Skip to main content

liminal/durability/
dedup.rs

1use crate::durability::settle::append_settled;
2use std::fmt;
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use super::head_checkpoint::READ_BATCH_SIZE;
7use super::{DurabilityError, DurableStore, ProcessingReceipt, StoredEntry};
8use codec::DedupRecord;
9pub use head::{DedupHeadCounts, dedup_head_counts};
10use in_flight::InFlightIndex;
11pub use sweep::{DedupSweepReport, DedupSweeper};
12
13#[cfg(test)]
14mod ceiling_in_flight;
15mod codec;
16mod head;
17#[cfg(test)]
18mod head_tests;
19mod in_flight;
20#[cfg(test)]
21mod in_flight_tests;
22#[cfg(test)]
23mod lookup_tests;
24#[cfg(test)]
25mod start_count_tests;
26mod sweep;
27#[cfg(test)]
28mod tests;
29mod unresolved;
30
31/// Hashes a producer idempotency key into a deterministic stream-key suffix.
32#[must_use]
33pub fn key_hash(idempotency_key: &str) -> String {
34    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
35    for byte in idempotency_key.as_bytes() {
36        hash ^= u64::from(*byte);
37        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
38    }
39    format!("{hash:016x}")
40}
41
42/// Where an in-flight claim's row would be, recorded with the claim.
43///
44/// The claim is written before its publish appends, so the row, if it exists,
45/// sits at or after the position each partition of the channel stood at when
46/// the claim was made: a lookup starts there and never reads the history
47/// before it.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ClaimEvidence {
50    channel: String,
51    positions: Vec<u64>,
52}
53
54impl ClaimEvidence {
55    /// Evidence naming the durable `channel` and, per partition, the sequence
56    /// the channel stood at when the claim was made.
57    #[must_use]
58    pub fn new(channel: impl Into<String>, positions: Vec<u64>) -> Self {
59        Self {
60            channel: channel.into(),
61            positions,
62        }
63    }
64
65    /// Durable channel whose log can establish the claim's outcome.
66    #[must_use]
67    pub fn channel(&self) -> &str {
68        &self.channel
69    }
70
71    /// Per partition, the first sequence the claim's row can sit at.
72    #[must_use]
73    pub fn positions(&self) -> &[u64] {
74        &self.positions
75    }
76}
77
78/// One claim in flight at a start: its key, when it was claimed, and where
79/// its row would be when the claim recorded that.
80///
81/// `claimed_at_millis` is the epoch millisecond the claim's entry carries,
82/// written at the claim; a start reads it from the same bounded page as the
83/// evidence. A claim with no evidence was written by a binary before the
84/// evidence lane (`liminal-server` 0.16.0 and earlier) and nothing can settle
85/// it by looking its row up (DUR-022).
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct InFlightClaim {
88    /// The producer idempotency key the claim holds.
89    pub idempotency_key: String,
90    /// Epoch milliseconds the claim was written at.
91    pub claimed_at_millis: u64,
92    /// Where the claim's row would be, when the claim recorded it.
93    pub evidence: Option<ClaimEvidence>,
94}
95
96impl InFlightClaim {
97    fn of(idempotency_key: String, entry: &DedupEntry) -> Self {
98        Self {
99            idempotency_key,
100            claimed_at_millis: entry.timestamp_millis(),
101            evidence: entry.evidence().cloned(),
102        }
103    }
104}
105
106/// Persisted dedup cache entry for one producer idempotency key.
107#[derive(Clone, PartialEq, Eq)]
108pub struct DedupEntry {
109    idempotency_key: String,
110    receipt: Option<Vec<u8>>,
111    timestamp_millis: u64,
112    evidence: Option<ClaimEvidence>,
113}
114
115impl DedupEntry {
116    /// Builds an entry containing the original key, optional receipt bytes, and epoch millis.
117    #[must_use]
118    pub fn new(
119        idempotency_key: impl Into<String>,
120        receipt: Option<Vec<u8>>,
121        timestamp_millis: u64,
122    ) -> Self {
123        Self {
124            idempotency_key: idempotency_key.into(),
125            receipt,
126            timestamp_millis,
127            evidence: None,
128        }
129    }
130
131    /// Returns the original idempotency key stored with this entry.
132    #[must_use]
133    pub fn idempotency_key(&self) -> &str {
134        &self.idempotency_key
135    }
136
137    /// Durable channel whose log can establish this in-flight claim's outcome.
138    #[must_use]
139    pub fn evidence_channel(&self) -> Option<&str> {
140        self.evidence.as_ref().map(ClaimEvidence::channel)
141    }
142
143    /// Where this in-flight claim's row would be, when the claim recorded it.
144    #[must_use]
145    pub const fn evidence(&self) -> Option<&ClaimEvidence> {
146        self.evidence.as_ref()
147    }
148
149    /// Returns the stored opaque receipt bytes, when processing has completed.
150    #[must_use]
151    pub fn receipt(&self) -> Option<&[u8]> {
152        self.receipt.as_deref()
153    }
154
155    /// Returns the entry timestamp in epoch milliseconds.
156    #[must_use]
157    pub const fn timestamp_millis(&self) -> u64 {
158        self.timestamp_millis
159    }
160
161    /// Serializes this active cache entry into deterministic storage bytes.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`DurabilityError::EnvelopeError`] when a field length cannot be encoded.
166    pub fn serialize(&self) -> Result<Vec<u8>, DurabilityError> {
167        DedupRecord::Active(self.clone()).serialize()
168    }
169
170    /// Deserializes an active cache entry previously produced by [`Self::serialize`].
171    ///
172    /// # Errors
173    ///
174    /// Returns [`DurabilityError::EnvelopeError`] when bytes are malformed or contain a tombstone.
175    pub fn deserialize(bytes: &[u8]) -> Result<Self, DurabilityError> {
176        match DedupRecord::deserialize(bytes)? {
177            DedupRecord::Active(entry) => Ok(entry),
178            DedupRecord::Tombstone { .. } => Err(DurabilityError::EnvelopeError(
179                "dedup tombstone is not an active entry".to_owned(),
180            )),
181        }
182    }
183}
184
185impl fmt::Debug for DedupEntry {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter
188            .debug_struct("DedupEntry")
189            .field("idempotency_key", &self.idempotency_key)
190            .field("receipt_bytes", &self.receipt.as_ref().map(Vec::len))
191            .field("timestamp_millis", &self.timestamp_millis)
192            .field("evidence", &self.evidence)
193            .finish()
194    }
195}
196
197/// Result of checking or claiming an idempotency key.
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub enum DedupDecision {
200    /// The caller claimed the key and may deliver the message for processing.
201    Claimed,
202    /// The key was already completed; return this receipt without re-delivery.
203    Completed(ProcessingReceipt),
204    /// The key is already being processed and delivery must be deferred.
205    InFlight,
206}
207
208/// Haematite-backed idempotency-key cache for the lightweight dedup contract.
209///
210/// Clones share one in-flight index (`dedup/in_flight.rs`). The index assumes
211/// it is the only one kept for its namespace in the process, as the store
212/// assumes one writing process: a second cache built over the same namespace
213/// is a second process's view, as a restart would be.
214#[derive(Clone)]
215pub struct DedupCache {
216    store: Arc<dyn DurableStore>,
217    namespace: String,
218    in_flight: Arc<InFlightIndex>,
219}
220
221impl DedupCache {
222    /// Creates a dedup cache over the given durable store and namespace prefix.
223    #[must_use]
224    pub fn new(store: Arc<dyn DurableStore>, namespace: impl Into<String>) -> Self {
225        Self {
226            store,
227            namespace: namespace.into(),
228            in_flight: Arc::new(InFlightIndex::default()),
229        }
230    }
231
232    /// Creates a dedup cache over a namespace nothing has written to: one in
233    /// a store this process created, or one no earlier process used.
234    ///
235    /// Its in-flight index starts empty without reading the store, so
236    /// [`Self::in_flight_claims`] reads nothing and no claim walks the
237    /// namespace. [`Self::record_empty_start`], or failing that the first
238    /// claim, records the index start before anything is written, so a later
239    /// process opening the namespace with [`Self::new`] reads the index and
240    /// never walks it. A namespace that may hold claims is opened with
241    /// [`Self::new`].
242    #[must_use]
243    pub fn new_empty(store: Arc<dyn DurableStore>, namespace: impl Into<String>) -> Self {
244        Self {
245            store,
246            namespace: namespace.into(),
247            in_flight: Arc::new(InFlightIndex::empty()),
248        }
249    }
250
251    /// Returns the configured dedup namespace.
252    #[must_use]
253    pub fn namespace(&self) -> &str {
254        &self.namespace
255    }
256
257    /// Formats the haematite stream key for an idempotency key.
258    #[must_use]
259    pub fn stream_key_for(&self, idempotency_key: &str) -> String {
260        format!("{}:{}", self.namespace, key_hash(idempotency_key))
261    }
262
263    /// Looks up an existing key without appending a record. A stream of
264    /// fewer than one page of records is read in one page, as before DUR-008
265    /// A12; a longer one is read from its verified recorded head, and one
266    /// whose head was never recorded is walked once and its head recorded.
267    ///
268    /// # Errors
269    ///
270    /// Propagates store read errors and returns [`DurabilityError::DedupCollision`] when
271    /// the hashed stream contains a different original idempotency key.
272    pub async fn lookup(
273        &self,
274        idempotency_key: &str,
275    ) -> Result<Option<DedupDecision>, DurabilityError> {
276        let stream_key = self.stream_key_for(idempotency_key);
277        let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
278        Ok(snapshot.current.as_ref().map(decision_for_entry))
279    }
280
281    /// Claims a new key or returns the existing completed/in-flight decision.
282    ///
283    /// Callers must only deliver the message when this returns [`DedupDecision::Claimed`].
284    ///
285    /// # Errors
286    ///
287    /// Propagates store errors and serialization errors. A concurrent first claim that wins
288    /// the append race is converted into the duplicate completed/in-flight decision.
289    pub async fn claim_or_get(
290        &self,
291        idempotency_key: &str,
292        timestamp_millis: u64,
293    ) -> Result<DedupDecision, DurabilityError> {
294        let stream_key = self.stream_key_for(idempotency_key);
295        let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
296        if let Some(entry) = snapshot.current.as_ref() {
297            return Ok(decision_for_entry(entry));
298        }
299
300        let entry = DedupEntry::new(idempotency_key, None, timestamp_millis);
301        self.record_empty_start().await?;
302        match append_settled(
303            self.store.as_ref(),
304            &stream_key,
305            entry.serialize()?,
306            snapshot.next_seq,
307        )
308        .await
309        {
310            Ok(_) => Ok(DedupDecision::Claimed),
311            Err(DurabilityError::SequenceConflict { expected, actual }) => {
312                self.decision_after_conflict(&stream_key, idempotency_key, expected, actual)
313                    .await
314            }
315            Err(error) => Err(error),
316        }
317    }
318
319    /// Stores a processing receipt by appending a completed entry for an existing key.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`DurabilityError::ConfigError`] when the current system time cannot be encoded.
324    /// Returns [`DurabilityError::DedupCollision`] when the key is missing or collides with
325    /// another original key, and propagates serialization/store append errors.
326    pub async fn complete_receipt(
327        &self,
328        idempotency_key: &str,
329        receipt: ProcessingReceipt,
330    ) -> Result<(), DurabilityError> {
331        self.complete_receipt_at(idempotency_key, receipt, current_epoch_millis()?)
332            .await
333    }
334
335    /// Stores a processing receipt with an explicit receipt timestamp.
336    ///
337    /// This is useful for deterministic tests and for callers that already have a trusted
338    /// processing-completion timestamp. The timestamp is the TTL anchor for the stored receipt.
339    ///
340    /// # Errors
341    ///
342    /// Returns [`DurabilityError::DedupCollision`] when the key is missing, collides with
343    /// another original key, or has already completed with different receipt bytes. Propagates
344    /// serialization/store append errors.
345    async fn complete_receipt_at(
346        &self,
347        idempotency_key: &str,
348        receipt: ProcessingReceipt,
349        timestamp_millis: u64,
350    ) -> Result<(), DurabilityError> {
351        let stream_key = self.stream_key_for(idempotency_key);
352        let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
353        let Some(entry) = snapshot.current.as_ref() else {
354            return Err(DurabilityError::DedupCollision {
355                key: idempotency_key.to_owned(),
356            });
357        };
358
359        let receipt_bytes = receipt.into_bytes();
360        if let Some(existing_receipt) = entry.receipt() {
361            if existing_receipt == receipt_bytes.as_slice() {
362                self.leave_in_flight(idempotency_key, snapshot.claim_sequence()?)
363                    .await;
364                return Ok(());
365            }
366            return Err(DurabilityError::DedupCollision {
367                key: idempotency_key.to_owned(),
368            });
369        }
370
371        let completed = DedupEntry::new(
372            entry.idempotency_key().to_owned(),
373            Some(receipt_bytes.clone()),
374            timestamp_millis,
375        );
376        match append_settled(
377            self.store.as_ref(),
378            &stream_key,
379            completed.serialize()?,
380            snapshot.next_seq,
381        )
382        .await
383        {
384            Ok(_) => {}
385            Err(DurabilityError::SequenceConflict { expected, actual }) => {
386                self.confirm_matching_receipt(
387                    &stream_key,
388                    idempotency_key,
389                    &receipt_bytes,
390                    expected,
391                    actual,
392                )
393                .await?;
394            }
395            Err(error) => return Err(error),
396        }
397        self.leave_in_flight(idempotency_key, snapshot.claim_sequence()?)
398            .await;
399        Ok(())
400    }
401
402    /// Releases an in-flight claim so the key becomes re-claimable.
403    ///
404    /// Callers use this on the publish failure path: a key was claimed
405    /// ([`DedupDecision::Claimed`]) but the downstream delivery failed before a
406    /// receipt could be recorded, leaving the key dangling [`DedupDecision::InFlight`]
407    /// forever and permanently suppressing every re-publish. Releasing appends a
408    /// tombstone so the next claim succeeds.
409    ///
410    /// This NEVER clobbers a stored receipt: an already-completed key (receipt
411    /// present) and an absent key are both no-ops, preserving the at-most-once
412    /// guarantee. Only a current in-flight entry (active, no receipt) is tombstoned.
413    ///
414    /// # Errors
415    ///
416    /// Propagates store read/append and serialization errors. A [`DurabilityError::SequenceConflict`]
417    /// on the tombstone append is re-checked: if the latest state is now completed
418    /// or already a tombstone the release is treated as successful, otherwise the
419    /// conflict is propagated.
420    pub async fn release_claim(&self, idempotency_key: &str) -> Result<(), DurabilityError> {
421        self.release_claim_at(idempotency_key, current_epoch_millis()?)
422            .await
423    }
424
425    async fn release_claim_at(
426        &self,
427        idempotency_key: &str,
428        timestamp_millis: u64,
429    ) -> Result<(), DurabilityError> {
430        let stream_key = self.stream_key_for(idempotency_key);
431        let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
432        // No-op when there is nothing in flight: absent key, or a completed key
433        // whose receipt must never be clobbered (guards at-most-once).
434        let Some(entry) = snapshot.current.as_ref() else {
435            return Ok(());
436        };
437        if entry.receipt().is_some() {
438            return Ok(());
439        }
440
441        let tombstone = DedupRecord::tombstone(idempotency_key.to_owned(), timestamp_millis);
442        match append_settled(
443            self.store.as_ref(),
444            &stream_key,
445            tombstone.serialize()?,
446            snapshot.next_seq,
447        )
448        .await
449        {
450            Ok(_) => {}
451            Err(DurabilityError::SequenceConflict { expected, actual }) => {
452                self.confirm_release_after_conflict(&stream_key, idempotency_key, expected, actual)
453                    .await?;
454            }
455            Err(error) => return Err(error),
456        }
457        self.leave_in_flight(idempotency_key, snapshot.claim_sequence()?)
458            .await;
459        Ok(())
460    }
461
462    async fn confirm_release_after_conflict(
463        &self,
464        stream_key: &str,
465        idempotency_key: &str,
466        expected: u64,
467        actual: u64,
468    ) -> Result<(), DurabilityError> {
469        // A concurrent writer advanced the stream after our snapshot. Re-load and
470        // re-check the latest record directly (not via `into_active`, so a fresh
471        // tombstone is distinguishable): if it is now completed (receipt present)
472        // or already a tombstone, the in-flight claim is gone and the release goal
473        // is met. A still-active no-receipt entry means a legitimate re-claim won
474        // the race, so we must not clobber it -- propagate the conflict.
475        let latest = self.latest_record(stream_key, idempotency_key).await?;
476        match latest {
477            Some(DedupRecord::Tombstone { .. }) => Ok(()),
478            Some(DedupRecord::Active(entry)) if entry.receipt().is_some() => Ok(()),
479            _ => Err(DurabilityError::SequenceConflict { expected, actual }),
480        }
481    }
482
483    async fn latest_record(
484        &self,
485        stream_key: &str,
486        idempotency_key: &str,
487    ) -> Result<Option<DedupRecord>, DurabilityError> {
488        Ok(self.latest(stream_key, idempotency_key).await?.0)
489    }
490
491    /// The key stream's latest record and its next append sequence.
492    ///
493    /// A lookup reads the stream's first page. A page short of
494    /// [`READ_BATCH_SIZE`] rows is the whole stream, so a short stream, the
495    /// common case, costs one `read_from` and nothing else, as it did before
496    /// DUR-008 A12, and every record's key is checked. Only a stream that
497    /// fills its first page is read past it from its verified recorded head
498    /// (`dedup/head.rs`), at a cost bounded by that page plus the rows past
499    /// the head, never the key's whole history. No append records a head.
500    async fn latest(
501        &self,
502        stream_key: &str,
503        idempotency_key: &str,
504    ) -> Result<(Option<DedupRecord>, u64), DurabilityError> {
505        // ast-grep-allow: no-read-from-start: one bounded page is the whole of a short key stream (the J5 path, no head read); a stream that fills it is read past it from its verified head
506        let page = self.store.read_from(stream_key, 0, READ_BATCH_SIZE).await?;
507        if page.len() < READ_BATCH_SIZE {
508            let latest = fold_page(stream_key, idempotency_key, &page, 0)?;
509            return Ok((latest, len_to_u64(page.len())?));
510        }
511        self.latest_past_first_page(stream_key, idempotency_key, &page)
512            .await
513    }
514
515    fn scan_prefix(&self) -> String {
516        format!("{}:", self.namespace)
517    }
518
519    async fn decision_after_conflict(
520        &self,
521        stream_key: &str,
522        idempotency_key: &str,
523        expected: u64,
524        actual: u64,
525    ) -> Result<DedupDecision, DurabilityError> {
526        let snapshot = self.load_snapshot(stream_key, idempotency_key).await?;
527        snapshot.current.as_ref().map_or(
528            Err(DurabilityError::SequenceConflict { expected, actual }),
529            |entry| Ok(decision_for_entry(entry)),
530        )
531    }
532
533    async fn confirm_matching_receipt(
534        &self,
535        stream_key: &str,
536        idempotency_key: &str,
537        receipt_bytes: &[u8],
538        expected: u64,
539        actual: u64,
540    ) -> Result<(), DurabilityError> {
541        let snapshot = self.load_snapshot(stream_key, idempotency_key).await?;
542        if snapshot
543            .current
544            .as_ref()
545            .and_then(DedupEntry::receipt)
546            .is_some_and(|bytes| bytes == receipt_bytes)
547        {
548            Ok(())
549        } else {
550            Err(DurabilityError::SequenceConflict { expected, actual })
551        }
552    }
553
554    async fn load_snapshot(
555        &self,
556        stream_key: &str,
557        idempotency_key: &str,
558    ) -> Result<StreamSnapshot, DurabilityError> {
559        let (latest, next_seq) = self.latest(stream_key, idempotency_key).await?;
560        Ok(StreamSnapshot {
561            current: latest.and_then(DedupRecord::into_active),
562            next_seq,
563        })
564    }
565}
566
567impl fmt::Debug for DedupCache {
568    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
569        formatter
570            .debug_struct("DedupCache")
571            .field("namespace", &self.namespace)
572            .field("store", &self.store)
573            .field("in_flight", &self.in_flight)
574            .finish()
575    }
576}
577
578struct StreamSnapshot {
579    current: Option<DedupEntry>,
580    next_seq: u64,
581}
582
583impl StreamSnapshot {
584    /// The sequence of the stream's latest record, the claim a completion or
585    /// release written after this snapshot settles.
586    fn claim_sequence(&self) -> Result<u64, DurabilityError> {
587        self.next_seq.checked_sub(1).ok_or_else(|| {
588            DurabilityError::ConfigError("dedup stream has no record to settle".to_owned())
589        })
590    }
591}
592
593fn decision_for_entry(entry: &DedupEntry) -> DedupDecision {
594    entry.receipt().map_or(DedupDecision::InFlight, |bytes| {
595        DedupDecision::Completed(ProcessingReceipt::new(bytes.to_vec()))
596    })
597}
598
599/// Decodes a key stream's record, refusing one written for another original
600/// key.
601fn checked_record(payload: &[u8], idempotency_key: &str) -> Result<DedupRecord, DurabilityError> {
602    let record = DedupRecord::deserialize(payload)?;
603    if record.idempotency_key() != idempotency_key {
604        return Err(DurabilityError::DedupCollision {
605            key: idempotency_key.to_owned(),
606        });
607    }
608    Ok(record)
609}
610
611/// Checks one page of a key stream read from `offset`: each row sits at its
612/// own position and holds a record of `idempotency_key`. Answers the page's
613/// last record.
614fn fold_page(
615    stream_key: &str,
616    idempotency_key: &str,
617    page: &[StoredEntry],
618    offset: u64,
619) -> Result<Option<DedupRecord>, DurabilityError> {
620    let mut latest = None;
621    let mut expected = offset;
622    for stored in page {
623        if stored.sequence != expected {
624            return Err(DurabilityError::ConfigError(format!(
625                "dedup stream {stream_key} answered sequence {} where {expected} was read",
626                stored.sequence
627            )));
628        }
629        latest = Some(checked_record(&stored.payload, idempotency_key)?);
630        expected = expected.checked_add(1).ok_or_else(|| {
631            DurabilityError::ConfigError(format!("dedup stream {stream_key} sequence overflow"))
632        })?;
633    }
634    Ok(latest)
635}
636
637fn len_to_u64(len: usize) -> Result<u64, DurabilityError> {
638    u64::try_from(len).map_err(|error| {
639        DurabilityError::ConfigError(format!("dedup entry count cannot fit u64: {error}"))
640    })
641}
642
643fn current_epoch_millis() -> Result<u64, DurabilityError> {
644    let duration = SystemTime::now()
645        .duration_since(UNIX_EPOCH)
646        .map_err(|error| {
647            DurabilityError::ConfigError(format!("system clock is before Unix epoch: {error}"))
648        })?;
649    u64::try_from(duration.as_millis()).map_err(|error| {
650        DurabilityError::ConfigError(format!(
651            "current epoch millis cannot fit u64 for dedup receipt: {error}"
652        ))
653    })
654}