Skip to main content

liminal_server/server/participant/production/
prune.rs

1//! Prune acknowledged conversation rows behind a checkpoint (DUR-018).
2//!
3//! liminal is not the system of record: a row is durable until it is
4//! delivered and acknowledged, then the app store owns it. At every
5//! checkpoint a conversation's operation and outbox rows that lie behind the
6//! checkpoint and behind every recipient's acknowledgement are cut, keeping a
7//! configured, pinned number behind the checkpoint. A receipt is staged into
8//! the checkpoint's own batch before any row goes, so a reader proves from
9//! the receipt that nothing unacknowledged was cut. Nothing here bounds a
10//! conversation log by row count or by age.
11
12use 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
21/// Prefix of a conversation's prune ledger keys inside its checkpoint route.
22pub(super) const PRUNE_RECEIPT_PREFIX: &str = "prune:";
23
24/// The key just past every receipt key: `;` follows `:` in byte order, so
25/// `[PRUNE_RECEIPT_PREFIX, PRUNE_RECEIPT_PREFIX_END)` is exactly the ledger.
26const PRUNE_RECEIPT_PREFIX_END: &[u8] = b"prune;";
27
28/// The two conversation streams a cut applies to.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PrunedStream {
32    /// The operation log.
33    Operations,
34    /// The outbox log.
35    Outbox,
36}
37
38/// A recipient's acknowledgement at the time of the cut.
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub struct RecipientAcknowledgement {
41    /// The recipient.
42    pub participant_id: u64,
43    /// Every delivery at or below this sequence is acknowledged by it.
44    pub acknowledged_through: u64,
45}
46
47/// What a cut was decided on; written before the cut, read to prove it.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct PruneReceipt {
50    /// The conversation.
51    pub conversation_id: u64,
52    /// The stream the cut applies to.
53    pub stream: PrunedStream,
54    /// Rows below this sequence are cut.
55    pub cut_before: u64,
56    /// Every recipient owed a row has acknowledged through here.
57    pub acknowledged_through: u64,
58    /// The checkpoint the cut sits behind.
59    pub checkpoint_position: u64,
60    /// The rows kept behind the checkpoint, from config.
61    pub kept: u64,
62    /// Each recipient's acknowledgement at the cut.
63    pub recipients: Vec<RecipientAcknowledgement>,
64    /// Milliseconds since the Unix epoch on the server clock.
65    pub at_ms: u64,
66}
67
68impl PruneReceipt {
69    /// Proves the receipt's own arithmetic: nothing unacknowledged was cut.
70    ///
71    /// # Errors
72    /// The receipt names a cut above an acknowledgement or a checkpoint.
73    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    /// The ledger key of this receipt inside the conversation's route.
101    #[must_use]
102    pub fn key(&self) -> Vec<u8> {
103        receipt_key(self.stream, self.cut_before)
104    }
105}
106
107/// The ledger key of a receipt for `stream` at `cut_before`: one key per
108/// cut point, so a repeated cut to the same point writes no second receipt.
109#[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/// A receipt that does not prove its own cut.
119#[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/// One stream's positions a cut is planned from: where its checkpoint
144/// stands, and the row every recipient has acknowledged through.
145#[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/// The cut decided at one checkpoint: staged as receipts in the checkpoint's
152/// batch, applied to the store only after that batch committed.
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub(super) struct PrunePlan {
155    pub(super) receipts: Vec<PruneReceipt>,
156}
157
158/// Decides the cut for one checkpoint. `None` when pruning is off, when no
159/// recipient has acknowledged anything, or when nothing lies below the kept
160/// window.
161#[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
196/// Stages every receipt of `plan` into the checkpoint's batch.
197///
198/// # Errors
199/// A receipt that does not prove its own cut, by name
200/// ([`OperationLogError::PruneReceipt`]); a receipt that did not encode; or
201/// the batch refusing the key.
202pub(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/// Where a conversation's rows live; a cut applies to both.
222///
223/// The legacy stream is what the migration and the older readers use; the
224/// routed rows are what the wired-boot head writes under the conversation's
225/// checkpoint route.
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227pub enum RowSource {
228    /// `DurableStore::truncate_before` on the stream key.
229    Legacy,
230    /// `DurableStore::remove_routed_range` over the row-key window on the
231    /// conversation's route; the head record lies outside the window.
232    Routed,
233}
234
235/// What one cut came to.
236#[derive(Clone, Debug, PartialEq, Eq)]
237pub struct PruneOutcome {
238    /// The stream.
239    pub stream: PrunedStream,
240    /// Which rows of it.
241    pub source: RowSource,
242    /// Rows below this were asked to be cut.
243    pub cut_before: u64,
244    /// Rows the store reports removed.
245    pub removed: u64,
246    /// Named when the store refused to cut; the receipt stands, the cut is
247    /// not claimed.
248    pub refused: Option<String>,
249}
250
251/// The stream key a receipt's rows live under, legacy and routed alike.
252fn 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
259/// Removes the routed rows of `receipt`'s stream below its cut.
260///
261/// The window runs from the stream's first row key to the key of
262/// `cut_before`; the head record and every later row lie outside it.
263async 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
275/// Applies a committed plan to the store, each source answered on its own.
276///
277/// The legacy stream goes through `truncate_before`, the routed rows through
278/// `remove_routed_range`. A refusal is named and the cut is not claimed.
279pub(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
304/// Keeps only the receipts of `plan` not already in the ledger.
305///
306/// A cut repeated to the same point after a crash between receipt and cut
307/// then writes no second receipt (DUR-018 R3). `None` when every receipt is
308/// already written.
309///
310/// # Errors
311/// The store could not be read.
312pub(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/// A prune ledger that cannot be read whole.
328#[derive(Debug, thiserror::Error)]
329pub enum PruneLedgerError {
330    /// The store refused the read.
331    #[error("prune ledger read refused: {0}")]
332    Store(#[from] DurabilityError),
333    /// A ledger key holds bytes that are not a receipt.
334    #[error("prune receipt {key} is unreadable: {detail}")]
335    Unreadable {
336        /// The ledger key.
337        key: String,
338        /// Why.
339        detail: String,
340    },
341    /// A receipt that does not prove its own cut: a named defect, never
342    /// silently accepted.
343    #[error("prune receipt {key} does not prove its cut: {error}")]
344    Defect {
345        /// The ledger key.
346        key: String,
347        /// The proof that failed.
348        #[source]
349        error: PruneProofError,
350    },
351}
352
353/// Reads every receipt of `conversation_id`'s prune ledger, proved.
354///
355/// They come in key order; a reader learns from them exactly which rows
356/// were cut and that every one was acknowledged by every recipient owed it.
357///
358/// # Errors
359/// The store refused the read, a key holds bytes that are not a receipt, or
360/// a receipt fails its own proof.
361pub 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}