Skip to main content

zeph_durable/
retention.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Journal retention, the background prune sweep, and the checkpoint-fold codec.
5//!
6//! Two mechanisms bound journal growth, and neither runs on the step-dispatch hot path (spec NEVER):
7//!
8//! - **Background prune** — [`DurableRetentionService`] is a tokio task that wakes every
9//!   `prune_interval_secs` and calls [`Journal::prune`](crate::Journal::prune), which deletes
10//!   *terminal* executions older than their TTL in `prune_batch_size` batches, yielding between
11//!   batches so a large sweep never holds the write lock.
12//! - **In-execution checkpoint fold** — a long *in-flight* execution that crosses the soft step cap
13//!   (90% of `max_steps_per_execution`) folds its committed-idempotent prefix into a single
14//!   [`Checkpoint`](crate::EntryKind::Checkpoint) entry. The fold packs each folded step's replay
15//!   value into the checkpoint snapshot and deletes the individual rows, so a resume still replays
16//!   those steps from the snapshot rather than re-running them. The hard cap (100%) aborts the
17//!   execution with [`DurableError::StepCapExceeded`].
18
19use std::sync::Arc;
20use std::time::Duration;
21
22use bytes::Bytes;
23use tracing::Instrument as _;
24
25use crate::backend::DurableBackendEnum;
26use crate::config::RetentionPolicy;
27use crate::error::DurableError;
28use crate::journal::Journal as _;
29
30/// Wire-format version for the checkpoint snapshot encoding.
31const CHECKPOINT_FORMAT_V1: u8 = 1;
32
33/// One step's replay value, folded into a checkpoint snapshot.
34///
35/// The fold preserves exactly what a resume needs to replay the step without re-running its
36/// operation: the [`IdempotencyKey`](crate::IdempotencyKey) bytes (so the replay-divergence guard
37/// still matches, INV-3), the payload wire-format version, and the *plaintext* result bytes (the
38/// snapshot as a whole is AEAD-sealed by the backend, so individual step payloads need no further
39/// sealing inside it).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub(crate) struct FoldedStep {
42    /// Position of the folded step within its execution.
43    pub(crate) step_id: u32,
44    /// The step's 32-byte idempotency key, for the divergence guard on replay.
45    pub(crate) idem_key: [u8; 32],
46    /// Wire-format version of the result payload.
47    pub(crate) payload_version: u8,
48    /// Plaintext result bytes.
49    pub(crate) payload: Bytes,
50}
51
52/// A decoded checkpoint snapshot: the folded prefix of an execution, in step order.
53pub(crate) type CheckpointSnapshot = Vec<FoldedStep>;
54
55/// Per-step fixed framing overhead in the encoded snapshot (step + version + `idem_key` + len).
56const FOLDED_STEP_OVERHEAD: usize = 4 + 1 + 32 + 4;
57
58/// Encoded size one [`FoldedStep`] contributes (framing + payload).
59pub(crate) fn folded_step_encoded_len(payload_len: usize) -> usize {
60    FOLDED_STEP_OVERHEAD.saturating_add(payload_len)
61}
62
63/// Serialize a checkpoint snapshot into a compact, self-describing byte buffer.
64///
65/// Layout: `version(1) || count(u32 le) || [ step(u32 le) version(1) idem_key(32) len(u32 le)
66/// payload(len) ]*`. Fixed-width framing keeps the encoding injective and the per-step size
67/// predictable, so the backend can cut the fold at the payload ceiling without trial encoding.
68pub(crate) fn encode_checkpoint(steps: &[FoldedStep]) -> Vec<u8> {
69    let total: usize = steps
70        .iter()
71        .map(|s| folded_step_encoded_len(s.payload.len()))
72        .sum();
73    let mut out = Vec::with_capacity(5 + total);
74    out.push(CHECKPOINT_FORMAT_V1);
75    out.extend_from_slice(&u32::try_from(steps.len()).unwrap_or(u32::MAX).to_le_bytes());
76    for step in steps {
77        out.extend_from_slice(&step.step_id.to_le_bytes());
78        out.push(step.payload_version);
79        out.extend_from_slice(&step.idem_key);
80        out.extend_from_slice(
81            &u32::try_from(step.payload.len())
82                .unwrap_or(u32::MAX)
83                .to_le_bytes(),
84        );
85        out.extend_from_slice(&step.payload);
86    }
87    out
88}
89
90/// Decode a checkpoint snapshot, failing closed on truncation or an unknown format version.
91///
92/// # Errors
93///
94/// Returns [`DurableError::Decode`] if the buffer is truncated, declares more steps than it
95/// contains, or carries an unrecognized format version.
96pub(crate) fn decode_checkpoint(bytes: &[u8]) -> Result<CheckpointSnapshot, DurableError> {
97    let mut cursor = Reader::new(bytes);
98    let version = cursor.u8()?;
99    if version != CHECKPOINT_FORMAT_V1 {
100        return Err(DurableError::Decode {
101            context: "checkpoint snapshot has an unknown format version",
102        });
103    }
104    let count = cursor.u32()? as usize;
105    let mut steps = Vec::with_capacity(count.min(1024));
106    for _ in 0..count {
107        let step_id = cursor.u32()?;
108        let payload_version = cursor.u8()?;
109        let idem_key = cursor.array32()?;
110        let len = cursor.u32()? as usize;
111        let payload = Bytes::copy_from_slice(cursor.take(len)?);
112        steps.push(FoldedStep {
113            step_id,
114            idem_key,
115            payload_version,
116            payload,
117        });
118    }
119    Ok(steps)
120}
121
122/// A bounds-checked forward reader over the snapshot buffer; every read fails closed on underrun.
123struct Reader<'a> {
124    bytes: &'a [u8],
125    pos: usize,
126}
127
128impl<'a> Reader<'a> {
129    fn new(bytes: &'a [u8]) -> Self {
130        Self { bytes, pos: 0 }
131    }
132
133    fn take(&mut self, len: usize) -> Result<&'a [u8], DurableError> {
134        let end = self.pos.checked_add(len).ok_or(DurableError::Decode {
135            context: "checkpoint snapshot length overflow",
136        })?;
137        let slice = self.bytes.get(self.pos..end).ok_or(DurableError::Decode {
138            context: "checkpoint snapshot is truncated",
139        })?;
140        self.pos = end;
141        Ok(slice)
142    }
143
144    fn u8(&mut self) -> Result<u8, DurableError> {
145        Ok(self.take(1)?[0])
146    }
147
148    fn u32(&mut self) -> Result<u32, DurableError> {
149        let bytes = self.take(4)?;
150        Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
151    }
152
153    fn array32(&mut self) -> Result<[u8; 32], DurableError> {
154        let mut out = [0u8; 32];
155        out.copy_from_slice(self.take(32)?);
156        Ok(out)
157    }
158}
159
160/// Compute the soft and hard step-cap thresholds for a `max_steps_per_execution` budget.
161///
162/// The soft threshold (90%) triggers a checkpoint fold; the hard threshold (the cap itself) aborts.
163/// A `max` of zero disables both (returns `(u32::MAX, u32::MAX)`), so an unconfigured cap never folds
164/// or aborts.
165#[must_use]
166pub(crate) fn step_cap_thresholds(max: u32) -> (u32, u32) {
167    if max == 0 {
168        return (u32::MAX, u32::MAX);
169    }
170    // `max * 9 / 10 <= max`, so the result always fits back into u32; the widening guards the
171    // intermediate product against overflow.
172    let soft = u32::try_from(u64::from(max) * 9 / 10).unwrap_or(max);
173    (soft, max)
174}
175
176/// Background task that prunes terminal executions on a fixed interval.
177///
178/// Spawn [`DurableRetentionService::run`] on a supervised task (alongside the
179/// [`JournalWriter`](crate::JournalWriter)). It owns no write path of its own — it calls
180/// [`Journal::prune`](crate::Journal::prune) on the shared backend, which performs the batched delete
181/// off the hot path.
182#[derive(Debug)]
183pub struct DurableRetentionService {
184    backend: Arc<DurableBackendEnum>,
185    policy: RetentionPolicy,
186    interval: Duration,
187}
188
189impl DurableRetentionService {
190    /// Build the service from the shared backend and the configured retention policy.
191    ///
192    /// # Examples
193    ///
194    /// ```no_run
195    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
196    /// use std::sync::Arc;
197    /// use zeph_durable::{DurableBackendEnum, DurableRetentionService, LocalBackend, RetentionPolicy};
198    ///
199    /// let backend = Arc::new(DurableBackendEnum::Local(Arc::new(
200    ///     LocalBackend::open("durable.db", 1_048_576).await?,
201    /// )));
202    /// let service = DurableRetentionService::new(backend, RetentionPolicy::default());
203    /// let task = tokio::spawn(service.run());
204    /// # let _ = task;
205    /// # Ok(()) }
206    /// ```
207    #[must_use]
208    pub fn new(backend: Arc<DurableBackendEnum>, policy: RetentionPolicy) -> Self {
209        let interval = Duration::from_secs(policy.prune_interval_secs.max(1));
210        Self {
211            backend,
212            policy,
213            interval,
214        }
215    }
216
217    /// Run the prune loop until the task is aborted.
218    ///
219    /// Each tick first runs the crash-orphan sweep (#6254), then prunes terminal executions older
220    /// than their TTL — in that order, so a just-aborted orphan is visible to the same tick's TTL
221    /// check (INV-17, M3). A sweep or prune failure is logged and the loop continues (a transient
222    /// database error must not kill retention).
223    #[tracing::instrument(name = "durable.retention.run", skip_all)]
224    pub async fn run(self) {
225        let mut tick = tokio::time::interval(self.interval);
226        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
227        // The first immediate tick fires at startup; skip pruning on it so a just-launched daemon
228        // does not sweep before the first real interval elapses.
229        tick.tick().await;
230        loop {
231            tick.tick().await;
232            async {
233                match self.backend.sweep_orphans(&self.policy).await {
234                    Ok(aborted) => {
235                        tracing::debug!(aborted, "durable retention crash-orphan sweep completed");
236                    }
237                    Err(error) => {
238                        tracing::warn!(%error, "durable retention crash-orphan sweep failed; will retry");
239                    }
240                }
241                match self.backend.prune(&self.policy).await {
242                    Ok(deleted) => {
243                        tracing::debug!(deleted, "durable retention prune sweep completed");
244                    }
245                    Err(error) => {
246                        tracing::warn!(%error, "durable retention prune sweep failed; will retry");
247                    }
248                }
249            }
250            .instrument(tracing::info_span!("durable.retention.run.iter"))
251            .await;
252        }
253    }
254}
255
256/// A keyset-pagination cursor over `durable_executions(updated_at, execution_id)`, used by the
257/// crash-orphan sweep to guarantee forward progress across batches (#6254 C1).
258///
259/// Ordering by `(updated_at, execution_id)` (not `updated_at` alone) gives a total order even
260/// when several rows share the same `updated_at` millisecond, so no candidate is ever skipped or
261/// revisited across batch boundaries.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub(crate) struct SweepCursor {
264    /// `updated_at` of the last row this batch scanned.
265    pub(crate) updated_at_ms: i64,
266    /// `execution_id` (as stored, a UUID string) of the last row this batch scanned.
267    pub(crate) execution_id: String,
268}
269
270/// The outcome of one batch of the crash-orphan sweep: how many `running` rows this batch
271/// scanned (drives the caller's batch-continuation decision, mirroring [`prune_in_batches`]'s
272/// `deleted < batch` check), how many of those were actually aborted (a candidate whose
273/// [`ExecutionLock`](crate::backend::ExecutionLock) is held by a live owner is scanned but not
274/// aborted — INV-17), and the keyset cursor to resume from on the next batch.
275///
276/// `next_cursor` is the load-bearing fix for #6254 C1: the sweep never deletes or otherwise
277/// removes a skipped (lock-held) candidate from `durable_executions`, so a batch that re-issued
278/// the *same* unbounded `SELECT ... LIMIT batch` on every iteration would re-select the exact
279/// same lock-held rows forever whenever the live-but-stale count reaches or exceeds `batch` —
280/// `scanned` would stay `== batch` and `aborted` would stay `0` on every iteration, so the
281/// `scanned < batch` continuation check would never trip and the loop would never terminate.
282/// Advancing past `next_cursor` on every batch — whether or not any row in it was aborted —
283/// guarantees the candidate set strictly shrinks each iteration regardless of lock outcomes.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub(crate) struct SweepBatchOutcome {
286    /// Number of `status='running'` candidates this batch scanned.
287    pub(crate) scanned: u64,
288    /// Number of those candidates this batch actually hard-aborted.
289    pub(crate) aborted: u64,
290    /// Keyset cursor positioned at the last-scanned row, `None` if this batch scanned zero rows.
291    pub(crate) next_cursor: Option<SweepCursor>,
292}
293
294/// Run one batched crash-orphan sweep pass, hard-aborting stale `running` executions whose
295/// INV-15 `ExecutionLock` is free (#6254).
296///
297/// This is the shared body behind [`Journal::sweep_orphans`](crate::Journal::sweep_orphans) for
298/// the local backend. Mirrors [`prune_in_batches`]'s batch/yield discipline so a large sweep
299/// never monopolizes the runtime, but continues based on `scanned` (not `aborted`) — a batch
300/// where every candidate's lock is held by a live owner still scanned a full `batch` and must not
301/// be mistaken for "sweep exhausted". Termination is guaranteed by keyset pagination
302/// ([`SweepCursor`]): each batch's `sweep_batch` call is handed the previous batch's cursor and
303/// scans strictly past it, so the same lock-held row is never re-selected across batches (#6254
304/// C1 — without this, an all-lock-held batch >= `batch_size` would loop forever).
305pub(crate) async fn sweep_orphans_in_batches<F, Fut>(
306    batch_size: u64,
307    cutoff_ms: i64,
308    sweep_batch: F,
309) -> Result<u64, DurableError>
310where
311    F: Fn(i64, u64, Option<SweepCursor>) -> Fut,
312    Fut: Future<Output = Result<SweepBatchOutcome, DurableError>>,
313{
314    let batch = batch_size.max(1);
315    let mut total_aborted = 0u64;
316    let mut cursor: Option<SweepCursor> = None;
317    let span = tracing::info_span!(
318        "durable.retention.sweep_orphans",
319        aborted_count = tracing::field::Empty
320    );
321    async {
322        loop {
323            let outcome = sweep_batch(cutoff_ms, batch, cursor.take()).await?;
324            total_aborted = total_aborted.saturating_add(outcome.aborted);
325            if outcome.scanned < batch {
326                break;
327            }
328            cursor = outcome.next_cursor;
329            // A full batch with no cursor to resume from cannot happen (scanned == batch >= 1
330            // rows implies a last row to build a cursor from) — but fail closed rather than
331            // looping forever on a future refactor that breaks this invariant.
332            if cursor.is_none() {
333                break;
334            }
335            // Release the write lock and let other tasks run before the next batch.
336            tokio::task::yield_now().await;
337        }
338        tracing::Span::current().record("aborted_count", total_aborted);
339        metrics::counter!("durable.retention.orphans_aborted").increment(total_aborted);
340        Ok(total_aborted)
341    }
342    .instrument(span)
343    .await
344}
345
346/// Run one batched prune pass over the journal, deleting terminal executions past their TTL.
347///
348/// This is the shared body behind [`Journal::prune`](crate::Journal::prune) for the local backend.
349/// It is a free function (rather than a method) so the backend can keep its prune implementation thin
350/// while the batching/yielding policy lives next to the rest of retention. `delete_batch` performs
351/// one bounded `DELETE` transaction and returns the rows it removed; the loop yields between batches
352/// so a large sweep never monopolizes the runtime.
353pub(crate) async fn prune_in_batches<F, Fut>(
354    policy: &RetentionPolicy,
355    now_ms: i64,
356    delete_batch: F,
357) -> Result<u64, DurableError>
358where
359    F: Fn(PruneCutoffs, u64) -> Fut,
360    Fut: Future<Output = Result<u64, DurableError>>,
361{
362    let cutoffs = PruneCutoffs::from_policy(policy, now_ms);
363    let batch = policy.prune_batch_size.max(1);
364    let mut total = 0u64;
365    let span = tracing::info_span!(
366        "durable.journal.prune",
367        deleted_count = tracing::field::Empty
368    );
369    async {
370        loop {
371            let deleted = delete_batch(cutoffs, batch).await?;
372            total = total.saturating_add(deleted);
373            if deleted < batch {
374                break;
375            }
376            // Release the write lock and let other tasks run before the next batch.
377            tokio::task::yield_now().await;
378        }
379        tracing::Span::current().record("deleted_count", total);
380        Ok(total)
381    }
382    .instrument(span)
383    .await
384}
385
386/// The absolute `finalized_at` cutoffs (Unix ms) below which a terminal execution is prunable.
387#[derive(Debug, Clone, Copy)]
388pub(crate) struct PruneCutoffs {
389    /// Completed executions finalized at or before this instant are prunable.
390    pub(crate) completed_before_ms: i64,
391    /// Failed/aborted executions finalized at or before this instant are prunable.
392    pub(crate) failed_before_ms: i64,
393}
394
395impl PruneCutoffs {
396    pub(crate) fn from_policy(policy: &RetentionPolicy, now_ms: i64) -> Self {
397        let completed =
398            i64::try_from(policy.ttl_completed_secs.saturating_mul(1000)).unwrap_or(i64::MAX);
399        let failed = i64::try_from(policy.ttl_failed_secs.saturating_mul(1000)).unwrap_or(i64::MAX);
400        Self {
401            completed_before_ms: now_ms.saturating_sub(completed),
402            failed_before_ms: now_ms.saturating_sub(failed),
403        }
404    }
405}
406
407/// The largest payload the backend will pack into a single checkpoint snapshot.
408///
409/// The fold cuts its prefix at this ceiling so a checkpoint entry obeys the same `max_payload_bytes`
410/// read/write guard as any other payload (INV-11). Steps that do not fit stay un-folded until a later
411/// checkpoint.
412#[must_use]
413pub(crate) fn checkpoint_budget(max_payload_bytes: u64) -> usize {
414    usize::try_from(max_payload_bytes).unwrap_or(usize::MAX)
415}
416
417/// Decide how many leading folded steps fit within the checkpoint payload budget.
418///
419/// Returns the count of steps from the front of `payload_lens` whose cumulative encoded size stays
420/// within `budget` (including the 5-byte snapshot header). A single step larger than the whole budget
421/// yields `0`, leaving it un-folded rather than producing an over-limit checkpoint.
422#[must_use]
423pub(crate) fn fold_prefix_len(payload_lens: &[usize], budget: usize) -> usize {
424    let mut used = 5usize; // version + count header
425    let mut taken = 0usize;
426    for &len in payload_lens {
427        let next = used.saturating_add(folded_step_encoded_len(len));
428        if next > budget {
429            break;
430        }
431        used = next;
432        taken += 1;
433    }
434    taken
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use std::assert_matches;
441
442    fn folded(step: u32, payload: &[u8]) -> FoldedStep {
443        FoldedStep {
444            step_id: step,
445            idem_key: [u8::try_from(step % 256).unwrap_or(0); 32],
446            payload_version: 1,
447            payload: Bytes::copy_from_slice(payload),
448        }
449    }
450
451    #[test]
452    fn checkpoint_round_trips() {
453        let steps = vec![
454            folded(0, b"alpha"),
455            folded(1, b""),
456            folded(2, b"gamma-payload"),
457        ];
458        let encoded = encode_checkpoint(&steps);
459        let decoded = decode_checkpoint(&encoded).unwrap();
460        assert_eq!(decoded, steps);
461    }
462
463    #[test]
464    fn decode_rejects_truncation() {
465        let steps = vec![folded(0, b"data")];
466        let mut encoded = encode_checkpoint(&steps);
467        encoded.truncate(encoded.len() - 2);
468        assert_matches!(
469            decode_checkpoint(&encoded),
470            Err(DurableError::Decode { .. })
471        );
472    }
473
474    #[test]
475    fn decode_rejects_unknown_version() {
476        let mut encoded = encode_checkpoint(&[folded(0, b"x")]);
477        encoded[0] = 99;
478        assert_matches!(
479            decode_checkpoint(&encoded),
480            Err(DurableError::Decode { .. })
481        );
482    }
483
484    #[test]
485    fn step_cap_thresholds_are_ninety_percent_and_full() {
486        assert_eq!(step_cap_thresholds(10_000), (9_000, 10_000));
487        assert_eq!(step_cap_thresholds(10), (9, 10));
488        assert_eq!(step_cap_thresholds(0), (u32::MAX, u32::MAX));
489    }
490
491    #[test]
492    fn fold_prefix_respects_budget() {
493        // Each step encodes to 41 + payload; with a 4-byte payload that is 45 bytes + the 5-byte
494        // header. A budget of 5 + 45 + 45 = 95 admits exactly two steps.
495        let lens = vec![4, 4, 4, 4];
496        assert_eq!(fold_prefix_len(&lens, 95), 2);
497        // A step larger than the entire budget is left un-folded.
498        assert_eq!(fold_prefix_len(&[10_000], 50), 0);
499    }
500
501    #[test]
502    fn prune_cutoffs_subtract_ttl_from_now() {
503        let policy = RetentionPolicy {
504            ttl_completed_secs: 10,
505            ttl_failed_secs: 20,
506            ..RetentionPolicy::default()
507        };
508        let cutoffs = PruneCutoffs::from_policy(&policy, 100_000);
509        assert_eq!(cutoffs.completed_before_ms, 90_000);
510        assert_eq!(cutoffs.failed_before_ms, 80_000);
511        assert_eq!(checkpoint_budget(1_048_576), 1_048_576);
512    }
513
514    #[tokio::test]
515    async fn prune_in_batches_loops_until_drained_and_yields() {
516        use std::cell::Cell;
517        // Three full batches (500) then a short one (120) → four calls, totalling 1620.
518        let remaining = Cell::new(1_620u64);
519        let policy = RetentionPolicy::default();
520        let total = prune_in_batches(&policy, 0, |_cutoffs, batch| {
521            let deleted = remaining.get().min(batch);
522            remaining.set(remaining.get() - deleted);
523            async move { Ok(deleted) }
524        })
525        .await
526        .unwrap();
527        assert_eq!(total, 1_620);
528        assert_eq!(remaining.get(), 0);
529    }
530
531    /// Mirrors [`prune_in_batches_loops_until_drained_and_yields`] but for the sweep, which
532    /// continues on `scanned` rather than `aborted` (#6254): a batch where every candidate's
533    /// `ExecutionLock` is held by a live owner still scans a full batch and must not be mistaken
534    /// for "sweep exhausted". The closure below draws from a *fixed*, non-shrinking pool of
535    /// candidate rows addressed purely by the `cursor` it is handed — exactly what
536    /// `LocalBackend::sweep_orphan_batch`'s keyset-paginated SQL does — rather than an internal
537    /// counter that shrinks regardless of lock outcome. This is deliberate: a version of this
538    /// test that shrinks an internal "remaining" counter on every batch (aborted or not) passes
539    /// even against the pre-fix implementation, which never advanced a cursor and would re-select
540    /// the identical lock-held rows forever in production — it asserts nothing about the actual
541    /// #6254 C1 bug. Middle batch (rows `[2, 4)`) aborts nothing, simulating "every candidate in
542    /// this batch is lock-held"; the sweep must still advance past those rows via `next_cursor`
543    /// and finish the remaining pool. Wrapped in a timeout so a regression that drops cursor
544    /// advancement fails this test instead of hanging the whole suite.
545    #[tokio::test]
546    async fn sweep_orphans_in_batches_advances_past_an_all_lock_held_batch() {
547        const POOL_SIZE: u64 = 5;
548        const BATCH_SIZE: u64 = 2;
549
550        let sweep = sweep_orphans_in_batches(BATCH_SIZE, 0, |_cutoff, batch, cursor| {
551            let start = cursor.map_or(0, |c| u64::try_from(c.updated_at_ms).unwrap() + 1);
552            let end = (start + batch).min(POOL_SIZE);
553            let scanned = end.saturating_sub(start);
554            // Rows [2, 4) simulate "every candidate in this batch is lock-held": 0 aborted.
555            let aborted = if start == 2 { 0 } else { scanned };
556            let next_cursor = (scanned > 0).then(|| SweepCursor {
557                updated_at_ms: i64::try_from(end - 1).unwrap(),
558                execution_id: String::new(),
559            });
560            async move {
561                Ok(SweepBatchOutcome {
562                    scanned,
563                    aborted,
564                    next_cursor,
565                })
566            }
567        });
568
569        let total_aborted = tokio::time::timeout(std::time::Duration::from_secs(5), sweep)
570            .await
571            .expect(
572                "sweep_orphans_in_batches must terminate even when a batch is entirely \
573                 lock-held (#6254 C1 regression) — it hung instead of returning",
574            )
575            .unwrap();
576
577        assert_eq!(
578            total_aborted,
579            POOL_SIZE - BATCH_SIZE,
580            "every row except the all-locked middle batch must be aborted"
581        );
582    }
583}