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/// Acknowledged rows kept behind the newest checkpoint when
25/// `[participant.pruning]` is absent (DUR-024 R4).
26///
27/// Two checkpoint intervals (`CHECKPOINT_EVERY` is 256) of slack, so a
28/// reader resuming from the previous checkpoint still finds every row it
29/// can ask for. Waffles' number by Tom's rule of 10 October 2026; pinned in
30/// `tests/ceilings/checkpoint.toml`. A configured value still governs.
31pub const DEFAULT_KEEP_ACKNOWLEDGED_BEHIND_CHECKPOINT: u64 = 512;
32
33/// The kept window a server prunes with: the configured one, or the pinned
34/// default when the section is absent. Pruning is never off.
35#[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
43/// The key just past every receipt key: `;` follows `:` in byte order, so
44/// `[PRUNE_RECEIPT_PREFIX, PRUNE_RECEIPT_PREFIX_END)` is exactly the ledger.
45const PRUNE_RECEIPT_PREFIX_END: &[u8] = b"prune;";
46
47/// The two conversation streams a cut applies to.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum PrunedStream {
51    /// The operation log.
52    Operations,
53    /// The outbox log.
54    Outbox,
55}
56
57/// A recipient's acknowledgement at the time of the cut.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RecipientAcknowledgement {
60    /// The recipient.
61    pub participant_id: u64,
62    /// Every delivery at or below this delivery sequence is acknowledged by
63    /// it. A delivery sequence, not a row of either stream.
64    pub acknowledged_through: u64,
65}
66
67/// The undelivered records the checkpoint a cut sits behind carries: every
68/// record some recipient owed it has not acknowledged, body and all. Boot
69/// restores them from the checkpoint and redelivery reads them from the
70/// outbox, so a row cut behind the checkpoint is never the only copy of
71/// anything owed.
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct HeldRecords {
74    /// Records held, each owed to at least one recipient.
75    pub records: u64,
76    /// Recipient obligations across them.
77    pub obligations: u64,
78    /// The lowest delivery sequence held; `None` when nothing is held.
79    pub lowest_delivery: Option<u64>,
80}
81
82/// What a cut was decided on; written before the cut, read to prove it.
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct PruneReceipt {
85    /// The conversation.
86    pub conversation_id: u64,
87    /// The stream the cut applies to.
88    pub stream: PrunedStream,
89    /// Rows below this sequence are cut.
90    pub cut_before: u64,
91    /// Every row below here is acknowledged by every recipient owed it, or
92    /// its record is among those `held` by the checkpoint at
93    /// `checkpoint_position`.
94    #[serde(alias = "acknowledged_through")]
95    pub covered_through: u64,
96    /// The checkpoint the cut sits behind.
97    pub checkpoint_position: u64,
98    /// The rows kept behind the checkpoint, from config.
99    pub kept: u64,
100    /// Each recipient's acknowledgement at the cut.
101    pub recipients: Vec<RecipientAcknowledgement>,
102    /// What the checkpoint carries undelivered at the cut.
103    #[serde(default)]
104    pub held: HeldRecords,
105    /// Milliseconds since the Unix epoch on the server clock.
106    pub at_ms: u64,
107}
108
109impl PruneReceipt {
110    /// Proves the receipt's own arithmetic: nothing was cut that is not
111    /// either acknowledged or held by the checkpoint the cut sits behind.
112    ///
113    /// # Errors
114    /// The receipt names a cut above its cover, a cover above its
115    /// checkpoint, or a held summary that does not add up.
116    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    /// The ledger key of this receipt inside the conversation's route.
138    #[must_use]
139    pub fn key(&self) -> Vec<u8> {
140        receipt_key(self.stream, self.cut_before)
141    }
142}
143
144/// The ledger key of a receipt for `stream` at `cut_before`: one key per
145/// cut point, so a repeated cut to the same point writes no second receipt.
146#[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/// A receipt that does not prove its own cut.
156#[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/// One stream's positions a cut is planned from: where its checkpoint
178/// stands, and the row below which every row is acknowledged or held by
179/// that checkpoint.
180#[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    /// A stream whose checkpoint holds every record not yet acknowledged:
188    /// covered through the checkpoint itself. A recipient that never
189    /// acknowledges holds its own records in the checkpoint, never the cut
190    /// (DUR-018 R2 as amended 12 October 2026).
191    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/// The cut decided at one checkpoint: staged as receipts in the checkpoint's
200/// batch, applied to the store only after that batch committed.
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub(super) struct PrunePlan {
203    pub(super) receipts: Vec<PruneReceipt>,
204}
205
206/// Decides the cut for one checkpoint. `None` when nothing lies below the
207/// kept window. `held` is what the checkpoint carries undelivered. `keep`
208/// is [`effective_keep`]'s answer: a server never passes `None` (DUR-024
209/// R4), and the option remains for a caller that stages no cut at all.
210#[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
247/// Stages every receipt of `plan` into the checkpoint's batch.
248///
249/// # Errors
250/// A receipt that does not prove its own cut, by name
251/// ([`OperationLogError::PruneReceipt`]); a receipt that did not encode; or
252/// the batch refusing the key.
253pub(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/// Where a conversation's rows live; a cut applies to both.
273///
274/// The legacy stream is what the migration and the older readers use; the
275/// routed rows are what the wired-boot head writes under the conversation's
276/// checkpoint route.
277#[derive(Clone, Copy, Debug, PartialEq, Eq)]
278pub enum RowSource {
279    /// `DurableStore::truncate_before` on the stream key.
280    Legacy,
281    /// `DurableStore::remove_routed_range` over the row-key window on the
282    /// conversation's route; the head record lies outside the window.
283    Routed,
284}
285
286/// What one cut came to.
287#[derive(Clone, Debug, PartialEq, Eq)]
288pub struct PruneOutcome {
289    /// The stream.
290    pub stream: PrunedStream,
291    /// Which rows of it.
292    pub source: RowSource,
293    /// Rows below this were asked to be cut.
294    pub cut_before: u64,
295    /// Rows the store reports removed.
296    pub removed: u64,
297    /// Named when the store refused to cut; the receipt stands, the cut is
298    /// not claimed.
299    pub refused: Option<String>,
300}
301
302/// The stream key a receipt's rows live under, legacy and routed alike.
303fn 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
310/// Removes the routed rows of `receipt`'s stream below its cut.
311///
312/// The window runs from the stream's first row key to the key of
313/// `cut_before`; the head record and every later row lie outside it.
314async 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
326/// Applies a committed plan to the store, each source answered on its own.
327///
328/// The legacy stream goes through `truncate_before`, the routed rows through
329/// `remove_routed_range`. A refusal is named and the cut is not claimed.
330/// Runs the cut `plan` promised and says what each source came to: a cut
331/// by name, a refusal by name, never a claim (DUR-018). The receipts are
332/// durable with the checkpoint before this runs.
333pub(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
381/// Keeps only the receipts of `plan` not already in the ledger.
382///
383/// A cut repeated to the same point after a crash between receipt and cut
384/// then writes no second receipt (DUR-018 R3). `None` when every receipt is
385/// already written.
386///
387/// # Errors
388/// The store could not be read.
389pub(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/// A prune ledger that cannot be read whole.
405#[derive(Debug, thiserror::Error)]
406pub enum PruneLedgerError {
407    /// The store refused the read.
408    #[error("prune ledger read refused: {0}")]
409    Store(#[from] DurabilityError),
410    /// A ledger key holds bytes that are not a receipt.
411    #[error("prune receipt {key} is unreadable: {detail}")]
412    Unreadable {
413        /// The ledger key.
414        key: String,
415        /// Why.
416        detail: String,
417    },
418    /// A receipt that does not prove its own cut: a named defect, never
419    /// silently accepted.
420    #[error("prune receipt {key} does not prove its cut: {error}")]
421    Defect {
422        /// The ledger key.
423        key: String,
424        /// The proof that failed.
425        #[source]
426        error: PruneProofError,
427    },
428}
429
430/// Reads every receipt of `conversation_id`'s prune ledger, proved.
431///
432/// They come in key order; a reader learns from them exactly which rows
433/// were cut and that every one was acknowledged by every recipient owed it.
434///
435/// # Errors
436/// The store refused the read, a key holds bytes that are not a receipt, or
437/// a receipt fails its own proof.
438pub 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    /// The 11 October finding: one recipient that never acknowledged held
536    /// every cut at 0. Its records are held by the checkpoint, so the cut
537    /// sits the kept rows behind the checkpoint and the receipt names what
538    /// is held.
539    #[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    /// A receipt written before the amendment reads back: its
633    /// `acknowledged_through` is the cover, and it held nothing it named.
634    #[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}