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/// Whether `value` is the exactly-once wrapper (`{"__faucet_eo": 1, …}`).
219pub fn is_eo_envelope(value: &Value) -> bool {
220    value.get(EO_MARKER).and_then(Value::as_u64) == Some(1)
221}
222
223/// Unwrap a stored state value into `(bookmark, seq)`. A versioned envelope
224/// (#736) is looked through first.
225///
226/// A value that is the exactly-once wrapper object unwraps to its inner
227/// bookmark + seq. Anything else is treated as a legacy/at-least-once **bare
228/// bookmark** with `seq = 0` — so switching an existing pipeline to
229/// `exactly_once` resumes cleanly (the sink's own watermark is authoritative).
230pub fn unwrap_state(value: &Value) -> (Option<Value>, u64) {
231    let peeled = crate::state_version::peel_versioned(value);
232    let value = &peeled;
233    if let Value::Object(map) = value
234        && map.get(EO_MARKER).and_then(Value::as_u64) == Some(1)
235    {
236        let bookmark = match map.get(EO_BOOKMARK) {
237            None | Some(Value::Null) => None,
238            Some(v) => Some(v.clone()),
239        };
240        let seq = map.get(EO_SEQ).and_then(Value::as_u64).unwrap_or(0);
241        return (bookmark, seq);
242    }
243    // Legacy bare bookmark.
244    let bookmark = if value.is_null() {
245        None
246    } else {
247        Some(value.clone())
248    };
249    (bookmark, 0)
250}
251
252/// Canonical watermark table the SQL sinks UPSERT the commit token into.
253pub const COMMIT_TOKEN_TABLE: &str = "_faucet_commit_token";
254/// Watermark column holding the pipeline state-key (`{name}::{row_id}`).
255pub const COMMIT_TOKEN_SCOPE_COL: &str = "scope";
256/// Watermark column holding the latest committed token.
257pub const COMMIT_TOKEN_TOKEN_COL: &str = "token";
258
259/// Iceberg snapshot summary property names.
260pub const ICEBERG_SCOPE_PROP: &str = "faucet.commit-scope";
261pub const ICEBERG_TOKEN_PROP: &str = "faucet.commit-token";
262
263/// Suffix appended to a destination's name to form the staging relation an
264/// in-flight `write_mode: overwrite` run writes into.
265///
266/// Reserved: the overwrite lifecycle creates, truncates, and **drops** whatever
267/// carries this suffix, so a real destination named `<x>__faucet_ovw` would be
268/// destroyed by `abort_overwrite`. Every sink derives its staging name from this
269/// one constant so the reserved set stays enumerable by a collision check or an
270/// orphan sweep (#654 M12).
271pub const OVERWRITE_STAGING_SUFFIX: &str = "__faucet_ovw";
272/// Suffix of the transient relation the *outgoing* destination is renamed to
273/// mid-swap, on backends whose only atomic publish is a rename (MySQL, whose
274/// DDL auto-commits so a transaction cannot span the swap).
275///
276/// Extends [`OVERWRITE_STAGING_SUFFIX`], so a sweep matching the base suffix as
277/// a prefix catches this one too.
278pub const OVERWRITE_STAGING_OLD_SUFFIX: &str = "__faucet_ovw_old";
279
280/// Config key the CLI sets on every sink instance of one overwrite run (the
281/// `begin`, writer and `commit` instances) to a per-run token from
282/// [`new_overwrite_run_token`]. A sink that honours it stages under
283/// [`overwrite_staging_suffix`]`(Some(token))`, so two concurrent overwrite
284/// runs of one destination never drop or share each other's staging.
285pub const OVERWRITE_RUN_KEY: &str = "_overwrite_run";
286
287/// Length of an overwrite run token: 8 hex digits of the run's start (Unix
288/// seconds) then 6 random hex digits.
289pub const OVERWRITE_RUN_TOKEN_LEN: usize = 14;
290
291/// How long another run's tokened staging is presumed in use. A `begin`
292/// drops a sibling run's staging only once its token is older than this —
293/// the staging of a crashed run; a live run is never that old in practice.
294pub const ABANDONED_OVERWRITE_STAGING: std::time::Duration =
295    std::time::Duration::from_secs(24 * 60 * 60);
296
297/// A fresh overwrite run token (see [`OVERWRITE_RUN_KEY`]): lowercase hex,
298/// valid unquoted in every SQL dialect, ordered by start time.
299pub fn new_overwrite_run_token() -> String {
300    let secs = std::time::SystemTime::now()
301        .duration_since(std::time::UNIX_EPOCH)
302        .map(|d| d.as_secs())
303        .unwrap_or(0);
304    overwrite_run_token_at(secs, &uuid::Uuid::new_v4().simple().to_string()[..6])
305}
306
307fn overwrite_run_token_at(secs: u64, random: &str) -> String {
308    format!("{:08x}{random}", secs & 0xffff_ffff)
309}
310
311/// Whether `token` has the shape [`new_overwrite_run_token`] produces.
312pub fn is_overwrite_run_token(token: &str) -> bool {
313    token.len() == OVERWRITE_RUN_TOKEN_LEN
314        && token
315            .bytes()
316            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
317}
318
319/// The run's start (Unix seconds) a token records.
320pub fn overwrite_run_started(token: &str) -> Option<u64> {
321    is_overwrite_run_token(token)
322        .then(|| u64::from_str_radix(&token[..8], 16).ok())
323        .flatten()
324}
325
326/// Refuse a value under [`OVERWRITE_RUN_KEY`] that is not a token
327/// [`new_overwrite_run_token`] made: it becomes part of a table name.
328pub fn check_overwrite_run_token(token: &str) -> Result<(), crate::FaucetError> {
329    if is_overwrite_run_token(token) {
330        Ok(())
331    } else {
332        Err(crate::FaucetError::Config(format!(
333            "`{OVERWRITE_RUN_KEY}` must be a {OVERWRITE_RUN_TOKEN_LEN}-character lowercase hex \
334             overwrite run token, got {token:?}"
335        )))
336    }
337}
338
339/// The name prefix every tokened staging of `table` starts with
340/// (`<table>__faucet_ovw_`), for a catalog query listing them.
341pub fn overwrite_staging_prefix(table: &str) -> String {
342    format!("{table}{OVERWRITE_STAGING_SUFFIX}_")
343}
344
345/// `text` as a SQL `LIKE` pattern matching it literally, escaping `%`, `_`
346/// and `escape` itself with `escape`.
347pub fn like_literal(text: &str, escape: char) -> String {
348    let mut out = String::with_capacity(text.len() + 4);
349    for c in text.chars() {
350        if c == '%' || c == '_' || c == escape {
351            out.push(escape);
352        }
353        out.push(c);
354    }
355    out
356}
357
358/// The staging suffix of an overwrite run: [`OVERWRITE_STAGING_SUFFIX`] alone
359/// without a token (the name every release before tokens used), else
360/// `__faucet_ovw_<token>`.
361pub fn overwrite_staging_suffix(run: Option<&str>) -> String {
362    match run {
363        Some(token) => format!("{OVERWRITE_STAGING_SUFFIX}_{token}"),
364        None => OVERWRITE_STAGING_SUFFIX.to_string(),
365    }
366}
367
368/// The run token of `name` when it is a tokened staging of `table`
369/// (`<table>__faucet_ovw_<token>`, compared ignoring ASCII case), lowercased;
370/// `None` for any other name, including the untokened staging.
371pub fn overwrite_staging_token(table: &str, name: &str) -> Option<String> {
372    let prefix = overwrite_staging_prefix(table);
373    let token = (name.len() == prefix.len() + OVERWRITE_RUN_TOKEN_LEN
374        && name.is_char_boundary(prefix.len())
375        && name[..prefix.len()].eq_ignore_ascii_case(&prefix))
376    .then(|| name[prefix.len()..].to_ascii_lowercase())?;
377    is_overwrite_run_token(&token).then_some(token)
378}
379
380/// Whether `name` is the tokened staging of `table` that another run left
381/// behind long enough ago ([`ABANDONED_OVERWRITE_STAGING`]) to be dropped:
382/// `<table>__faucet_ovw_<token>` with a token other than `own`, started
383/// before `now_secs` minus the threshold. Names compare ignoring ASCII case
384/// (a catalog may fold them). The untokened staging and the mid-swap
385/// `__faucet_ovw_old` relation are never matched here.
386pub fn abandoned_overwrite_staging(
387    table: &str,
388    name: &str,
389    own: Option<&str>,
390    now_secs: u64,
391) -> bool {
392    let Some(token) = overwrite_staging_token(table, name) else {
393        return false;
394    };
395    overwrite_run_abandoned(&token, own, now_secs)
396}
397
398/// Whether overwrite run `token` belongs to another run than `own` that
399/// started more than [`ABANDONED_OVERWRITE_STAGING`] before `now_secs`: its
400/// staging is a crashed run's and may be dropped. For a backend whose staging
401/// names do not follow `<table>__faucet_ovw_<token>`.
402pub fn overwrite_run_abandoned(token: &str, own: Option<&str>, now_secs: u64) -> bool {
403    if own.is_some_and(|o| o == token) {
404        return false;
405    }
406    let Some(started) = overwrite_run_started(token) else {
407        return false;
408    };
409    // Tokens keep 32 bits of seconds; read one from the future as one era
410    // back rather than as abandoned forever.
411    let now = now_secs & 0xffff_ffff;
412    let age = if started <= now {
413        now - started
414    } else {
415        now + (1 << 32) - started
416    };
417    age > ABANDONED_OVERWRITE_STAGING.as_secs()
418}
419
420/// Length of the `__h:` + 64 hex-digit SHA-256 suffix appended to a shortened
421/// scope.
422const SCOPE_DIGEST_LEN: usize = 4 + 64;
423
424/// Fit a watermark scope into a length-capped, indexable key column.
425///
426/// The scope is the pipeline state key — `{name}::{row}` for a root, plus
427/// `::{parent_record_key}` for a child — and a child's key comes from *record
428/// data*, so it has no length bound. The SQL sinks store it as a PRIMARY KEY, and
429/// a key column cannot be unbounded (MySQL's index limit, SQL Server's 900-byte
430/// key budget), so an over-long scope either errors or — under a non-strict MySQL
431/// `sql_mode` — is **truncated**, silently collapsing two distinct rows onto one
432/// watermark so one row's committed token suppresses the other's pages (#456 L1).
433///
434/// Scopes at or under `max` are returned verbatim, so every watermark written
435/// before this existed still resolves. A longer one is replaced by a
436/// deterministic, collision-resistant digest form (`__h:<64-hex>`), which is
437/// stable across restarts — the only property the watermark needs.
438pub fn scope_key(scope: &str, max: usize) -> String {
439    if scope.len() <= max {
440        return scope.to_owned();
441    }
442    use sha2::Digest;
443    let digest = sha2::Sha256::digest(scope.as_bytes());
444    let h: String = digest.iter().map(|b| format!("{b:02x}")).collect();
445    // Keep as much of the readable head as fits, so an operator inspecting the
446    // watermark table can still tell which pipeline a row belongs to. Truncate on
447    // a char boundary — a scope may hold non-ASCII.
448    let room = max.saturating_sub(SCOPE_DIGEST_LEN);
449    let mut head = 0usize;
450    for (i, _) in scope.char_indices() {
451        if i > room {
452            break;
453        }
454        head = i;
455    }
456    let shortened = format!("{}__h:{h}", &scope[..head]);
457    tracing::debug!(
458        scope_len = scope.len(),
459        max,
460        "exactly-once scope exceeds the sink's key-column width; shortening with a digest"
461    );
462    shortened
463}
464
465/// The shortened form [`scope_key`] produced before faucet-core 1.13 (an
466/// FNV-1a digest), for a sink to read and retire a watermark written then
467/// (#862 M-017). `None` when `scope` fits in `max`: that form never changed.
468pub fn legacy_scope_key(scope: &str, max: usize) -> Option<String> {
469    const LEGACY_DIGEST_LEN: usize = 4 + 16;
470    if scope.len() <= max {
471        return None;
472    }
473    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
474    for b in scope.as_bytes() {
475        h ^= u64::from(*b);
476        h = h.wrapping_mul(0x1000_0000_01b3);
477    }
478    let room = max.saturating_sub(LEGACY_DIGEST_LEN);
479    let mut head = 0usize;
480    for (i, _) in scope.char_indices() {
481        if i > room {
482            break;
483        }
484        head = i;
485    }
486    Some(format!("{}__h:{h:016x}", &scope[..head]))
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use serde_json::json;
493
494    #[test]
495    fn token_round_trips_and_orders_lexicographically() {
496        assert_eq!(format_token(42).len(), TOKEN_WIDTH);
497        assert_eq!(parse_token(&format_token(42)), Some(42));
498        assert_eq!(parse_token(&format_token(0)), Some(0));
499        assert_eq!(parse_token(&format_token(u64::MAX)), Some(u64::MAX));
500        assert!(format_token(9) < format_token(10));
501        assert!(format_token(2) < format_token(1000));
502    }
503
504    #[test]
505    fn parse_token_rejects_garbage() {
506        assert_eq!(parse_token("abc"), None);
507        assert_eq!(parse_token(""), None);
508    }
509
510    #[test]
511    fn wrap_then_unwrap_preserves_bookmark_and_seq() {
512        let bm = json!({"lsn": "0/16B2D58"});
513        let wrapped = wrap_state(Some(&bm), 7);
514        let (got_bm, got_seq) = unwrap_state(&wrapped);
515        assert_eq!(got_bm, Some(bm));
516        assert_eq!(got_seq, 7);
517    }
518
519    #[test]
520    fn wrap_none_bookmark_unwraps_to_none() {
521        let wrapped = wrap_state(None, 3);
522        let (got_bm, got_seq) = unwrap_state(&wrapped);
523        assert_eq!(got_bm, None);
524        assert_eq!(got_seq, 3);
525    }
526
527    #[test]
528    fn legacy_bare_bookmark_unwraps_with_seq_zero() {
529        let (bm, seq) = unwrap_state(&json!("2024-12-01"));
530        assert_eq!(bm, Some(json!("2024-12-01")));
531        assert_eq!(seq, 0);
532        let (bm2, seq2) = unwrap_state(&json!({"updated_at": "2024-12-01"}));
533        assert_eq!(bm2, Some(json!({"updated_at": "2024-12-01"})));
534        assert_eq!(seq2, 0);
535    }
536
537    #[test]
538    fn object_with_non_sentinel_marker_is_treated_as_bare_bookmark() {
539        // A legacy/user object that merely contains the key must NOT be misread
540        // as an EO wrapper — only the typed sentinel `1` counts.
541        let v = json!({"__faucet_eo": null, "offset": 500});
542        let (bm, seq) = unwrap_state(&v);
543        assert_eq!(bm, Some(v));
544        assert_eq!(seq, 0);
545    }
546
547    #[test]
548    fn null_value_unwraps_to_none_seq_zero() {
549        let (bm, seq) = unwrap_state(&json!(null));
550        assert_eq!(bm, None);
551        assert_eq!(seq, 0);
552    }
553
554    #[test]
555    fn token_with_bookmark_round_trips() {
556        let bm = json!({"partition_offsets": [{"topic": "t", "partition": 0, "offset": 42}]});
557        let token = format_token_with_bookmark(7, Some(&bm));
558        assert!(token.starts_with(&format_token(7)));
559        assert_eq!(parse_token(&token), Some(7));
560        let (seq, parsed_bm) = parse_token_parts(&token).unwrap();
561        assert_eq!(seq, 7);
562        assert_eq!(parsed_bm, Some(bm));
563    }
564
565    #[test]
566    fn token_with_no_bookmark_is_bare_and_back_compatible() {
567        assert_eq!(format_token_with_bookmark(3, None), format_token(3));
568        let (seq, bm) = parse_token_parts(&format_token(3)).unwrap();
569        assert_eq!((seq, bm), (3, None));
570    }
571
572    #[test]
573    fn token_with_bookmark_orders_lexicographically_on_prefix() {
574        // The fixed-width numeric prefix keeps lexicographic order meaningful
575        // even with an embedded bookmark (kafka side-topic folding compares
576        // parsed sequences, but SQL MAX() naturally works too).
577        let a = format_token_with_bookmark(9, Some(&json!({"o": 1})));
578        let b = format_token_with_bookmark(10, Some(&json!({"o": 2})));
579        assert!(a < b);
580    }
581
582    #[test]
583    fn parse_token_parts_tolerates_garbage() {
584        assert_eq!(parse_token_parts("abc"), None);
585        assert_eq!(parse_token_parts(""), None);
586        // Bad JSON suffix: sequence survives, bookmark is dropped.
587        let (seq, bm) = parse_token_parts("00000000000000000005#{not json").unwrap();
588        assert_eq!((seq, bm), (5, None));
589        // parse_token ignores the suffix entirely.
590        assert_eq!(parse_token("00000000000000000005#{not json"), Some(5));
591    }
592
593    #[test]
594    fn derive_guarantee_prefers_atomic_then_keyed_then_at_least_once() {
595        use ReplayGuarantee::*;
596        let base = GuaranteeInputs {
597            replay: Deterministic,
598            sink_atomic: true,
599            keyed_upsert_configured: false,
600            durable_state: true,
601            dlq: false,
602        };
603        assert_eq!(
604            derive_delivery_guarantee(&base),
605            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark)
606        );
607        // Atomic path degrades without deterministic replay…
608        let non_det = GuaranteeInputs {
609            replay: NonDeterministic,
610            ..base
611        };
612        assert_eq!(
613            derive_delivery_guarantee(&non_det),
614            DeliveryGuarantee::AtLeastOnce
615        );
616        // …but keyed upsert rescues it, source-independent.
617        let keyed = GuaranteeInputs {
618            keyed_upsert_configured: true,
619            ..non_det
620        };
621        assert_eq!(
622            derive_delivery_guarantee(&keyed),
623            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert)
624        );
625        // A DLQ or missing durable state disables atomic; keyed still applies.
626        let dlq = GuaranteeInputs {
627            dlq: true,
628            keyed_upsert_configured: true,
629            ..base
630        };
631        assert_eq!(
632            derive_delivery_guarantee(&dlq),
633            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert)
634        );
635        let mem_state = GuaranteeInputs {
636            durable_state: false,
637            ..base
638        };
639        assert_eq!(
640            derive_delivery_guarantee(&mem_state),
641            DeliveryGuarantee::AtLeastOnce
642        );
643    }
644
645    #[test]
646    fn guarantee_display_is_human_readable() {
647        assert_eq!(DeliveryGuarantee::AtLeastOnce.to_string(), "at-least-once");
648        assert_eq!(
649            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark)
650                .to_string(),
651            "effectively-once (atomic watermark)"
652        );
653        assert_eq!(
654            DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert).to_string(),
655            "effectively-once (keyed upsert)"
656        );
657    }
658
659    #[test]
660    fn capability_enums_default_to_weakest() {
661        assert_eq!(
662            ReplayGuarantee::default(),
663            ReplayGuarantee::NonDeterministic
664        );
665        assert_eq!(SinkGuarantee::default(), SinkGuarantee::AtLeastOnce);
666    }
667
668    #[test]
669    fn delivery_mode_serde_is_snake_case_and_defaults_at_least_once() {
670        assert_eq!(DeliveryMode::default(), DeliveryMode::AtLeastOnce);
671        assert_eq!(
672            serde_json::to_string(&DeliveryMode::ExactlyOnce).unwrap(),
673            "\"exactly_once\""
674        );
675        let m: DeliveryMode = serde_json::from_str("\"at_least_once\"").unwrap();
676        assert_eq!(m, DeliveryMode::AtLeastOnce);
677    }
678
679    #[test]
680    fn overwrite_staging_suffixes_are_pinned() {
681        // These are on-the-wire relation names: a live destination staged under
682        // the old spelling would be stranded (and the new spelling's relation
683        // dropped) if either value ever moved.
684        assert_eq!(OVERWRITE_STAGING_SUFFIX, "__faucet_ovw");
685        assert_eq!(OVERWRITE_STAGING_OLD_SUFFIX, "__faucet_ovw_old");
686        assert_ne!(OVERWRITE_STAGING_SUFFIX, OVERWRITE_STAGING_OLD_SUFFIX);
687        // An orphan sweep matching the base suffix as a prefix must also catch
688        // the mid-swap relation.
689        assert!(OVERWRITE_STAGING_OLD_SUFFIX.starts_with(OVERWRITE_STAGING_SUFFIX));
690    }
691}
692
693#[cfg(test)]
694mod overwrite_run_tests {
695    use super::*;
696
697    #[test]
698    fn tokens_are_short_hex_and_record_their_start() {
699        let t = new_overwrite_run_token();
700        assert!(is_overwrite_run_token(&t), "{t}");
701        assert_ne!(t, new_overwrite_run_token());
702        let t = overwrite_run_token_at(0x6700_0000, "abcdef");
703        assert_eq!(t, "67000000abcdef");
704        assert_eq!(overwrite_run_started(&t), Some(0x6700_0000));
705        for bad in [
706            "",
707            "67000000abcdeg",
708            "67000000ABCDEF",
709            "67000000abcde",
710            "old",
711        ] {
712            assert!(!is_overwrite_run_token(bad), "{bad}");
713        }
714        assert_eq!(overwrite_staging_suffix(None), "__faucet_ovw");
715        assert!(check_overwrite_run_token(&t).is_ok());
716        assert!(check_overwrite_run_token("../x").is_err());
717        assert_eq!(overwrite_staging_prefix("t"), "t__faucet_ovw_");
718        assert_eq!(like_literal("a_b%c\\", '\\'), "a\\_b\\%c\\\\");
719        assert_eq!(
720            overwrite_staging_suffix(Some(&t)),
721            "__faucet_ovw_67000000abcdef"
722        );
723    }
724
725    #[test]
726    fn only_another_runs_old_staging_is_abandoned() {
727        let day = ABANDONED_OVERWRITE_STAGING.as_secs();
728        let start = 0x6700_0000u64;
729        let old = format!("t__faucet_ovw_{}", overwrite_run_token_at(start, "aaaaaa"));
730        assert!(abandoned_overwrite_staging(
731            "t",
732            &old,
733            None,
734            start + day + 1
735        ));
736        assert!(abandoned_overwrite_staging(
737            "T",
738            &old.to_uppercase(),
739            None,
740            start + day + 1
741        ));
742        // Young: a run may still be using it.
743        assert!(!abandoned_overwrite_staging("t", &old, None, start + day));
744        // Our own staging is never another run's.
745        assert!(!abandoned_overwrite_staging(
746            "t",
747            &old,
748            Some("67000000aaaaaa"),
749            start + 2 * day
750        ));
751        assert_eq!(
752            overwrite_staging_token("t", "T__FAUCET_OVW_67000000AAAAAA").as_deref(),
753            Some("67000000aaaaaa")
754        );
755        assert_eq!(overwrite_staging_token("t", "t__faucet_ovw"), None);
756        assert!(overwrite_run_abandoned(
757            "67000000aaaaaa",
758            None,
759            start + 2 * day
760        ));
761        assert!(!overwrite_run_abandoned(
762            "67000000aaaaaa",
763            Some("67000000aaaaaa"),
764            start + 2 * day
765        ));
766        assert!(!overwrite_run_abandoned("nope", None, start + 2 * day));
767        // Not a tokened staging of this table.
768        for name in [
769            "t__faucet_ovw",
770            "t__faucet_ovw_old",
771            "tt__faucet_ovw_67000000aaaaaa",
772            "t__faucet_ovw_67000000aaaaaa_x",
773            "t__faucet_ovw_zzzzzzzzaaaaaa",
774        ] {
775            assert!(
776                !abandoned_overwrite_staging("t", name, None, start + 2 * day),
777                "{name}"
778            );
779        }
780        // A token past a 32-bit wrap is one era old, not in the future.
781        let late = format!(
782            "t__faucet_ovw_{}",
783            overwrite_run_token_at(0xffff_ff00, "bbbbbb")
784        );
785        assert!(abandoned_overwrite_staging(
786            "t",
787            &late,
788            None,
789            (1 << 32) + day
790        ));
791    }
792}
793
794#[cfg(test)]
795mod scope_key_tests {
796    use super::*;
797
798    /// #456 L1: the SQL sinks store the scope as a length-capped PRIMARY KEY, so
799    /// a long child scope (its key comes from record data and has no bound) either
800    /// errored or — under a non-strict MySQL sql_mode — truncated, collapsing two
801    /// rows onto one watermark.
802    #[test]
803    fn scope_key_passes_short_scopes_through_and_shortens_long_ones() {
804        // Backwards compatible: anything that fit before is returned verbatim, so
805        // watermarks written before this existed still resolve.
806        assert_eq!(scope_key("pipe::row", 255), "pipe::row");
807        let exactly = "x".repeat(255);
808        assert_eq!(scope_key(&exactly, 255), exactly);
809
810        // Over the cap: shortened, and within the cap.
811        let long = format!("pipe::row::{}", "k".repeat(400));
812        let key = scope_key(&long, 255);
813        assert!(key.len() <= 255, "len {}", key.len());
814        assert_ne!(key, long);
815        // Keeps a readable head so the row is still attributable.
816        assert!(key.starts_with("pipe::row::"), "{key}");
817        assert!(key.contains("__h:"), "{key}");
818    }
819
820    #[test]
821    fn scope_key_is_deterministic_and_distinguishes_scopes() {
822        let a = format!("pipe::row::{}", "a".repeat(400));
823        let b = format!("pipe::row::{}", "b".repeat(400));
824        // Stable across calls — the watermark must resolve after a restart.
825        assert_eq!(scope_key(&a, 255), scope_key(&a, 255));
826        // Two distinct scopes must not collide onto one watermark. Under plain
827        // truncation both of these would become the same 255-char prefix.
828        assert_ne!(scope_key(&a, 255), scope_key(&b, 255));
829        assert_eq!(&a[..255], &format!("pipe::row::{}", "a".repeat(400))[..255]);
830    }
831
832    #[test]
833    fn scope_key_digest_is_sha256() {
834        let long = format!("pipe::row::{}", "k".repeat(400));
835        let key = scope_key(&long, 255);
836        let (_, digest) = key.rsplit_once("__h:").unwrap();
837        assert_eq!(digest.len(), 64);
838        assert_eq!(
839            &digest[..16],
840            "4c5c3b297497a0a0",
841            "pinned: a changed digest strands every shortened watermark"
842        );
843    }
844
845    #[test]
846    fn legacy_scope_key_reproduces_the_fnv_form() {
847        assert_eq!(legacy_scope_key("short", 64), None);
848        let long = "p::row::".to_string() + &"x".repeat(100);
849        let legacy = legacy_scope_key(&long, 64).unwrap();
850        assert!(legacy.len() <= 64, "{legacy}");
851        assert!(legacy.starts_with("p::row::"), "{legacy}");
852        let digest = legacy.rsplit_once("__h:").unwrap().1;
853        assert_eq!(digest.len(), 16);
854        assert_ne!(legacy, scope_key(&long, 64));
855        assert_eq!(
856            legacy_scope_key("é".repeat(40).as_str(), 30).unwrap().len(),
857            4 + 16 + 10
858        );
859    }
860
861    #[test]
862    fn scope_key_truncates_on_a_char_boundary() {
863        // A multi-byte head must not be split mid-character (that would panic).
864        let long = format!("pipé::{}", "é".repeat(400));
865        let key = scope_key(&long, 255);
866        assert!(key.len() <= 255);
867        assert!(key.contains("__h:"), "{key}");
868    }
869}