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.stream, self.cut_before)
141 }
142}
143
144#[must_use]
147pub(super) fn receipt_key(stream: PrunedStream, cut_before: u64) -> Vec<u8> {
148 let name = match stream {
149 PrunedStream::Operations => "operations",
150 PrunedStream::Outbox => "outbox",
151 };
152 format!("{PRUNE_RECEIPT_PREFIX}{name}:{cut_before:020}").into_bytes()
153}
154
155#[derive(Debug, PartialEq, Eq, thiserror::Error)]
157pub enum PruneProofError {
158 #[error("cut_before {cut_before} is above covered_through {covered_through}")]
159 CutAboveCover {
160 cut_before: u64,
161 covered_through: u64,
162 },
163 #[error("covered_through {covered_through} is above checkpoint_position {checkpoint_position}")]
164 CoverAboveCheckpoint {
165 covered_through: u64,
166 checkpoint_position: u64,
167 },
168 #[error(
169 "the held summary does not add up: {} records, {} obligations, lowest delivery {:?}",
170 .held.records,
171 .held.obligations,
172 .held.lowest_delivery
173 )]
174 HeldInconsistent { held: HeldRecords },
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub(super) struct StreamBounds {
182 pub(super) checkpoint_position: u64,
183 pub(super) covered_through: u64,
184}
185
186impl StreamBounds {
187 pub(super) const fn behind_checkpoint(checkpoint_position: u64) -> Self {
192 Self {
193 checkpoint_position,
194 covered_through: checkpoint_position,
195 }
196 }
197}
198
199#[derive(Clone, Debug, PartialEq, Eq)]
202pub(super) struct PrunePlan {
203 pub(super) receipts: Vec<PruneReceipt>,
204}
205
206#[must_use]
211pub(super) fn plan(
212 keep: Option<u64>,
213 conversation_id: u64,
214 operations: StreamBounds,
215 outbox: StreamBounds,
216 recipients: &[RecipientAcknowledgement],
217 held: HeldRecords,
218 at_ms: u64,
219) -> Option<PrunePlan> {
220 let keep = keep?;
221 let mut receipts = Vec::new();
222 for (stream, bounds) in [
223 (PrunedStream::Operations, operations),
224 (PrunedStream::Outbox, outbox),
225 ] {
226 let checkpoint_position = bounds.checkpoint_position;
227 let covered_through = bounds.covered_through.min(checkpoint_position);
228 let cut_before = covered_through.saturating_sub(keep);
229 if cut_before == 0 {
230 continue;
231 }
232 receipts.push(PruneReceipt {
233 conversation_id,
234 stream,
235 cut_before,
236 covered_through,
237 checkpoint_position,
238 kept: keep,
239 recipients: recipients.to_owned(),
240 held,
241 at_ms,
242 });
243 }
244 (!receipts.is_empty()).then_some(PrunePlan { receipts })
245}
246
247pub(super) fn stage(batch: &mut OperationBatch, plan: &PrunePlan) -> Result<(), OperationLogError> {
254 for receipt in &plan.receipts {
255 receipt
256 .prove()
257 .map_err(|source| OperationLogError::PruneReceipt {
258 key: String::from_utf8_lossy(&receipt.key()).into_owned(),
259 source,
260 })?;
261 let value = serde_json::to_vec(receipt)?;
262 batch.stage(RoutedBatchItem {
263 key: receipt.key(),
264 expected: None,
265 value,
266 ttl: None,
267 })?;
268 }
269 Ok(())
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
278pub enum RowSource {
279 Legacy,
281 Routed,
284}
285
286#[derive(Clone, Debug, PartialEq, Eq)]
288pub struct PruneOutcome {
289 pub stream: PrunedStream,
291 pub source: RowSource,
293 pub cut_before: u64,
295 pub removed: u64,
297 pub refused: Option<String>,
300}
301
302fn stream_key(receipt: &PruneReceipt) -> String {
304 match receipt.stream {
305 PrunedStream::Operations => format!("{STREAM_PREFIX}{}", receipt.conversation_id),
306 PrunedStream::Outbox => format!("{OUTBOX_STREAM_PREFIX}{}", receipt.conversation_id),
307 }
308}
309
310async fn cut_routed(store: &dyn DurableStore, receipt: &PruneReceipt) -> Result<u64, String> {
315 let route = checkpoint_conversation_route(receipt.conversation_id);
316 let stream = stream_key(receipt);
317 let from = routed_stream_codec::row_key(&stream, 0).map_err(|error| error.to_string())?;
318 let to = routed_stream_codec::row_key(&stream, receipt.cut_before)
319 .map_err(|error| error.to_string())?;
320 store
321 .remove_routed_range(&route, &from, &to)
322 .await
323 .map_err(|error| error.to_string())
324}
325
326pub(super) async fn report_cut(store: &dyn DurableStore, conversation_id: u64, plan: &PrunePlan) {
334 for outcome in cut(store, plan).await {
335 match outcome.refused {
336 None => tracing::info!(
337 conversation_id,
338 stream = ?outcome.stream,
339 source = ?outcome.source,
340 cut_before = outcome.cut_before,
341 removed = outcome.removed,
342 "pruned acknowledged rows behind the checkpoint"
343 ),
344 Some(refusal) => tracing::warn!(
345 conversation_id,
346 stream = ?outcome.stream,
347 source = ?outcome.source,
348 cut_before = outcome.cut_before,
349 refusal = %refusal,
350 "prune receipt written, cut refused by the store"
351 ),
352 }
353 }
354}
355
356pub(super) async fn cut(store: &dyn DurableStore, plan: &PrunePlan) -> Vec<PruneOutcome> {
357 let mut outcomes = Vec::with_capacity(plan.receipts.len().saturating_mul(2));
358 for receipt in &plan.receipts {
359 let legacy = store
360 .truncate_before(&stream_key(receipt), receipt.cut_before)
361 .await
362 .map_err(|error| error.to_string());
363 let routed = cut_routed(store, receipt).await;
364 for (source, answer) in [(RowSource::Legacy, legacy), (RowSource::Routed, routed)] {
365 let (removed, refused) = match answer {
366 Ok(removed) => (removed, None),
367 Err(refusal) => (0, Some(refusal)),
368 };
369 outcomes.push(PruneOutcome {
370 stream: receipt.stream,
371 source,
372 cut_before: receipt.cut_before,
373 removed,
374 refused,
375 });
376 }
377 }
378 outcomes
379}
380
381pub(super) async fn unwritten(
390 store: &dyn DurableStore,
391 conversation_id: u64,
392 plan: PrunePlan,
393) -> Result<Option<PrunePlan>, DurabilityError> {
394 let route = checkpoint_conversation_route(conversation_id);
395 let mut receipts = Vec::with_capacity(plan.receipts.len());
396 for receipt in plan.receipts {
397 if store.get_routed(&route, &receipt.key()).await?.is_none() {
398 receipts.push(receipt);
399 }
400 }
401 Ok((!receipts.is_empty()).then_some(PrunePlan { receipts }))
402}
403
404#[derive(Debug, thiserror::Error)]
406pub enum PruneLedgerError {
407 #[error("prune ledger read refused: {0}")]
409 Store(#[from] DurabilityError),
410 #[error("prune receipt {key} is unreadable: {detail}")]
412 Unreadable {
413 key: String,
415 detail: String,
417 },
418 #[error("prune receipt {key} does not prove its cut: {error}")]
421 Defect {
422 key: String,
424 #[source]
426 error: PruneProofError,
427 },
428}
429
430pub async fn prune_receipts(
439 store: &dyn DurableStore,
440 conversation_id: u64,
441) -> Result<Vec<PruneReceipt>, PruneLedgerError> {
442 let route = checkpoint_conversation_route(conversation_id);
443 let rows = store
444 .scan_routed(
445 &route,
446 PRUNE_RECEIPT_PREFIX.as_bytes(),
447 PRUNE_RECEIPT_PREFIX_END,
448 )
449 .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}