1use 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#[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
27pub struct LeaseSweep<T> {
31 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 pub fn new(source: T) -> Self {
47 Self::optional(Some(source))
48 }
49
50 pub fn optional(source: Option<T>) -> Self {
52 Self {
53 source,
54 metrics: Arc::new(NoopMetrics),
55 }
56 }
57
58 #[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 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 ®istry,
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 ®istry,
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}