use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tracing::Instrument as _;
use crate::backend::DurableBackendEnum;
use crate::config::RetentionPolicy;
use crate::error::DurableError;
use crate::journal::Journal as _;
const CHECKPOINT_FORMAT_V1: u8 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FoldedStep {
pub(crate) step_id: u32,
pub(crate) idem_key: [u8; 32],
pub(crate) payload_version: u8,
pub(crate) payload: Bytes,
}
pub(crate) type CheckpointSnapshot = Vec<FoldedStep>;
const FOLDED_STEP_OVERHEAD: usize = 4 + 1 + 32 + 4;
pub(crate) fn folded_step_encoded_len(payload_len: usize) -> usize {
FOLDED_STEP_OVERHEAD.saturating_add(payload_len)
}
pub(crate) fn encode_checkpoint(steps: &[FoldedStep]) -> Vec<u8> {
let total: usize = steps
.iter()
.map(|s| folded_step_encoded_len(s.payload.len()))
.sum();
let mut out = Vec::with_capacity(5 + total);
out.push(CHECKPOINT_FORMAT_V1);
out.extend_from_slice(&u32::try_from(steps.len()).unwrap_or(u32::MAX).to_le_bytes());
for step in steps {
out.extend_from_slice(&step.step_id.to_le_bytes());
out.push(step.payload_version);
out.extend_from_slice(&step.idem_key);
out.extend_from_slice(
&u32::try_from(step.payload.len())
.unwrap_or(u32::MAX)
.to_le_bytes(),
);
out.extend_from_slice(&step.payload);
}
out
}
pub(crate) fn decode_checkpoint(bytes: &[u8]) -> Result<CheckpointSnapshot, DurableError> {
let mut cursor = Reader::new(bytes);
let version = cursor.u8()?;
if version != CHECKPOINT_FORMAT_V1 {
return Err(DurableError::Decode {
context: "checkpoint snapshot has an unknown format version",
});
}
let count = cursor.u32()? as usize;
let mut steps = Vec::with_capacity(count.min(1024));
for _ in 0..count {
let step_id = cursor.u32()?;
let payload_version = cursor.u8()?;
let idem_key = cursor.array32()?;
let len = cursor.u32()? as usize;
let payload = Bytes::copy_from_slice(cursor.take(len)?);
steps.push(FoldedStep {
step_id,
idem_key,
payload_version,
payload,
});
}
Ok(steps)
}
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, pos: 0 }
}
fn take(&mut self, len: usize) -> Result<&'a [u8], DurableError> {
let end = self.pos.checked_add(len).ok_or(DurableError::Decode {
context: "checkpoint snapshot length overflow",
})?;
let slice = self.bytes.get(self.pos..end).ok_or(DurableError::Decode {
context: "checkpoint snapshot is truncated",
})?;
self.pos = end;
Ok(slice)
}
fn u8(&mut self) -> Result<u8, DurableError> {
Ok(self.take(1)?[0])
}
fn u32(&mut self) -> Result<u32, DurableError> {
let bytes = self.take(4)?;
Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
fn array32(&mut self) -> Result<[u8; 32], DurableError> {
let mut out = [0u8; 32];
out.copy_from_slice(self.take(32)?);
Ok(out)
}
}
#[must_use]
pub(crate) fn step_cap_thresholds(max: u32) -> (u32, u32) {
if max == 0 {
return (u32::MAX, u32::MAX);
}
let soft = u32::try_from(u64::from(max) * 9 / 10).unwrap_or(max);
(soft, max)
}
#[derive(Debug)]
pub struct DurableRetentionService {
backend: Arc<DurableBackendEnum>,
policy: RetentionPolicy,
interval: Duration,
}
impl DurableRetentionService {
#[must_use]
pub fn new(backend: Arc<DurableBackendEnum>, policy: RetentionPolicy) -> Self {
let interval = Duration::from_secs(policy.prune_interval_secs.max(1));
Self {
backend,
policy,
interval,
}
}
#[tracing::instrument(name = "durable.retention.run", skip_all)]
pub async fn run(self) {
let mut tick = tokio::time::interval(self.interval);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
tick.tick().await;
loop {
tick.tick().await;
async {
match self.backend.sweep_orphans(&self.policy).await {
Ok(aborted) => {
tracing::debug!(aborted, "durable retention crash-orphan sweep completed");
}
Err(error) => {
tracing::warn!(%error, "durable retention crash-orphan sweep failed; will retry");
}
}
match self.backend.prune(&self.policy).await {
Ok(deleted) => {
tracing::debug!(deleted, "durable retention prune sweep completed");
}
Err(error) => {
tracing::warn!(%error, "durable retention prune sweep failed; will retry");
}
}
}
.instrument(tracing::info_span!("durable.retention.run.iter"))
.await;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SweepCursor {
pub(crate) updated_at_ms: i64,
pub(crate) execution_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SweepBatchOutcome {
pub(crate) scanned: u64,
pub(crate) aborted: u64,
pub(crate) next_cursor: Option<SweepCursor>,
}
pub(crate) async fn sweep_orphans_in_batches<F, Fut>(
batch_size: u64,
cutoff_ms: i64,
sweep_batch: F,
) -> Result<u64, DurableError>
where
F: Fn(i64, u64, Option<SweepCursor>) -> Fut,
Fut: Future<Output = Result<SweepBatchOutcome, DurableError>>,
{
let batch = batch_size.max(1);
let mut total_aborted = 0u64;
let mut cursor: Option<SweepCursor> = None;
let span = tracing::info_span!(
"durable.retention.sweep_orphans",
aborted_count = tracing::field::Empty
);
async {
loop {
let outcome = sweep_batch(cutoff_ms, batch, cursor.take()).await?;
total_aborted = total_aborted.saturating_add(outcome.aborted);
if outcome.scanned < batch {
break;
}
cursor = outcome.next_cursor;
if cursor.is_none() {
break;
}
tokio::task::yield_now().await;
}
tracing::Span::current().record("aborted_count", total_aborted);
metrics::counter!("durable.retention.orphans_aborted").increment(total_aborted);
Ok(total_aborted)
}
.instrument(span)
.await
}
pub(crate) async fn prune_in_batches<F, Fut>(
policy: &RetentionPolicy,
now_ms: i64,
delete_batch: F,
) -> Result<u64, DurableError>
where
F: Fn(PruneCutoffs, u64) -> Fut,
Fut: Future<Output = Result<u64, DurableError>>,
{
let cutoffs = PruneCutoffs::from_policy(policy, now_ms);
let batch = policy.prune_batch_size.max(1);
let mut total = 0u64;
let span = tracing::info_span!(
"durable.journal.prune",
deleted_count = tracing::field::Empty
);
async {
loop {
let deleted = delete_batch(cutoffs, batch).await?;
total = total.saturating_add(deleted);
if deleted < batch {
break;
}
tokio::task::yield_now().await;
}
tracing::Span::current().record("deleted_count", total);
Ok(total)
}
.instrument(span)
.await
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PruneCutoffs {
pub(crate) completed_before_ms: i64,
pub(crate) failed_before_ms: i64,
}
impl PruneCutoffs {
pub(crate) fn from_policy(policy: &RetentionPolicy, now_ms: i64) -> Self {
let completed =
i64::try_from(policy.ttl_completed_secs.saturating_mul(1000)).unwrap_or(i64::MAX);
let failed = i64::try_from(policy.ttl_failed_secs.saturating_mul(1000)).unwrap_or(i64::MAX);
Self {
completed_before_ms: now_ms.saturating_sub(completed),
failed_before_ms: now_ms.saturating_sub(failed),
}
}
}
#[must_use]
pub(crate) fn checkpoint_budget(max_payload_bytes: u64) -> usize {
usize::try_from(max_payload_bytes).unwrap_or(usize::MAX)
}
#[must_use]
pub(crate) fn fold_prefix_len(payload_lens: &[usize], budget: usize) -> usize {
let mut used = 5usize; let mut taken = 0usize;
for &len in payload_lens {
let next = used.saturating_add(folded_step_encoded_len(len));
if next > budget {
break;
}
used = next;
taken += 1;
}
taken
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
fn folded(step: u32, payload: &[u8]) -> FoldedStep {
FoldedStep {
step_id: step,
idem_key: [u8::try_from(step % 256).unwrap_or(0); 32],
payload_version: 1,
payload: Bytes::copy_from_slice(payload),
}
}
#[test]
fn checkpoint_round_trips() {
let steps = vec![
folded(0, b"alpha"),
folded(1, b""),
folded(2, b"gamma-payload"),
];
let encoded = encode_checkpoint(&steps);
let decoded = decode_checkpoint(&encoded).unwrap();
assert_eq!(decoded, steps);
}
#[test]
fn decode_rejects_truncation() {
let steps = vec![folded(0, b"data")];
let mut encoded = encode_checkpoint(&steps);
encoded.truncate(encoded.len() - 2);
assert_matches!(
decode_checkpoint(&encoded),
Err(DurableError::Decode { .. })
);
}
#[test]
fn decode_rejects_unknown_version() {
let mut encoded = encode_checkpoint(&[folded(0, b"x")]);
encoded[0] = 99;
assert_matches!(
decode_checkpoint(&encoded),
Err(DurableError::Decode { .. })
);
}
#[test]
fn step_cap_thresholds_are_ninety_percent_and_full() {
assert_eq!(step_cap_thresholds(10_000), (9_000, 10_000));
assert_eq!(step_cap_thresholds(10), (9, 10));
assert_eq!(step_cap_thresholds(0), (u32::MAX, u32::MAX));
}
#[test]
fn fold_prefix_respects_budget() {
let lens = vec![4, 4, 4, 4];
assert_eq!(fold_prefix_len(&lens, 95), 2);
assert_eq!(fold_prefix_len(&[10_000], 50), 0);
}
#[test]
fn prune_cutoffs_subtract_ttl_from_now() {
let policy = RetentionPolicy {
ttl_completed_secs: 10,
ttl_failed_secs: 20,
..RetentionPolicy::default()
};
let cutoffs = PruneCutoffs::from_policy(&policy, 100_000);
assert_eq!(cutoffs.completed_before_ms, 90_000);
assert_eq!(cutoffs.failed_before_ms, 80_000);
assert_eq!(checkpoint_budget(1_048_576), 1_048_576);
}
#[tokio::test]
async fn prune_in_batches_loops_until_drained_and_yields() {
use std::cell::Cell;
let remaining = Cell::new(1_620u64);
let policy = RetentionPolicy::default();
let total = prune_in_batches(&policy, 0, |_cutoffs, batch| {
let deleted = remaining.get().min(batch);
remaining.set(remaining.get() - deleted);
async move { Ok(deleted) }
})
.await
.unwrap();
assert_eq!(total, 1_620);
assert_eq!(remaining.get(), 0);
}
#[tokio::test]
async fn sweep_orphans_in_batches_advances_past_an_all_lock_held_batch() {
const POOL_SIZE: u64 = 5;
const BATCH_SIZE: u64 = 2;
let sweep = sweep_orphans_in_batches(BATCH_SIZE, 0, |_cutoff, batch, cursor| {
let start = cursor.map_or(0, |c| u64::try_from(c.updated_at_ms).unwrap() + 1);
let end = (start + batch).min(POOL_SIZE);
let scanned = end.saturating_sub(start);
let aborted = if start == 2 { 0 } else { scanned };
let next_cursor = (scanned > 0).then(|| SweepCursor {
updated_at_ms: i64::try_from(end - 1).unwrap(),
execution_id: String::new(),
});
async move {
Ok(SweepBatchOutcome {
scanned,
aborted,
next_cursor,
})
}
});
let total_aborted = tokio::time::timeout(std::time::Duration::from_secs(5), sweep)
.await
.expect(
"sweep_orphans_in_batches must terminate even when a batch is entirely \
lock-held (#6254 C1 regression) — it hung instead of returning",
)
.unwrap();
assert_eq!(
total_aborted,
POOL_SIZE - BATCH_SIZE,
"every row except the all-locked middle batch must be aborted"
);
}
}