1use liminal::durability::{DurabilityError, DurableStore, RoutedBatchItem};
13use serde::{Deserialize, Serialize};
14
15use super::log::{OperationLogError, STREAM_PREFIX};
16use super::operation_batch::OperationBatch;
17use super::outbox_log::OUTBOX_STREAM_PREFIX;
18use super::restore_checkpoint_locator::checkpoint_conversation_route;
19use super::routed_stream_codec;
20
21pub(super) const PRUNE_RECEIPT_PREFIX: &str = "prune:";
23
24pub const DEFAULT_KEEP_ACKNOWLEDGED_BEHIND_CHECKPOINT: u64 = 512;
32
33#[must_use]
36pub const fn effective_keep(pruning: Option<crate::config::types::PruningConfig>) -> u64 {
37 match pruning {
38 Some(configured) => configured.keep_acknowledged_behind_checkpoint,
39 None => DEFAULT_KEEP_ACKNOWLEDGED_BEHIND_CHECKPOINT,
40 }
41}
42
43const PRUNE_RECEIPT_PREFIX_END: &[u8] = b"prune;";
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum PrunedStream {
51 Operations,
53 Outbox,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RecipientAcknowledgement {
60 pub participant_id: u64,
62 pub acknowledged_through: u64,
65}
66
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct HeldRecords {
74 pub records: u64,
76 pub obligations: u64,
78 pub lowest_delivery: Option<u64>,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct PruneReceipt {
85 pub conversation_id: u64,
87 pub stream: PrunedStream,
89 pub cut_before: u64,
91 #[serde(alias = "acknowledged_through")]
95 pub covered_through: u64,
96 pub checkpoint_position: u64,
98 pub kept: u64,
100 pub recipients: Vec<RecipientAcknowledgement>,
102 #[serde(default)]
104 pub held: HeldRecords,
105 pub at_ms: u64,
107}
108
109impl PruneReceipt {
110 pub const fn prove(&self) -> Result<(), PruneProofError> {
117 if self.cut_before > self.covered_through {
118 return Err(PruneProofError::CutAboveCover {
119 cut_before: self.cut_before,
120 covered_through: self.covered_through,
121 });
122 }
123 if self.covered_through > self.checkpoint_position {
124 return Err(PruneProofError::CoverAboveCheckpoint {
125 covered_through: self.covered_through,
126 checkpoint_position: self.checkpoint_position,
127 });
128 }
129 let held = self.held;
130 if (held.records == 0) != held.lowest_delivery.is_none() || held.obligations < held.records
131 {
132 return Err(PruneProofError::HeldInconsistent { held });
133 }
134 Ok(())
135 }
136
137 #[must_use]
139 pub fn key(&self) -> Vec<u8> {
140 receipt_key(self.conversation_id, self.stream, self.cut_before)
141 }
142}
143
144#[must_use]
146pub(super) fn receipt_key(conversation: u64, stream: PrunedStream, cut_before: u64) -> Vec<u8> {
147 super::route_keys::namespaced(conversation, &legacy_receipt_key(stream, cut_before))
148}
149
150pub(super) fn legacy_receipt_key(stream: PrunedStream, cut_before: u64) -> Vec<u8> {
152 let name = match stream {
153 PrunedStream::Operations => "operations",
154 PrunedStream::Outbox => "outbox",
155 };
156 format!("{PRUNE_RECEIPT_PREFIX}{name}:{cut_before:020}").into_bytes()
157}
158
159#[derive(Debug, PartialEq, Eq, thiserror::Error)]
161pub enum PruneProofError {
162 #[error("cut_before {cut_before} is above covered_through {covered_through}")]
163 CutAboveCover {
164 cut_before: u64,
165 covered_through: u64,
166 },
167 #[error("covered_through {covered_through} is above checkpoint_position {checkpoint_position}")]
168 CoverAboveCheckpoint {
169 covered_through: u64,
170 checkpoint_position: u64,
171 },
172 #[error(
173 "the held summary does not add up: {} records, {} obligations, lowest delivery {:?}",
174 .held.records,
175 .held.obligations,
176 .held.lowest_delivery
177 )]
178 HeldInconsistent { held: HeldRecords },
179}
180
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
185pub(super) struct StreamBounds {
186 pub(super) checkpoint_position: u64,
187 pub(super) covered_through: u64,
188}
189
190impl StreamBounds {
191 pub(super) const fn behind_checkpoint(checkpoint_position: u64) -> Self {
196 Self {
197 checkpoint_position,
198 covered_through: checkpoint_position,
199 }
200 }
201}
202
203#[derive(Clone, Debug, PartialEq, Eq)]
206pub(super) struct PrunePlan {
207 pub(super) receipts: Vec<PruneReceipt>,
208}
209
210#[must_use]
215pub(super) fn plan(
216 keep: Option<u64>,
217 conversation_id: u64,
218 operations: StreamBounds,
219 outbox: StreamBounds,
220 recipients: &[RecipientAcknowledgement],
221 held: HeldRecords,
222 at_ms: u64,
223) -> Option<PrunePlan> {
224 let keep = keep?;
225 let mut receipts = Vec::new();
226 for (stream, bounds) in [
227 (PrunedStream::Operations, operations),
228 (PrunedStream::Outbox, outbox),
229 ] {
230 let checkpoint_position = bounds.checkpoint_position;
231 let covered_through = bounds.covered_through.min(checkpoint_position);
232 let cut_before = covered_through.saturating_sub(keep);
233 if cut_before == 0 {
234 continue;
235 }
236 receipts.push(PruneReceipt {
237 conversation_id,
238 stream,
239 cut_before,
240 covered_through,
241 checkpoint_position,
242 kept: keep,
243 recipients: recipients.to_owned(),
244 held,
245 at_ms,
246 });
247 }
248 (!receipts.is_empty()).then_some(PrunePlan { receipts })
249}
250
251pub(super) fn stage(batch: &mut OperationBatch, plan: &PrunePlan) -> Result<(), OperationLogError> {
258 for receipt in &plan.receipts {
259 receipt
260 .prove()
261 .map_err(|source| OperationLogError::PruneReceipt {
262 key: String::from_utf8_lossy(&receipt.key()).into_owned(),
263 source,
264 })?;
265 let value = serde_json::to_vec(receipt)?;
266 batch.stage(RoutedBatchItem {
267 key: receipt.key(),
268 expected: None,
269 value,
270 ttl: None,
271 })?;
272 }
273 Ok(())
274}
275
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum RowSource {
283 Legacy,
285 Routed,
288}
289
290#[derive(Clone, Debug, PartialEq, Eq)]
292pub struct PruneOutcome {
293 pub stream: PrunedStream,
295 pub source: RowSource,
297 pub cut_before: u64,
299 pub removed: u64,
301 pub refused: Option<String>,
304}
305
306fn stream_key(receipt: &PruneReceipt) -> String {
308 match receipt.stream {
309 PrunedStream::Operations => format!("{STREAM_PREFIX}{}", receipt.conversation_id),
310 PrunedStream::Outbox => format!("{OUTBOX_STREAM_PREFIX}{}", receipt.conversation_id),
311 }
312}
313
314async fn cut_routed(store: &dyn DurableStore, receipt: &PruneReceipt) -> Result<u64, String> {
319 let route = checkpoint_conversation_route(receipt.conversation_id);
320 let stream = stream_key(receipt);
321 let from = routed_stream_codec::row_key(&stream, 0).map_err(|error| error.to_string())?;
322 let to = routed_stream_codec::row_key(&stream, receipt.cut_before)
323 .map_err(|error| error.to_string())?;
324 store
325 .remove_routed_range(&route, &from, &to)
326 .await
327 .map_err(|error| error.to_string())
328}
329
330pub(super) async fn report_cut(store: &dyn DurableStore, conversation_id: u64, plan: &PrunePlan) {
338 for outcome in cut(store, plan).await {
339 match outcome.refused {
340 None => tracing::info!(
341 conversation_id,
342 stream = ?outcome.stream,
343 source = ?outcome.source,
344 cut_before = outcome.cut_before,
345 removed = outcome.removed,
346 "pruned acknowledged rows behind the checkpoint"
347 ),
348 Some(refusal) => tracing::warn!(
349 conversation_id,
350 stream = ?outcome.stream,
351 source = ?outcome.source,
352 cut_before = outcome.cut_before,
353 refusal = %refusal,
354 "prune receipt written, cut refused by the store"
355 ),
356 }
357 }
358}
359
360pub(super) async fn cut(store: &dyn DurableStore, plan: &PrunePlan) -> Vec<PruneOutcome> {
361 let mut outcomes = Vec::with_capacity(plan.receipts.len().saturating_mul(2));
362 for receipt in &plan.receipts {
363 let legacy = store
364 .truncate_before(&stream_key(receipt), receipt.cut_before)
365 .await
366 .map_err(|error| error.to_string());
367 let routed = cut_routed(store, receipt).await;
368 for (source, answer) in [(RowSource::Legacy, legacy), (RowSource::Routed, routed)] {
369 let (removed, refused) = match answer {
370 Ok(removed) => (removed, None),
371 Err(refusal) => (0, Some(refusal)),
372 };
373 outcomes.push(PruneOutcome {
374 stream: receipt.stream,
375 source,
376 cut_before: receipt.cut_before,
377 removed,
378 refused,
379 });
380 }
381 }
382 outcomes
383}
384
385pub(super) async fn unwritten(
394 store: &dyn DurableStore,
395 conversation_id: u64,
396 plan: PrunePlan,
397) -> Result<Option<PrunePlan>, DurabilityError> {
398 let route = checkpoint_conversation_route(conversation_id);
399 let mut receipts = Vec::with_capacity(plan.receipts.len());
400 for receipt in plan.receipts {
401 if store.get_routed(&route, &receipt.key()).await?.is_none() {
402 receipts.push(receipt);
403 }
404 }
405 Ok((!receipts.is_empty()).then_some(PrunePlan { receipts }))
406}
407
408#[derive(Debug, thiserror::Error)]
410pub enum PruneLedgerError {
411 #[error("prune ledger read refused: {0}")]
413 Store(#[from] DurabilityError),
414 #[error("prune receipt {key} is unreadable: {detail}")]
416 Unreadable {
417 key: String,
419 detail: String,
421 },
422 #[error("prune receipt {key} does not prove its cut: {error}")]
425 Defect {
426 key: String,
428 #[source]
430 error: PruneProofError,
431 },
432}
433
434pub async fn prune_receipts(
443 store: &dyn DurableStore,
444 conversation: u64,
445) -> Result<Vec<PruneReceipt>, PruneLedgerError> {
446 let from = super::route_keys::namespaced(conversation, PRUNE_RECEIPT_PREFIX.as_bytes());
447 let to = super::route_keys::namespaced(conversation, PRUNE_RECEIPT_PREFIX_END);
448 let route = checkpoint_conversation_route(conversation);
449 let rows = store.scan_routed(&route, &from, &to).await?;
450 let mut receipts = Vec::with_capacity(rows.len());
451 for (key, value) in rows {
452 let key = String::from_utf8_lossy(&key).into_owned();
453 let receipt: PruneReceipt =
454 serde_json::from_slice(&value).map_err(|error| PruneLedgerError::Unreadable {
455 key: key.clone(),
456 detail: error.to_string(),
457 })?;
458 receipt
459 .prove()
460 .map_err(|error| PruneLedgerError::Defect { key, error })?;
461 receipts.push(receipt);
462 }
463 Ok(receipts)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::{
469 HeldRecords, PruneProofError, PruneReceipt, PrunedStream, RecipientAcknowledgement,
470 StreamBounds, plan,
471 };
472
473 const fn bounds(checkpoint_position: u64, covered_through: u64) -> StreamBounds {
474 StreamBounds {
475 checkpoint_position,
476 covered_through,
477 }
478 }
479
480 fn recipients(acks: &[(u64, u64)]) -> Vec<RecipientAcknowledgement> {
481 acks.iter()
482 .map(
483 |&(participant_id, acknowledged_through)| RecipientAcknowledgement {
484 participant_id,
485 acknowledged_through,
486 },
487 )
488 .collect()
489 }
490
491 const NOTHING_HELD: HeldRecords = HeldRecords {
492 records: 0,
493 obligations: 0,
494 lowest_delivery: None,
495 };
496
497 #[test]
498 fn no_configured_bound_means_no_cut() {
499 assert_eq!(
500 plan(
501 None,
502 7,
503 bounds(2_048, 2_048),
504 bounds(2_048, 2_048),
505 &recipients(&[(1, 2_048)]),
506 NOTHING_HELD,
507 0
508 ),
509 None
510 );
511 }
512
513 #[test]
514 fn the_cut_sits_the_kept_rows_behind_the_cover() -> Result<(), Box<dyn std::error::Error>> {
515 let planned = plan(
516 Some(100),
517 7,
518 bounds(2_000, 900),
519 bounds(2_000, 900),
520 &recipients(&[(1, 900), (2, 1_200), (3, 1_500)]),
521 NOTHING_HELD,
522 5,
523 )
524 .ok_or("rows lie below the kept window")?;
525 assert_eq!(planned.receipts.len(), 2);
526 for receipt in &planned.receipts {
527 assert_eq!(receipt.cut_before, 800);
528 assert_eq!(receipt.covered_through, 900);
529 assert_eq!(receipt.checkpoint_position, 2_000);
530 receipt.prove()?;
531 }
532 Ok(())
533 }
534
535 #[test]
540 fn a_recipient_that_never_acknowledged_no_longer_holds_the_cut_at_zero()
541 -> Result<(), Box<dyn std::error::Error>> {
542 let held = HeldRecords {
543 records: 3,
544 obligations: 3,
545 lowest_delivery: Some(1),
546 };
547 let planned = plan(
548 Some(256),
549 7,
550 StreamBounds::behind_checkpoint(2_048),
551 StreamBounds::behind_checkpoint(1_024),
552 &recipients(&[(1, 0), (2, 2_048)]),
553 held,
554 0,
555 )
556 .ok_or("rows lie below the kept window")?;
557 assert_eq!(planned.receipts.len(), 2);
558 assert_eq!(planned.receipts[0].cut_before, 1_792);
559 assert_eq!(planned.receipts[1].cut_before, 768);
560 for receipt in &planned.receipts {
561 assert_eq!(receipt.held, held);
562 receipt.prove()?;
563 }
564 Ok(())
565 }
566
567 #[test]
568 fn the_checkpoint_bounds_the_cover() -> Result<(), Box<dyn std::error::Error>> {
569 let planned = plan(
570 Some(256),
571 7,
572 bounds(2_048, 9_000),
573 bounds(2_048, 9_000),
574 &recipients(&[(1, 9_000)]),
575 NOTHING_HELD,
576 0,
577 )
578 .ok_or("rows lie below the kept window")?;
579 assert_eq!(planned.receipts[0].covered_through, 2_048);
580 assert_eq!(planned.receipts[0].cut_before, 1_792);
581 Ok(())
582 }
583
584 #[test]
585 fn a_receipt_that_does_not_add_up_is_a_named_defect() {
586 let receipt = PruneReceipt {
587 conversation_id: 7,
588 stream: PrunedStream::Operations,
589 cut_before: 901,
590 covered_through: 900,
591 checkpoint_position: 2_000,
592 kept: 0,
593 recipients: recipients(&[(1, 900)]),
594 held: NOTHING_HELD,
595 at_ms: 0,
596 };
597 assert_eq!(
598 receipt.prove(),
599 Err(PruneProofError::CutAboveCover {
600 cut_before: 901,
601 covered_through: 900
602 })
603 );
604 let receipt = PruneReceipt {
605 cut_before: 800,
606 covered_through: 2_001,
607 ..receipt
608 };
609 assert_eq!(
610 receipt.prove(),
611 Err(PruneProofError::CoverAboveCheckpoint {
612 covered_through: 2_001,
613 checkpoint_position: 2_000
614 })
615 );
616 let held = HeldRecords {
617 records: 2,
618 obligations: 1,
619 lowest_delivery: Some(4),
620 };
621 let receipt = PruneReceipt {
622 covered_through: 900,
623 held,
624 ..receipt
625 };
626 assert_eq!(
627 receipt.prove(),
628 Err(PruneProofError::HeldInconsistent { held })
629 );
630 }
631
632 #[test]
635 fn a_receipt_written_before_the_amendment_reads_back() -> Result<(), Box<dyn std::error::Error>>
636 {
637 let receipt: PruneReceipt = serde_json::from_value(serde_json::json!({
638 "conversation_id": 7, "stream": "operations", "cut_before": 800,
639 "acknowledged_through": 900, "checkpoint_position": 2_000, "kept": 100,
640 "recipients": [{"participant_id": 1, "acknowledged_through": 900}], "at_ms": 5
641 }))?;
642 assert_eq!(receipt.covered_through, 900);
643 assert_eq!(receipt.held, NOTHING_HELD);
644 receipt.prove()?;
645 Ok(())
646 }
647}