Skip to main content

mkit_server/timers/
lease_sweep.rs

1//! Expired epoch-lease table cleanup, confined to the coordinator partition.
2
3use bytes::Bytes;
4use std::sync::Arc;
5
6use super::{DueTimer, Fired, TimerCtx, TimerHandler, TimerKind, registry::kinds};
7use crate::relay::{RELAY_LAG_BOUND_MS, source_relay_state};
8use crate::repo::RepoName;
9use crate::rt::BoxFuture;
10use crate::store::{Batch, NamespaceStore, Partition, Precondition, StoreError, codec, keys};
11use crate::telemetry::{METRIC_RELAY_LEASE_LAG, Metrics, NoopMetrics};
12
13/// Reference shared by the lease grant's timer and its sweep handler.
14#[must_use]
15pub fn lease_reference(repo: &RepoName, shard_ref: &str) -> Bytes {
16    Bytes::from([repo.as_str().as_bytes(), b"\0", shard_ref.as_bytes()].concat())
17}
18
19fn discard_malformed_reference(reason: &str) -> Fired {
20    tracing::warn!(
21        reason,
22        "discarding malformed epoch-lease sweep timer reference"
23    );
24    Fired::Done(Batch::new())
25}
26
27/// Deletes a coordinator lease-table row only once its actual expiry passes
28/// and its source outbox is empty.
29/// Malformed references are warned about and drained without touching lease rows.
30pub struct LeaseSweep<T> {
31    /// Client for reading ref shards. Absence keeps expired rows.
32    pub source: Option<T>,
33    metrics: Arc<dyn Metrics>,
34}
35
36impl<T: core::fmt::Debug> core::fmt::Debug for LeaseSweep<T> {
37    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
38        f.debug_struct("LeaseSweep")
39            .field("source", &self.source)
40            .finish_non_exhaustive()
41    }
42}
43
44impl<T> LeaseSweep<T> {
45    /// Use a source store to inspect ref-shard outboxes at expiry.
46    pub fn new(source: T) -> Self {
47        Self::optional(Some(source))
48    }
49
50    /// Retain rows when the shard client is unavailable.
51    pub fn optional(source: Option<T>) -> Self {
52        Self {
53            source,
54            metrics: Arc::new(NoopMetrics),
55        }
56    }
57
58    /// Attach the deployment's metrics sink for overdue kept rows.
59    #[must_use]
60    pub fn with_metrics(mut self, metrics: Arc<dyn Metrics>) -> Self {
61        self.metrics = metrics;
62        self
63    }
64}
65
66impl<S: NamespaceStore, T: NamespaceStore> TimerHandler<S> for LeaseSweep<T> {
67    fn kind(&self) -> TimerKind {
68        kinds::LEASE_SWEEP
69    }
70
71    fn fire<'a>(
72        &'a self,
73        ctx: &'a TimerCtx<'a, S>,
74        timer: &'a DueTimer,
75    ) -> BoxFuture<'a, Result<Fired, StoreError>> {
76        Box::pin(async move {
77            if !matches!(ctx.partition, Partition::Coordinator(_)) {
78                return Ok(Fired::Retry);
79            }
80            let Some(sep) = timer.reference.iter().position(|&byte| byte == 0) else {
81                return Ok(discard_malformed_reference("missing separator"));
82            };
83            let (Ok(repo), Ok(shard_ref)) = (
84                core::str::from_utf8(&timer.reference[..sep]),
85                core::str::from_utf8(&timer.reference[sep + 1..]),
86            ) else {
87                return Ok(discard_malformed_reference("invalid UTF-8"));
88            };
89            let Ok(repo) = RepoName::new(repo) else {
90                return Ok(discard_malformed_reference("invalid repository name"));
91            };
92            if !crate::refs::validate_ref_name(shard_ref) {
93                return Ok(discard_malformed_reference("invalid shard ref"));
94            }
95            let key = keys::leased_shard(&repo, shard_ref);
96            let Some(value) = ctx.store.get(ctx.partition, &key).await? else {
97                return Ok(Fired::Done(Batch::new()));
98            };
99            let lease = codec::decode_leased_shard(&value)?;
100            if timer.due_at_ms != lease.sweep_due_ms {
101                // A renewed row owns another timer. Drain only this stale one.
102                return Ok(Fired::Done(Batch::new()));
103            }
104            let batch = Batch::new().require(Precondition::Equals(key.clone(), value));
105            if ctx.now_ms >= lease.expires_at_ms {
106                let Some(source) = self.source.as_ref() else {
107                    return Ok(Fired::Retry);
108                };
109                let Partition::Coordinator(ns) = ctx.partition else {
110                    return Ok(Fired::Retry);
111                };
112                let shard = Partition::Ref {
113                    ns: ns.clone(),
114                    repo,
115                    shard_ref: shard_ref.into(),
116                };
117                let (reported, empty) = source_relay_state(source, &shard, ctx.now_ms).await?;
118                if empty {
119                    Ok(Fired::Done(batch.delete(key)))
120                } else {
121                    let kept_age_ms = ctx.now_ms.saturating_sub(lease.expires_at_ms);
122                    if kept_age_ms > RELAY_LAG_BOUND_MS {
123                        tracing::warn!(shard = ?shard, kept_age_ms, "relay backlog kept lease row beyond lag bound");
124                        self.metrics.incr(METRIC_RELAY_LEASE_LAG, &[], 1);
125                    }
126                    let due = ctx
127                        .now_ms
128                        .saturating_add(10_000)
129                        .max(timer.due_at_ms.saturating_add(1));
130                    let mut updated = lease;
131                    updated.relay_watermark_ms = updated.relay_watermark_ms.max(reported);
132                    updated.sweep_due_ms = due;
133                    Ok(Fired::Reschedule {
134                        due_at_ms: due,
135                        value: timer.value.clone(),
136                        batch: batch.put(key, codec::encode_leased_shard(&updated)),
137                    })
138                }
139            } else {
140                let mut updated = lease;
141                updated.sweep_due_ms = lease.expires_at_ms;
142                Ok(Fired::Reschedule {
143                    due_at_ms: lease.expires_at_ms,
144                    value: timer.value.clone(),
145                    batch: batch.put(key, codec::encode_leased_shard(&updated)),
146                })
147            }
148        })
149    }
150}
151
152#[cfg(all(test, feature = "memory"))]
153mod tests {
154    use super::*;
155    use crate::store::{BatchOutcome, Value};
156    use crate::store::{Cursor, Key, PartitionStats, ScanPage, StoreCapabilities};
157    use crate::timers::{TickBudget, TimerRegistry, run_due};
158    use crate::{ManualClock, MemoryKv, NamespaceKey};
159    use std::sync::{
160        Arc,
161        atomic::{AtomicU64, Ordering},
162    };
163
164    #[derive(Default)]
165    struct CountMetrics(AtomicU64);
166
167    impl Metrics for CountMetrics {
168        fn incr(&self, name: &'static str, _: &[(&'static str, &str)], by: u64) {
169            if name == METRIC_RELAY_LEASE_LAG {
170                self.0.fetch_add(by, Ordering::SeqCst);
171            }
172        }
173        fn observe_ms(&self, _: &'static str, _: &[(&'static str, &str)], _: f64) {}
174    }
175
176    fn partition() -> Partition {
177        Partition::Coordinator(NamespaceKey::deployment_default())
178    }
179
180    fn repo() -> RepoName {
181        RepoName::new("room").expect("valid test repository")
182    }
183
184    fn lease_key() -> crate::store::Key {
185        keys::leased_shard(&repo(), "refs/heads/main")
186    }
187
188    fn timer_key(due: u64) -> crate::store::Key {
189        keys::timer(
190            due,
191            kinds::LEASE_SWEEP.get(),
192            &lease_reference(&repo(), "refs/heads/main"),
193        )
194    }
195
196    fn lease(expires_at_ms: u64) -> codec::LeasedShard {
197        codec::LeasedShard {
198            authority_generation: None,
199            acked_authority_generation: None,
200            epoch: 3,
201            expires_at_ms,
202            acked_epoch: 2,
203            relay_watermark_ms: 0,
204            sweep_due_ms: expires_at_ms,
205        }
206    }
207
208    #[tokio::test]
209    async fn malformed_references_are_drained_without_deleting_lease_rows() {
210        let malformed: &[&[u8]] = &[
211            b"roomrefs/heads/main",
212            b"\xff\0refs/heads/main",
213            b"room\0refs/heads/\xff",
214            b"\0refs/heads/main",
215            b"has space\0refs/heads/main",
216            b"room\0refs/heads/..",
217            b"room\0refs/heads/main\0extra",
218        ];
219        for reference in malformed {
220            let clock = Arc::new(ManualClock::new(100));
221            let store = MemoryKv::with_clock(clock.clone());
222            let partition = partition();
223            let timer = keys::timer(100, kinds::LEASE_SWEEP.get(), reference);
224            let live_lease = codec::encode_leased_shard(&lease(1_000));
225            store
226                .apply(
227                    &partition,
228                    Batch::new()
229                        .put(lease_key(), live_lease.clone())
230                        .put(timer.clone(), Value::default()),
231                )
232                .await
233                .unwrap();
234            let report = run_due(
235                &store,
236                &partition,
237                &TimerRegistry::new().register(LeaseSweep::new(MemoryKv::default())),
238                clock.as_ref(),
239                100,
240                &TickBudget::default(),
241            )
242            .await
243            .unwrap();
244            assert_eq!(report.fired, 1, "malformed reference: {reference:?}");
245            assert_eq!(report.failed, 0, "malformed reference: {reference:?}");
246            assert!(store.get(&partition, &timer).await.unwrap().is_none());
247            assert_eq!(
248                store.get(&partition, &lease_key()).await.unwrap(),
249                Some(live_lease),
250            );
251        }
252    }
253
254    #[tokio::test]
255    async fn expiry_deletes_row_and_timer_atomically() {
256        let clock = Arc::new(ManualClock::new(100));
257        let store = MemoryKv::with_clock(clock.clone());
258        store
259            .apply(
260                &partition(),
261                Batch::new()
262                    .put(lease_key(), codec::encode_leased_shard(&lease(100)))
263                    .put(timer_key(100), Value::default()),
264            )
265            .await
266            .unwrap();
267        let registry = TimerRegistry::new().register(LeaseSweep::new(MemoryKv::default()));
268        let report = run_due(
269            &store,
270            &partition(),
271            &registry,
272            clock.as_ref(),
273            100,
274            &TickBudget::default(),
275        )
276        .await
277        .unwrap();
278        assert_eq!(report.fired, 1);
279        assert!(
280            store
281                .get(&partition(), &lease_key())
282                .await
283                .unwrap()
284                .is_none()
285        );
286        assert!(
287            store
288                .get(&partition(), &timer_key(100))
289                .await
290                .unwrap()
291                .is_none()
292        );
293    }
294
295    #[tokio::test]
296    async fn overdue_kept_row_emits_lag_metric() {
297        let clock = Arc::new(ManualClock::new(60_102));
298        let coordinator = MemoryKv::with_clock(clock.clone());
299        let source = MemoryKv::with_clock(clock.clone());
300        let shard = Partition::Ref {
301            ns: NamespaceKey::deployment_default(),
302            repo: repo(),
303            shard_ref: "refs/heads/main".into(),
304        };
305        source
306            .apply(
307                &shard,
308                Batch::new().put(
309                    keys::relay(1),
310                    codec::encode_relay(&codec::RelayV1 {
311                        at_ms: 50,
312                        target: partition(),
313                        puts: vec![(Key::new(&b"x\0"[..]), Value::default())],
314                        deletes: Vec::new(),
315                    })
316                    .unwrap(),
317                ),
318            )
319            .await
320            .unwrap();
321        coordinator
322            .apply(
323                &partition(),
324                Batch::new()
325                    .put(lease_key(), codec::encode_leased_shard(&lease(100)))
326                    .put(timer_key(100), Value::default()),
327            )
328            .await
329            .unwrap();
330        let metrics = Arc::new(CountMetrics::default());
331        let registry =
332            TimerRegistry::new().register(LeaseSweep::new(source).with_metrics(metrics.clone()));
333        let report = run_due(
334            &coordinator,
335            &partition(),
336            &registry,
337            clock.as_ref(),
338            60_102,
339            &TickBudget::default(),
340        )
341        .await
342        .unwrap();
343        assert_eq!(report.fired, 1);
344        assert_eq!(metrics.0.load(Ordering::SeqCst), 1);
345        assert!(
346            coordinator
347                .get(&partition(), &lease_key())
348                .await
349                .unwrap()
350                .is_some()
351        );
352    }
353
354    #[tokio::test]
355    async fn renewal_race_prevents_deletion_and_early_timer_moves_to_expiry() {
356        let store = MemoryKv::default();
357        let old_value = codec::encode_leased_shard(&lease(100));
358        store
359            .apply(&partition(), Batch::new().put(lease_key(), old_value))
360            .await
361            .unwrap();
362        let timer = DueTimer {
363            due_at_ms: 100,
364            kind: kinds::LEASE_SWEEP,
365            reference: lease_reference(&repo(), "refs/heads/main"),
366            value: Value::default(),
367        };
368        let ctx = TimerCtx {
369            store: &store,
370            partition: &partition(),
371            now_ms: 100,
372        };
373        let Fired::Done(batch) = LeaseSweep::new(MemoryKv::default())
374            .fire(&ctx, &timer)
375            .await
376            .unwrap()
377        else {
378            panic!("expired lease must produce a deletion");
379        };
380        let new_value = codec::encode_leased_shard(&lease(200));
381        store
382            .apply(
383                &partition(),
384                Batch::new().put(lease_key(), new_value.clone()),
385            )
386            .await
387            .unwrap();
388        assert!(matches!(
389            store.apply(&partition(), batch).await.unwrap(),
390            BatchOutcome::PreconditionFailed { .. }
391        ));
392        let Fired::Done(_) = LeaseSweep::new(MemoryKv::default())
393            .fire(&ctx, &timer)
394            .await
395            .unwrap()
396        else {
397            panic!("stale timer must be drained");
398        };
399        assert_eq!(
400            store.get(&partition(), &lease_key()).await.unwrap(),
401            Some(new_value)
402        );
403    }
404
405    struct UnreachableSource;
406    impl NamespaceStore for UnreachableSource {
407        fn capabilities(&self) -> StoreCapabilities {
408            StoreCapabilities::full()
409        }
410        async fn get(&self, _: &Partition, _: &Key) -> Result<Option<Value>, StoreError> {
411            unreachable!()
412        }
413        async fn scan(
414            &self,
415            _: &Partition,
416            _: &Key,
417            _: &Key,
418            _: Option<&Cursor>,
419            _: u32,
420        ) -> Result<ScanPage, StoreError> {
421            Err(StoreError::unavailable(std::io::Error::other(
422                "source unreachable",
423            )))
424        }
425        async fn apply(&self, _: &Partition, _: Batch) -> Result<BatchOutcome, StoreError> {
426            unreachable!()
427        }
428        async fn stats(&self, _: &Partition) -> Result<PartitionStats, StoreError> {
429            unreachable!()
430        }
431        async fn probe(&self) -> Result<(), StoreError> {
432            unreachable!()
433        }
434    }
435
436    #[tokio::test]
437    async fn missing_unreachable_and_corrupt_sources_hold_expired_row() {
438        let coordinator = MemoryKv::default();
439        let row = codec::encode_leased_shard(&lease(100));
440        coordinator
441            .apply(&partition(), Batch::new().put(lease_key(), row.clone()))
442            .await
443            .unwrap();
444        let timer = DueTimer {
445            due_at_ms: 100,
446            kind: kinds::LEASE_SWEEP,
447            reference: lease_reference(&repo(), "refs/heads/main"),
448            value: Value::default(),
449        };
450        let ctx = TimerCtx {
451            store: &coordinator,
452            partition: &partition(),
453            now_ms: 100,
454        };
455        assert!(matches!(
456            LeaseSweep::<MemoryKv>::optional(None)
457                .fire(&ctx, &timer)
458                .await
459                .unwrap(),
460            Fired::Retry
461        ));
462        assert!(
463            LeaseSweep::new(UnreachableSource)
464                .fire(&ctx, &timer)
465                .await
466                .is_err()
467        );
468        let source = MemoryKv::default();
469        let source_partition = Partition::Ref {
470            ns: NamespaceKey::deployment_default(),
471            repo: repo(),
472            shard_ref: "refs/heads/main".into(),
473        };
474        source
475            .apply(
476                &source_partition,
477                Batch::new().put(keys::relay(1), Value::new(&b"bad"[..])),
478            )
479            .await
480            .unwrap();
481        assert!(LeaseSweep::new(source).fire(&ctx, &timer).await.is_err());
482        assert_eq!(
483            coordinator.get(&partition(), &lease_key()).await.unwrap(),
484            Some(row)
485        );
486    }
487}