Skip to main content

faucet_core/
idempotency.rs

1//! Exactly-once / idempotent delivery primitives.
2//!
3//! The pipeline issues a monotonic **commit token** for every page that carries
4//! a bookmark. The token is persisted in the [`StateStore`](crate::state::StateStore)
5//! value next to the bookmark and committed inside the sink's own transaction,
6//! so a crash between "sink durably wrote" and "state persisted" is resolved on
7//! resume by skipping pages the sink already committed. See
8//! `docs/superpowers/specs/2026-06-09-exactly-once-delivery-design.md`.
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Delivery guarantee for a pipeline run.
14#[derive(
15    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum DeliveryMode {
19    /// Today's behaviour: a page may be re-delivered after a crash between the
20    /// sink write and the bookmark persist. Downstream must tolerate duplicates.
21    #[default]
22    AtLeastOnce,
23    /// The sink durably records a per-page commit token atomically with the
24    /// data; on resume the pipeline skips already-committed pages. Requires a
25    /// state store, an idempotent sink, and a deterministic-replay source.
26    ExactlyOnce,
27}
28
29/// How faithfully a [`Source`](crate::Source) **replays** its record stream
30/// when resumed from a bookmark.
31///
32/// This is the source-side capability the effectively-once *atomic-watermark*
33/// mechanism depends on: after a crash the pipeline re-anchors the source at a
34/// persisted position, and correctness requires that nothing before that
35/// position is re-emitted and nothing after it is skipped.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ReplayGuarantee {
39    /// Resuming from a bookmark may replay a *different* record stream
40    /// (query-based sources whose upstream can mutate, sources without
41    /// per-page bookmarks). The default.
42    #[default]
43    NonDeterministic,
44    /// The source emits a complete resume position (bookmark) on **every**
45    /// page, and resuming from any such bookmark continues the record stream
46    /// at exactly that position — no record before the bookmark is re-emitted
47    /// and none after it is skipped (immutable-log sources: CDC WAL/binlog/
48    /// change streams, Kafka partitions).
49    Deterministic,
50}
51
52/// The strongest delivery guarantee a [`Sink`](crate::Sink) can uphold.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SinkGuarantee {
56    /// Plain writes: a replayed page is written again. The default.
57    #[default]
58    AtLeastOnce,
59    /// The sink can dedup by key (`write_mode: upsert` with a configured
60    /// `key`): re-applying a record with the same key converges instead of
61    /// duplicating.
62    KeyedUpsert,
63    /// The sink can commit a page's rows **and** a commit token in one atomic
64    /// transaction ([`Sink::write_batch_idempotent`](crate::Sink)).
65    AtomicWatermark,
66}
67
68/// The mechanism through which a pipeline achieves effectively-once delivery.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum EffectivelyOnceMechanism {
72    /// Deterministic-replay source + sink that commits data and a per-page
73    /// commit token atomically; on resume already-committed pages are skipped
74    /// (or the stream is re-anchored at the sink's recorded position).
75    AtomicWatermark,
76    /// The sink dedups by key (`write_mode: upsert`); replayed records
77    /// converge on the same keyed row. Works with any source.
78    KeyedUpsert,
79}
80
81/// The end-to-end guarantee a *pipeline* provides for a given
82/// source × sink × config combination.
83///
84/// Deliberately no `ExactlyOnce` variant — distributed-consensus exactly-once
85/// is not achievable here; effectively-once (idempotent at-least-once: each
86/// record is *observably applied* once) is the ceiling, and
87/// `delivery: exactly_once` in config is precisely documented as requesting it.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case", tag = "guarantee", content = "via")]
90pub enum DeliveryGuarantee {
91    /// A crash between the sink write and the bookmark persist may re-deliver
92    /// a page. Downstream must tolerate duplicates.
93    AtLeastOnce,
94    /// Idempotent at-least-once: each record is observably applied once.
95    EffectivelyOnce(EffectivelyOnceMechanism),
96}
97
98impl std::fmt::Display for DeliveryGuarantee {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::AtLeastOnce => write!(f, "at-least-once"),
102            Self::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark) => {
103                write!(f, "effectively-once (atomic watermark)")
104            }
105            Self::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert) => {
106                write!(f, "effectively-once (keyed upsert)")
107            }
108        }
109    }
110}
111
112/// Inputs to [`derive_delivery_guarantee`] — the facts about a concrete
113/// source × sink × config combination the derivation keys off.
114#[derive(Debug, Clone, Copy, Default)]
115pub struct GuaranteeInputs {
116    /// The source's replay capability.
117    pub replay: ReplayGuarantee,
118    /// Whether the sink commits data + token atomically
119    /// (`Sink::supports_idempotent_writes`).
120    pub sink_atomic: bool,
121    /// Whether the sink is *configured* to dedup by key — `write_mode: upsert`
122    /// (or `delete`) with a non-empty `key` (`Sink::dedups_by_key`).
123    pub keyed_upsert_configured: bool,
124    /// Whether a durable (non-memory) state store is configured. The
125    /// atomic-watermark mechanism persists its cross-restart sequence here.
126    pub durable_state: bool,
127    /// Whether a DLQ is configured (incompatible with the atomic-watermark
128    /// mechanism in this version).
129    pub dlq: bool,
130}
131
132/// Derive the end-to-end [`DeliveryGuarantee`] a pipeline actually provides.
133///
134/// Preference order: the atomic-watermark mechanism (strongest bookkeeping,
135/// no keyed-schema requirement) when the topology supports it, then keyed
136/// upsert, then at-least-once. A sink that is both atomic and keyed reports
137/// atomic-watermark when the source replays deterministically, and falls back
138/// to keyed upsert otherwise.
139pub fn derive_delivery_guarantee(i: &GuaranteeInputs) -> DeliveryGuarantee {
140    if i.sink_atomic && i.replay == ReplayGuarantee::Deterministic && i.durable_state && !i.dlq {
141        return DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark);
142    }
143    if i.keyed_upsert_configured {
144        return DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert);
145    }
146    DeliveryGuarantee::AtLeastOnce
147}
148
149/// Reserved key marking the exactly-once state wrapper object.
150const EO_MARKER: &str = "__faucet_eo";
151const EO_BOOKMARK: &str = "bookmark";
152const EO_SEQ: &str = "seq";
153
154/// Width of the zero-padded decimal token. `u64::MAX` is 20 digits, so 20 makes
155/// lexicographic order match numeric order for the full `u64` range.
156const TOKEN_WIDTH: usize = 20;
157
158/// Separator between the numeric sequence and the embedded resume bookmark in
159/// a commit token. The prefix before it is always the fixed-width sequence.
160const TOKEN_BOOKMARK_SEP: char = '#';
161
162/// Render a page sequence as a fixed-width, lexicographically-ordered token.
163pub fn format_token(seq: u64) -> String {
164    format!("{seq:0TOKEN_WIDTH$}")
165}
166
167/// Render a commit token that carries the page's **resume bookmark** alongside
168/// the sequence: `"{seq:020}#{bookmark-json}"`.
169///
170/// Sinks store the token opaquely, so the committed watermark doubles as a
171/// durable record of *where the stream stood* when the page committed. On
172/// resume the pipeline recovers that position from the sink
173/// ([`parse_token_parts`]) and re-anchors the source there — closing the
174/// crash window between "sink durably committed" and "state store persisted"
175/// without requiring the source to replay identical page boundaries.
176pub fn format_token_with_bookmark(seq: u64, bookmark: Option<&Value>) -> String {
177    match bookmark {
178        Some(bm) => format!("{seq:0TOKEN_WIDTH$}{TOKEN_BOOKMARK_SEP}{bm}"),
179        None => format_token(seq),
180    }
181}
182
183/// Parse the numeric sequence from a token produced by [`format_token`] or
184/// [`format_token_with_bookmark`]. Returns `None` on garbage.
185pub fn parse_token(s: &str) -> Option<u64> {
186    let seq = match s.split_once(TOKEN_BOOKMARK_SEP) {
187        Some((prefix, _)) => prefix,
188        None => s,
189    };
190    seq.trim().parse::<u64>().ok()
191}
192
193/// Parse a stored commit token into `(seq, embedded_bookmark)`.
194///
195/// Tokens written before bookmarks were embedded (bare `format_token` output)
196/// parse with `bookmark = None`. A bookmark suffix that is not valid JSON also
197/// yields `None` for the bookmark — the sequence alone still drives the
198/// skip-on-resume path.
199pub fn parse_token_parts(s: &str) -> Option<(u64, Option<Value>)> {
200    match s.split_once(TOKEN_BOOKMARK_SEP) {
201        Some((prefix, suffix)) => {
202            let seq = prefix.trim().parse::<u64>().ok()?;
203            Some((seq, serde_json::from_str(suffix).ok()))
204        }
205        None => Some((s.trim().parse::<u64>().ok()?, None)),
206    }
207}
208
209/// Wrap a bookmark + sequence into the exactly-once state value.
210pub fn wrap_state(bookmark: Option<&Value>, seq: u64) -> Value {
211    serde_json::json!({
212        EO_MARKER: 1,
213        EO_BOOKMARK: bookmark.cloned().unwrap_or(Value::Null),
214        EO_SEQ: seq,
215    })
216}
217
218/// Unwrap a stored state value into `(bookmark, seq)`.
219///
220/// A value that is the exactly-once wrapper object unwraps to its inner
221/// bookmark + seq. Anything else is treated as a legacy/at-least-once **bare
222/// bookmark** with `seq = 0` — so switching an existing pipeline to
223/// `exactly_once` resumes cleanly (the sink's own watermark is authoritative).
224pub fn unwrap_state(value: &Value) -> (Option<Value>, u64) {
225    if let Value::Object(map) = value
226        && map.get(EO_MARKER).and_then(Value::as_u64) == Some(1)
227    {
228        let bookmark = match map.get(EO_BOOKMARK) {
229            None | Some(Value::Null) => None,
230            Some(v) => Some(v.clone()),
231        };
232        let seq = map.get(EO_SEQ).and_then(Value::as_u64).unwrap_or(0);
233        return (bookmark, seq);
234    }
235    // Legacy bare bookmark.
236    let bookmark = if value.is_null() {
237        None
238    } else {
239        Some(value.clone())
240    };
241    (bookmark, 0)
242}
243
244/// Canonical watermark table the SQL sinks UPSERT the commit token into.
245pub const COMMIT_TOKEN_TABLE: &str = "_faucet_commit_token";
246/// Watermark column holding the pipeline state-key (`{name}::{row_id}`).
247pub const COMMIT_TOKEN_SCOPE_COL: &str = "scope";
248/// Watermark column holding the latest committed token.
249pub const COMMIT_TOKEN_TOKEN_COL: &str = "token";
250
251/// Iceberg snapshot summary property names.
252pub const ICEBERG_SCOPE_PROP: &str = "faucet.commit-scope";
253pub const ICEBERG_TOKEN_PROP: &str = "faucet.commit-token";
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use serde_json::json;
259
260    #[test]
261    fn token_round_trips_and_orders_lexicographically() {
262        assert_eq!(format_token(42).len(), TOKEN_WIDTH);
263        assert_eq!(parse_token(&format_token(42)), Some(42));
264        assert_eq!(parse_token(&format_token(0)), Some(0));
265        assert_eq!(parse_token(&format_token(u64::MAX)), Some(u64::MAX));
266        assert!(format_token(9) < format_token(10));
267        assert!(format_token(2) < format_token(1000));
268    }
269
270    #[test]
271    fn parse_token_rejects_garbage() {
272        assert_eq!(parse_token("abc"), None);
273        assert_eq!(parse_token(""), None);
274    }
275
276    #[test]
277    fn wrap_then_unwrap_preserves_bookmark_and_seq() {
278        let bm = json!({"lsn": "0/16B2D58"});
279        let wrapped = wrap_state(Some(&bm), 7);
280        let (got_bm, got_seq) = unwrap_state(&wrapped);
281        assert_eq!(got_bm, Some(bm));
282        assert_eq!(got_seq, 7);
283    }
284
285    #[test]
286    fn wrap_none_bookmark_unwraps_to_none() {
287        let wrapped = wrap_state(None, 3);
288        let (got_bm, got_seq) = unwrap_state(&wrapped);
289        assert_eq!(got_bm, None);
290        assert_eq!(got_seq, 3);
291    }
292
293    #[test]
294    fn legacy_bare_bookmark_unwraps_with_seq_zero() {
295        let (bm, seq) = unwrap_state(&json!("2024-12-01"));
296        assert_eq!(bm, Some(json!("2024-12-01")));
297        assert_eq!(seq, 0);
298        let (bm2, seq2) = unwrap_state(&json!({"updated_at": "2024-12-01"}));
299        assert_eq!(bm2, Some(json!({"updated_at": "2024-12-01"})));
300        assert_eq!(seq2, 0);
301    }
302
303    #[test]
304    fn object_with_non_sentinel_marker_is_treated_as_bare_bookmark() {
305        // A legacy/user object that merely contains the key must NOT be misread
306        // as an EO wrapper — only the typed sentinel `1` counts.
307        let v = json!({"__faucet_eo": null, "offset": 500});
308        let (bm, seq) = unwrap_state(&v);
309        assert_eq!(bm, Some(v));
310        assert_eq!(seq, 0);
311    }
312
313    #[test]
314    fn null_value_unwraps_to_none_seq_zero() {
315        let (bm, seq) = unwrap_state(&json!(null));
316        assert_eq!(bm, None);
317        assert_eq!(seq, 0);
318    }
319
320    #[test]
321    fn token_with_bookmark_round_trips() {
322        let bm = json!({"partition_offsets": [{"topic": "t", "partition": 0, "offset": 42}]});
323        let token = format_token_with_bookmark(7, Some(&bm));
324        assert!(token.starts_with(&format_token(7)));
325        assert_eq!(parse_token(&token), Some(7));
326        let (seq, parsed_bm) = parse_token_parts(&token).unwrap();
327        assert_eq!(seq, 7);
328        assert_eq!(parsed_bm, Some(bm));
329    }
330
331    #[test]
332    fn token_with_no_bookmark_is_bare_and_back_compatible() {
333        assert_eq!(format_token_with_bookmark(3, None), format_token(3));
334        let (seq, bm) = parse_token_parts(&format_token(3)).unwrap();
335        assert_eq!((seq, bm), (3, None));
336    }
337
338    #[test]
339    fn token_with_bookmark_orders_lexicographically_on_prefix() {
340        // The fixed-width numeric prefix keeps lexicographic order meaningful
341        // even with an embedded bookmark (kafka side-topic folding compares
342        // parsed sequences, but SQL MAX() naturally works too).
343        let a = format_token_with_bookmark(9, Some(&json!({"o": 1})));
344        let b = format_token_with_bookmark(10, Some(&json!({"o": 2})));
345        assert!(a < b);
346    }
347
348    #[test]
349    fn parse_token_parts_tolerates_garbage() {
350        assert_eq!(parse_token_parts("abc"), None);
351        assert_eq!(parse_token_parts(""), None);
352        // Bad JSON suffix: sequence survives, bookmark is dropped.
353        let (seq, bm) = parse_token_parts("00000000000000000005#{not json").unwrap();
354        assert_eq!((seq, bm), (5, None));
355        // parse_token ignores the suffix entirely.
356        assert_eq!(parse_token("00000000000000000005#{not json"), Some(5));
357    }
358
359    #[test]
360    fn derive_guarantee_prefers_atomic_then_keyed_then_at_least_once() {
361        use ReplayGuarantee::*;
362        let base = GuaranteeInputs {
363            replay: Deterministic,
364            sink_atomic: true,
365            keyed_upsert_configured: false,
366            durable_state: true,
367            dlq: false,
368        };
369        assert_eq!(
370            derive_delivery_guarantee(&base),
371            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark)
372        );
373        // Atomic path degrades without deterministic replay…
374        let non_det = GuaranteeInputs {
375            replay: NonDeterministic,
376            ..base
377        };
378        assert_eq!(
379            derive_delivery_guarantee(&non_det),
380            DeliveryGuarantee::AtLeastOnce
381        );
382        // …but keyed upsert rescues it, source-independent.
383        let keyed = GuaranteeInputs {
384            keyed_upsert_configured: true,
385            ..non_det
386        };
387        assert_eq!(
388            derive_delivery_guarantee(&keyed),
389            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert)
390        );
391        // A DLQ or missing durable state disables atomic; keyed still applies.
392        let dlq = GuaranteeInputs {
393            dlq: true,
394            keyed_upsert_configured: true,
395            ..base
396        };
397        assert_eq!(
398            derive_delivery_guarantee(&dlq),
399            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert)
400        );
401        let mem_state = GuaranteeInputs {
402            durable_state: false,
403            ..base
404        };
405        assert_eq!(
406            derive_delivery_guarantee(&mem_state),
407            DeliveryGuarantee::AtLeastOnce
408        );
409    }
410
411    #[test]
412    fn guarantee_display_is_human_readable() {
413        assert_eq!(DeliveryGuarantee::AtLeastOnce.to_string(), "at-least-once");
414        assert_eq!(
415            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark)
416                .to_string(),
417            "effectively-once (atomic watermark)"
418        );
419        assert_eq!(
420            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert).to_string(),
421            "effectively-once (keyed upsert)"
422        );
423    }
424
425    #[test]
426    fn capability_enums_default_to_weakest() {
427        assert_eq!(
428            ReplayGuarantee::default(),
429            ReplayGuarantee::NonDeterministic
430        );
431        assert_eq!(SinkGuarantee::default(), SinkGuarantee::AtLeastOnce);
432    }
433
434    #[test]
435    fn delivery_mode_serde_is_snake_case_and_defaults_at_least_once() {
436        assert_eq!(DeliveryMode::default(), DeliveryMode::AtLeastOnce);
437        assert_eq!(
438            serde_json::to_string(&DeliveryMode::ExactlyOnce).unwrap(),
439            "\"exactly_once\""
440        );
441        let m: DeliveryMode = serde_json::from_str("\"at_least_once\"").unwrap();
442        assert_eq!(m, DeliveryMode::AtLeastOnce);
443    }
444}