use liminal::durability::{DurabilityError, DurableStore, RoutedBatchItem};
use serde::{Deserialize, Serialize};
use super::log::{OperationLogError, STREAM_PREFIX};
use super::operation_batch::OperationBatch;
use super::outbox_log::OUTBOX_STREAM_PREFIX;
use super::restore_checkpoint_locator::checkpoint_conversation_route;
use super::routed_stream_codec;
pub(super) const PRUNE_RECEIPT_PREFIX: &str = "prune:";
const PRUNE_RECEIPT_PREFIX_END: &[u8] = b"prune;";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrunedStream {
Operations,
Outbox,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecipientAcknowledgement {
pub participant_id: u64,
pub acknowledged_through: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PruneReceipt {
pub conversation_id: u64,
pub stream: PrunedStream,
pub cut_before: u64,
pub acknowledged_through: u64,
pub checkpoint_position: u64,
pub kept: u64,
pub recipients: Vec<RecipientAcknowledgement>,
pub at_ms: u64,
}
impl PruneReceipt {
pub fn prove(&self) -> Result<(), PruneProofError> {
if self.cut_before > self.acknowledged_through {
return Err(PruneProofError::CutAboveAcknowledgement {
cut_before: self.cut_before,
acknowledged_through: self.acknowledged_through,
});
}
if self.acknowledged_through > self.checkpoint_position {
return Err(PruneProofError::AcknowledgementAboveCheckpoint {
acknowledged_through: self.acknowledged_through,
checkpoint_position: self.checkpoint_position,
});
}
if let Some(recipient) = self
.recipients
.iter()
.find(|recipient| recipient.acknowledged_through < self.cut_before)
{
return Err(PruneProofError::RecipientBelowCut {
participant_id: recipient.participant_id,
acknowledged_through: recipient.acknowledged_through,
cut_before: self.cut_before,
});
}
Ok(())
}
#[must_use]
pub fn key(&self) -> Vec<u8> {
receipt_key(self.stream, self.cut_before)
}
}
#[must_use]
pub(super) fn receipt_key(stream: PrunedStream, cut_before: u64) -> Vec<u8> {
let name = match stream {
PrunedStream::Operations => "operations",
PrunedStream::Outbox => "outbox",
};
format!("{PRUNE_RECEIPT_PREFIX}{name}:{cut_before:020}").into_bytes()
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum PruneProofError {
#[error("cut_before {cut_before} is above acknowledged_through {acknowledged_through}")]
CutAboveAcknowledgement {
cut_before: u64,
acknowledged_through: u64,
},
#[error(
"acknowledged_through {acknowledged_through} is above checkpoint_position {checkpoint_position}"
)]
AcknowledgementAboveCheckpoint {
acknowledged_through: u64,
checkpoint_position: u64,
},
#[error(
"recipient {participant_id} acknowledged through {acknowledged_through}, below cut_before {cut_before}"
)]
RecipientBelowCut {
participant_id: u64,
acknowledged_through: u64,
cut_before: u64,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct StreamBounds {
pub(super) checkpoint_position: u64,
pub(super) acknowledged_through: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct PrunePlan {
pub(super) receipts: Vec<PruneReceipt>,
}
#[must_use]
pub(super) fn plan(
keep: Option<u64>,
conversation_id: u64,
operations: StreamBounds,
outbox: StreamBounds,
recipients: &[RecipientAcknowledgement],
at_ms: u64,
) -> Option<PrunePlan> {
let keep = keep?;
let mut receipts = Vec::new();
for (stream, bounds) in [
(PrunedStream::Operations, operations),
(PrunedStream::Outbox, outbox),
] {
let checkpoint_position = bounds.checkpoint_position;
let acknowledged_through = bounds.acknowledged_through.min(checkpoint_position);
let cut_before = acknowledged_through.saturating_sub(keep);
if cut_before == 0 {
continue;
}
receipts.push(PruneReceipt {
conversation_id,
stream,
cut_before,
acknowledged_through,
checkpoint_position,
kept: keep,
recipients: recipients.to_owned(),
at_ms,
});
}
(!receipts.is_empty()).then_some(PrunePlan { receipts })
}
pub(super) fn stage(batch: &mut OperationBatch, plan: &PrunePlan) -> Result<(), OperationLogError> {
for receipt in &plan.receipts {
receipt
.prove()
.map_err(|source| OperationLogError::PruneReceipt {
key: String::from_utf8_lossy(&receipt.key()).into_owned(),
source,
})?;
let value = serde_json::to_vec(receipt)?;
batch.stage(RoutedBatchItem {
key: receipt.key(),
expected: None,
value,
ttl: None,
})?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RowSource {
Legacy,
Routed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PruneOutcome {
pub stream: PrunedStream,
pub source: RowSource,
pub cut_before: u64,
pub removed: u64,
pub refused: Option<String>,
}
fn stream_key(receipt: &PruneReceipt) -> String {
match receipt.stream {
PrunedStream::Operations => format!("{STREAM_PREFIX}{}", receipt.conversation_id),
PrunedStream::Outbox => format!("{OUTBOX_STREAM_PREFIX}{}", receipt.conversation_id),
}
}
async fn cut_routed(store: &dyn DurableStore, receipt: &PruneReceipt) -> Result<u64, String> {
let route = checkpoint_conversation_route(receipt.conversation_id);
let stream = stream_key(receipt);
let from = routed_stream_codec::row_key(&stream, 0).map_err(|error| error.to_string())?;
let to = routed_stream_codec::row_key(&stream, receipt.cut_before)
.map_err(|error| error.to_string())?;
store
.remove_routed_range(&route, &from, &to)
.await
.map_err(|error| error.to_string())
}
pub(super) async fn cut(store: &dyn DurableStore, plan: &PrunePlan) -> Vec<PruneOutcome> {
let mut outcomes = Vec::with_capacity(plan.receipts.len().saturating_mul(2));
for receipt in &plan.receipts {
let legacy = store
.truncate_before(&stream_key(receipt), receipt.cut_before)
.await
.map_err(|error| error.to_string());
let routed = cut_routed(store, receipt).await;
for (source, answer) in [(RowSource::Legacy, legacy), (RowSource::Routed, routed)] {
let (removed, refused) = match answer {
Ok(removed) => (removed, None),
Err(refusal) => (0, Some(refusal)),
};
outcomes.push(PruneOutcome {
stream: receipt.stream,
source,
cut_before: receipt.cut_before,
removed,
refused,
});
}
}
outcomes
}
pub(super) async fn unwritten(
store: &dyn DurableStore,
conversation_id: u64,
plan: PrunePlan,
) -> Result<Option<PrunePlan>, DurabilityError> {
let route = checkpoint_conversation_route(conversation_id);
let mut receipts = Vec::with_capacity(plan.receipts.len());
for receipt in plan.receipts {
if store.get_routed(&route, &receipt.key()).await?.is_none() {
receipts.push(receipt);
}
}
Ok((!receipts.is_empty()).then_some(PrunePlan { receipts }))
}
#[derive(Debug, thiserror::Error)]
pub enum PruneLedgerError {
#[error("prune ledger read refused: {0}")]
Store(#[from] DurabilityError),
#[error("prune receipt {key} is unreadable: {detail}")]
Unreadable {
key: String,
detail: String,
},
#[error("prune receipt {key} does not prove its cut: {error}")]
Defect {
key: String,
#[source]
error: PruneProofError,
},
}
pub async fn prune_receipts(
store: &dyn DurableStore,
conversation_id: u64,
) -> Result<Vec<PruneReceipt>, PruneLedgerError> {
let route = checkpoint_conversation_route(conversation_id);
let rows = store
.scan_routed(
&route,
PRUNE_RECEIPT_PREFIX.as_bytes(),
PRUNE_RECEIPT_PREFIX_END,
)
.await?;
let mut receipts = Vec::with_capacity(rows.len());
for (key, value) in rows {
let key = String::from_utf8_lossy(&key).into_owned();
let receipt: PruneReceipt =
serde_json::from_slice(&value).map_err(|error| PruneLedgerError::Unreadable {
key: key.clone(),
detail: error.to_string(),
})?;
receipt
.prove()
.map_err(|error| PruneLedgerError::Defect { key, error })?;
receipts.push(receipt);
}
Ok(receipts)
}
#[cfg(test)]
mod tests {
use super::{
PruneProofError, PruneReceipt, PrunedStream, RecipientAcknowledgement, StreamBounds, plan,
};
const fn bounds(checkpoint_position: u64, acknowledged_through: u64) -> StreamBounds {
StreamBounds {
checkpoint_position,
acknowledged_through,
}
}
fn recipients(acks: &[(u64, u64)]) -> Vec<RecipientAcknowledgement> {
acks.iter()
.map(
|&(participant_id, acknowledged_through)| RecipientAcknowledgement {
participant_id,
acknowledged_through,
},
)
.collect()
}
#[test]
fn no_configured_bound_means_no_cut() {
assert_eq!(
plan(
None,
7,
bounds(2_048, 2_048),
bounds(2_048, 2_048),
&recipients(&[(1, 2_048)]),
0
),
None
);
}
#[test]
fn the_cut_sits_the_kept_rows_behind_the_least_acknowledgement()
-> Result<(), Box<dyn std::error::Error>> {
let planned = plan(
Some(100),
7,
bounds(2_000, 900),
bounds(2_000, 900),
&recipients(&[(1, 900), (2, 1_200), (3, 1_500)]),
5,
)
.ok_or("rows lie below the kept window")?;
assert_eq!(planned.receipts.len(), 2);
for receipt in &planned.receipts {
assert_eq!(receipt.cut_before, 800);
assert_eq!(receipt.acknowledged_through, 900);
assert_eq!(receipt.checkpoint_position, 2_000);
receipt.prove()?;
}
Ok(())
}
#[test]
fn a_recipient_that_never_acknowledged_holds_the_cut_at_zero() {
assert_eq!(
plan(
Some(256),
7,
bounds(2_048, 0),
bounds(2_048, 0),
&recipients(&[(1, 0), (2, 2_048)]),
0
),
None
);
}
#[test]
fn the_checkpoint_bounds_the_acknowledgement() -> Result<(), Box<dyn std::error::Error>> {
let planned = plan(
Some(256),
7,
bounds(2_048, 9_000),
bounds(2_048, 9_000),
&recipients(&[(1, 9_000)]),
0,
)
.ok_or("rows lie below the kept window")?;
assert_eq!(planned.receipts[0].acknowledged_through, 2_048);
assert_eq!(planned.receipts[0].cut_before, 1_792);
Ok(())
}
#[test]
fn a_receipt_that_cuts_above_an_acknowledgement_is_a_named_defect() {
let receipt = PruneReceipt {
conversation_id: 7,
stream: PrunedStream::Operations,
cut_before: 901,
acknowledged_through: 900,
checkpoint_position: 2_000,
kept: 0,
recipients: recipients(&[(1, 900)]),
at_ms: 0,
};
assert_eq!(
receipt.prove(),
Err(PruneProofError::CutAboveAcknowledgement {
cut_before: 901,
acknowledged_through: 900
})
);
let receipt = PruneReceipt {
cut_before: 800,
recipients: recipients(&[(1, 900), (2, 700)]),
..receipt
};
assert_eq!(
receipt.prove(),
Err(PruneProofError::RecipientBelowCut {
participant_id: 2,
acknowledged_through: 700,
cut_before: 800
})
);
}
}