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
24const PRUNE_RECEIPT_PREFIX_END: &[u8] = b"prune;";
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PrunedStream {
32 Operations,
34 Outbox,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub struct RecipientAcknowledgement {
41 pub participant_id: u64,
43 pub acknowledged_through: u64,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct PruneReceipt {
50 pub conversation_id: u64,
52 pub stream: PrunedStream,
54 pub cut_before: u64,
56 pub acknowledged_through: u64,
58 pub checkpoint_position: u64,
60 pub kept: u64,
62 pub recipients: Vec<RecipientAcknowledgement>,
64 pub at_ms: u64,
66}
67
68impl PruneReceipt {
69 pub fn prove(&self) -> Result<(), PruneProofError> {
74 if self.cut_before > self.acknowledged_through {
75 return Err(PruneProofError::CutAboveAcknowledgement {
76 cut_before: self.cut_before,
77 acknowledged_through: self.acknowledged_through,
78 });
79 }
80 if self.acknowledged_through > self.checkpoint_position {
81 return Err(PruneProofError::AcknowledgementAboveCheckpoint {
82 acknowledged_through: self.acknowledged_through,
83 checkpoint_position: self.checkpoint_position,
84 });
85 }
86 if let Some(recipient) = self
87 .recipients
88 .iter()
89 .find(|recipient| recipient.acknowledged_through < self.cut_before)
90 {
91 return Err(PruneProofError::RecipientBelowCut {
92 participant_id: recipient.participant_id,
93 acknowledged_through: recipient.acknowledged_through,
94 cut_before: self.cut_before,
95 });
96 }
97 Ok(())
98 }
99
100 #[must_use]
102 pub fn key(&self) -> Vec<u8> {
103 receipt_key(self.stream, self.cut_before)
104 }
105}
106
107#[must_use]
110pub(super) fn receipt_key(stream: PrunedStream, cut_before: u64) -> Vec<u8> {
111 let name = match stream {
112 PrunedStream::Operations => "operations",
113 PrunedStream::Outbox => "outbox",
114 };
115 format!("{PRUNE_RECEIPT_PREFIX}{name}:{cut_before:020}").into_bytes()
116}
117
118#[derive(Debug, PartialEq, Eq, thiserror::Error)]
120pub enum PruneProofError {
121 #[error("cut_before {cut_before} is above acknowledged_through {acknowledged_through}")]
122 CutAboveAcknowledgement {
123 cut_before: u64,
124 acknowledged_through: u64,
125 },
126 #[error(
127 "acknowledged_through {acknowledged_through} is above checkpoint_position {checkpoint_position}"
128 )]
129 AcknowledgementAboveCheckpoint {
130 acknowledged_through: u64,
131 checkpoint_position: u64,
132 },
133 #[error(
134 "recipient {participant_id} acknowledged through {acknowledged_through}, below cut_before {cut_before}"
135 )]
136 RecipientBelowCut {
137 participant_id: u64,
138 acknowledged_through: u64,
139 cut_before: u64,
140 },
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub(super) struct StreamBounds {
147 pub(super) checkpoint_position: u64,
148 pub(super) acknowledged_through: u64,
149}
150
151#[derive(Clone, Debug, PartialEq, Eq)]
154pub(super) struct PrunePlan {
155 pub(super) receipts: Vec<PruneReceipt>,
156}
157
158#[must_use]
162pub(super) fn plan(
163 keep: Option<u64>,
164 conversation_id: u64,
165 operations: StreamBounds,
166 outbox: StreamBounds,
167 recipients: &[RecipientAcknowledgement],
168 at_ms: u64,
169) -> Option<PrunePlan> {
170 let keep = keep?;
171 let mut receipts = Vec::new();
172 for (stream, bounds) in [
173 (PrunedStream::Operations, operations),
174 (PrunedStream::Outbox, outbox),
175 ] {
176 let checkpoint_position = bounds.checkpoint_position;
177 let acknowledged_through = bounds.acknowledged_through.min(checkpoint_position);
178 let cut_before = acknowledged_through.saturating_sub(keep);
179 if cut_before == 0 {
180 continue;
181 }
182 receipts.push(PruneReceipt {
183 conversation_id,
184 stream,
185 cut_before,
186 acknowledged_through,
187 checkpoint_position,
188 kept: keep,
189 recipients: recipients.to_owned(),
190 at_ms,
191 });
192 }
193 (!receipts.is_empty()).then_some(PrunePlan { receipts })
194}
195
196pub(super) fn stage(batch: &mut OperationBatch, plan: &PrunePlan) -> Result<(), OperationLogError> {
203 for receipt in &plan.receipts {
204 receipt
205 .prove()
206 .map_err(|source| OperationLogError::PruneReceipt {
207 key: String::from_utf8_lossy(&receipt.key()).into_owned(),
208 source,
209 })?;
210 let value = serde_json::to_vec(receipt)?;
211 batch.stage(RoutedBatchItem {
212 key: receipt.key(),
213 expected: None,
214 value,
215 ttl: None,
216 })?;
217 }
218 Ok(())
219}
220
221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227pub enum RowSource {
228 Legacy,
230 Routed,
233}
234
235#[derive(Clone, Debug, PartialEq, Eq)]
237pub struct PruneOutcome {
238 pub stream: PrunedStream,
240 pub source: RowSource,
242 pub cut_before: u64,
244 pub removed: u64,
246 pub refused: Option<String>,
249}
250
251fn stream_key(receipt: &PruneReceipt) -> String {
253 match receipt.stream {
254 PrunedStream::Operations => format!("{STREAM_PREFIX}{}", receipt.conversation_id),
255 PrunedStream::Outbox => format!("{OUTBOX_STREAM_PREFIX}{}", receipt.conversation_id),
256 }
257}
258
259async fn cut_routed(store: &dyn DurableStore, receipt: &PruneReceipt) -> Result<u64, String> {
264 let route = checkpoint_conversation_route(receipt.conversation_id);
265 let stream = stream_key(receipt);
266 let from = routed_stream_codec::row_key(&stream, 0).map_err(|error| error.to_string())?;
267 let to = routed_stream_codec::row_key(&stream, receipt.cut_before)
268 .map_err(|error| error.to_string())?;
269 store
270 .remove_routed_range(&route, &from, &to)
271 .await
272 .map_err(|error| error.to_string())
273}
274
275pub(super) async fn cut(store: &dyn DurableStore, plan: &PrunePlan) -> Vec<PruneOutcome> {
280 let mut outcomes = Vec::with_capacity(plan.receipts.len().saturating_mul(2));
281 for receipt in &plan.receipts {
282 let legacy = store
283 .truncate_before(&stream_key(receipt), receipt.cut_before)
284 .await
285 .map_err(|error| error.to_string());
286 let routed = cut_routed(store, receipt).await;
287 for (source, answer) in [(RowSource::Legacy, legacy), (RowSource::Routed, routed)] {
288 let (removed, refused) = match answer {
289 Ok(removed) => (removed, None),
290 Err(refusal) => (0, Some(refusal)),
291 };
292 outcomes.push(PruneOutcome {
293 stream: receipt.stream,
294 source,
295 cut_before: receipt.cut_before,
296 removed,
297 refused,
298 });
299 }
300 }
301 outcomes
302}
303
304pub(super) async fn unwritten(
313 store: &dyn DurableStore,
314 conversation_id: u64,
315 plan: PrunePlan,
316) -> Result<Option<PrunePlan>, DurabilityError> {
317 let route = checkpoint_conversation_route(conversation_id);
318 let mut receipts = Vec::with_capacity(plan.receipts.len());
319 for receipt in plan.receipts {
320 if store.get_routed(&route, &receipt.key()).await?.is_none() {
321 receipts.push(receipt);
322 }
323 }
324 Ok((!receipts.is_empty()).then_some(PrunePlan { receipts }))
325}
326
327#[derive(Debug, thiserror::Error)]
329pub enum PruneLedgerError {
330 #[error("prune ledger read refused: {0}")]
332 Store(#[from] DurabilityError),
333 #[error("prune receipt {key} is unreadable: {detail}")]
335 Unreadable {
336 key: String,
338 detail: String,
340 },
341 #[error("prune receipt {key} does not prove its cut: {error}")]
344 Defect {
345 key: String,
347 #[source]
349 error: PruneProofError,
350 },
351}
352
353pub async fn prune_receipts(
362 store: &dyn DurableStore,
363 conversation_id: u64,
364) -> Result<Vec<PruneReceipt>, PruneLedgerError> {
365 let route = checkpoint_conversation_route(conversation_id);
366 let rows = store
367 .scan_routed(
368 &route,
369 PRUNE_RECEIPT_PREFIX.as_bytes(),
370 PRUNE_RECEIPT_PREFIX_END,
371 )
372 .await?;
373 let mut receipts = Vec::with_capacity(rows.len());
374 for (key, value) in rows {
375 let key = String::from_utf8_lossy(&key).into_owned();
376 let receipt: PruneReceipt =
377 serde_json::from_slice(&value).map_err(|error| PruneLedgerError::Unreadable {
378 key: key.clone(),
379 detail: error.to_string(),
380 })?;
381 receipt
382 .prove()
383 .map_err(|error| PruneLedgerError::Defect { key, error })?;
384 receipts.push(receipt);
385 }
386 Ok(receipts)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::{
392 PruneProofError, PruneReceipt, PrunedStream, RecipientAcknowledgement, StreamBounds, plan,
393 };
394
395 const fn bounds(checkpoint_position: u64, acknowledged_through: u64) -> StreamBounds {
396 StreamBounds {
397 checkpoint_position,
398 acknowledged_through,
399 }
400 }
401
402 fn recipients(acks: &[(u64, u64)]) -> Vec<RecipientAcknowledgement> {
403 acks.iter()
404 .map(
405 |&(participant_id, acknowledged_through)| RecipientAcknowledgement {
406 participant_id,
407 acknowledged_through,
408 },
409 )
410 .collect()
411 }
412
413 #[test]
414 fn no_configured_bound_means_no_cut() {
415 assert_eq!(
416 plan(
417 None,
418 7,
419 bounds(2_048, 2_048),
420 bounds(2_048, 2_048),
421 &recipients(&[(1, 2_048)]),
422 0
423 ),
424 None
425 );
426 }
427
428 #[test]
429 fn the_cut_sits_the_kept_rows_behind_the_least_acknowledgement()
430 -> Result<(), Box<dyn std::error::Error>> {
431 let planned = plan(
432 Some(100),
433 7,
434 bounds(2_000, 900),
435 bounds(2_000, 900),
436 &recipients(&[(1, 900), (2, 1_200), (3, 1_500)]),
437 5,
438 )
439 .ok_or("rows lie below the kept window")?;
440 assert_eq!(planned.receipts.len(), 2);
441 for receipt in &planned.receipts {
442 assert_eq!(receipt.cut_before, 800);
443 assert_eq!(receipt.acknowledged_through, 900);
444 assert_eq!(receipt.checkpoint_position, 2_000);
445 receipt.prove()?;
446 }
447 Ok(())
448 }
449
450 #[test]
451 fn a_recipient_that_never_acknowledged_holds_the_cut_at_zero() {
452 assert_eq!(
453 plan(
454 Some(256),
455 7,
456 bounds(2_048, 0),
457 bounds(2_048, 0),
458 &recipients(&[(1, 0), (2, 2_048)]),
459 0
460 ),
461 None
462 );
463 }
464
465 #[test]
466 fn the_checkpoint_bounds_the_acknowledgement() -> Result<(), Box<dyn std::error::Error>> {
467 let planned = plan(
468 Some(256),
469 7,
470 bounds(2_048, 9_000),
471 bounds(2_048, 9_000),
472 &recipients(&[(1, 9_000)]),
473 0,
474 )
475 .ok_or("rows lie below the kept window")?;
476 assert_eq!(planned.receipts[0].acknowledged_through, 2_048);
477 assert_eq!(planned.receipts[0].cut_before, 1_792);
478 Ok(())
479 }
480
481 #[test]
482 fn a_receipt_that_cuts_above_an_acknowledgement_is_a_named_defect() {
483 let receipt = PruneReceipt {
484 conversation_id: 7,
485 stream: PrunedStream::Operations,
486 cut_before: 901,
487 acknowledged_through: 900,
488 checkpoint_position: 2_000,
489 kept: 0,
490 recipients: recipients(&[(1, 900)]),
491 at_ms: 0,
492 };
493 assert_eq!(
494 receipt.prove(),
495 Err(PruneProofError::CutAboveAcknowledgement {
496 cut_before: 901,
497 acknowledged_through: 900
498 })
499 );
500 let receipt = PruneReceipt {
501 cut_before: 800,
502 recipients: recipients(&[(1, 900), (2, 700)]),
503 ..receipt
504 };
505 assert_eq!(
506 receipt.prove(),
507 Err(PruneProofError::RecipientBelowCut {
508 participant_id: 2,
509 acknowledged_through: 700,
510 cut_before: 800
511 })
512 );
513 }
514}