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}