Skip to main content

statelet_sdk/
cdc.rs

1//! Durable change-feed (CDC) — issue #824 (CDC epic #692, Phase 5c).
2//!
3//! This mirrors the canonical Python consumer (#823, Phase 5b) with identical
4//! semantics: Kafka-style client-managed committed offsets, a pluggable
5//! [`CheckpointStore`] with a file-backed atomic default ([`FileCheckpointStore`]),
6//! bootstrap-on-`compacted` (paged `Scan` baseline then resume from
7//! `snapshot_offset + 1`), heartbeat-advances-checkpoint, reconnect-on-disconnect
8//! from `last_offset + 1`, and at-least-once delivery with idempotency required on
9//! the boundary.
10
11use std::collections::HashMap;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15use std::time::Duration;
16
17use crate::proto;
18
19/// Pluggable store for a consumer's last fully-processed CDC offset.
20///
21/// Implements Kafka-style client-managed offsets: the consumer commits the
22/// offset *after* a change has been processed (at-least-once). Apps may supply a
23/// custom store that persists the offset transactionally with their own sink to
24/// achieve exactly-once-sink semantics.
25pub trait CheckpointStore: Send + Sync {
26    /// Return the last fully-processed offset for `subscription_id`, or `None`
27    /// when no checkpoint has been committed yet.
28    fn load(&self, subscription_id: &str) -> io::Result<Option<u64>>;
29
30    /// Durably record `offset` as fully processed for `subscription_id`.
31    fn commit(&self, subscription_id: &str, offset: u64) -> io::Result<()>;
32}
33
34/// Default [`CheckpointStore`] backed by an atomically-written JSON file.
35///
36/// The file maps `subscription_id -> offset`. Each commit rewrites the file via
37/// a temp file + [`std::fs::rename`], which is atomic on POSIX, so a crash
38/// between the write and the rename leaves the previous valid offset intact
39/// (never a torn/partial file).
40pub struct FileCheckpointStore {
41    path: PathBuf,
42    state: Mutex<HashMap<String, u64>>,
43}
44
45impl FileCheckpointStore {
46    /// Open (or initialize) a file-backed checkpoint store at `path`. A missing
47    /// or corrupt file is treated as "no checkpoint"; a prior atomic commit
48    /// could never produce a corrupt file.
49    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
50        let path = path.as_ref().to_path_buf();
51        let state = match std::fs::read(&path) {
52            Ok(bytes) => parse_state(&bytes),
53            Err(e) if e.kind() == io::ErrorKind::NotFound => HashMap::new(),
54            // Unreadable file: start empty rather than fail to open.
55            Err(_) => HashMap::new(),
56        };
57        Ok(Self {
58            path,
59            state: Mutex::new(state),
60        })
61    }
62}
63
64/// Parse the minimal `{"sub":offset,...}` JSON map. Returns an empty map on any
65/// parse error (corrupt files are treated as "no checkpoint").
66///
67/// The parser is quote-aware and unescapes the same escapes [`json_string`]
68/// emits, so it round-trips any `subscription_id` — including ones containing
69/// `,`, `:`, `"`, or whitespace. (A naive comma/colon split would corrupt the
70/// whole map when a single key contained those characters.)
71fn parse_state(bytes: &[u8]) -> HashMap<String, u64> {
72    let s = match std::str::from_utf8(bytes) {
73        Ok(s) => s.trim(),
74        Err(_) => return HashMap::new(),
75    };
76    let inner = match s.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
77        Some(inner) => inner.trim(),
78        None => return HashMap::new(),
79    };
80    parse_pairs(inner).unwrap_or_default()
81}
82
83/// Parse `"key":num,"key":num,...` (no surrounding braces). Returns `None` on
84/// any malformed input so the caller can fall back to "no checkpoint".
85fn parse_pairs(inner: &str) -> Option<HashMap<String, u64>> {
86    let mut out = HashMap::new();
87    let mut chars = inner.chars().peekable();
88    loop {
89        // Skip leading whitespace; an empty (or whitespace-only) map is valid.
90        while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
91            chars.next();
92        }
93        if chars.peek().is_none() {
94            return Some(out);
95        }
96        let key = parse_json_string(&mut chars)?;
97        skip_ws(&mut chars);
98        if chars.next() != Some(':') {
99            return None;
100        }
101        skip_ws(&mut chars);
102        // Read the digits of an unsigned integer value.
103        let mut num = String::new();
104        while matches!(chars.peek(), Some(c) if c.is_ascii_digit()) {
105            num.push(chars.next().unwrap());
106        }
107        let n: u64 = num.parse().ok()?;
108        out.insert(key, n);
109        skip_ws(&mut chars);
110        match chars.next() {
111            Some(',') => continue,
112            None => return Some(out),
113            Some(_) => return None,
114        }
115    }
116}
117
118fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
119    while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
120        chars.next();
121    }
122}
123
124/// Read one JSON string token (the leading `"` must be next). Unescapes the
125/// escapes [`json_string`] produces. Returns `None` on a malformed token.
126fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
127    if chars.next() != Some('"') {
128        return None;
129    }
130    let mut out = String::new();
131    loop {
132        match chars.next()? {
133            '"' => return Some(out),
134            '\\' => match chars.next()? {
135                '"' => out.push('"'),
136                '\\' => out.push('\\'),
137                'n' => out.push('\n'),
138                'r' => out.push('\r'),
139                't' => out.push('\t'),
140                _ => return None,
141            },
142            c => out.push(c),
143        }
144    }
145}
146
147/// Serialize the offset map as a compact JSON object. Keys are simple
148/// `subscription_id` strings; the file is internal so we escape conservatively.
149fn serialize_state(state: &HashMap<String, u64>) -> String {
150    let mut parts: Vec<String> = state
151        .iter()
152        .map(|(k, v)| format!("{}:{}", json_string(k), v))
153        .collect();
154    parts.sort();
155    format!("{{{}}}", parts.join(","))
156}
157
158fn json_string(s: &str) -> String {
159    let mut out = String::with_capacity(s.len() + 2);
160    out.push('"');
161    for c in s.chars() {
162        match c {
163            '"' => out.push_str("\\\""),
164            '\\' => out.push_str("\\\\"),
165            '\n' => out.push_str("\\n"),
166            '\r' => out.push_str("\\r"),
167            '\t' => out.push_str("\\t"),
168            _ => out.push(c),
169        }
170    }
171    out.push('"');
172    out
173}
174
175impl CheckpointStore for FileCheckpointStore {
176    fn load(&self, subscription_id: &str) -> io::Result<Option<u64>> {
177        let state = self.state.lock().unwrap();
178        Ok(state.get(subscription_id).copied())
179    }
180
181    fn commit(&self, subscription_id: &str, offset: u64) -> io::Result<()> {
182        let mut state = self.state.lock().unwrap();
183        state.insert(subscription_id.to_string(), offset);
184        let data = serialize_state(&state);
185
186        let dir = self
187            .path
188            .parent()
189            .filter(|p| !p.as_os_str().is_empty())
190            .map(Path::to_path_buf)
191            .unwrap_or_else(|| PathBuf::from("."));
192        std::fs::create_dir_all(&dir)?;
193
194        // Write to a unique temp file, fsync, then atomically rename over the
195        // target. A crash before the rename leaves the prior file intact.
196        let tmp = dir.join(format!(".ckpt-{}.tmp", unique_suffix()));
197        let write_and_rename = || -> io::Result<()> {
198            use std::io::Write;
199            let mut f = std::fs::File::create(&tmp)?;
200            f.write_all(data.as_bytes())?;
201            f.sync_all()?;
202            drop(f);
203            std::fs::rename(&tmp, &self.path)
204        };
205        if let Err(e) = write_and_rename() {
206            let _ = std::fs::remove_file(&tmp);
207            return Err(e);
208        }
209        Ok(())
210    }
211}
212
213/// A process-unique suffix for temp checkpoint files (pid + monotonic counter).
214fn unique_suffix() -> String {
215    use std::sync::atomic::{AtomicU64, Ordering};
216    static COUNTER: AtomicU64 = AtomicU64::new(0);
217    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
218    let nanos = std::time::SystemTime::now()
219        .duration_since(std::time::UNIX_EPOCH)
220        .map(|d| d.as_nanos())
221        .unwrap_or(0);
222    format!("{}-{}-{}", std::process::id(), nanos, n)
223}
224
225/// A single change delivered by [`crate::StateletClient::subscribe_committed`].
226///
227/// `offset` is the stable Raft log index (the resume key). `is_snapshot` is
228/// `true` for synthetic `put` changes produced by a bootstrap `Scan` after the
229/// requested offset was compacted away; those all carry the same
230/// `snapshot_offset` and `term = 0` / `seq_in_entry = 0`.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct CommittedChange {
233    pub offset: u64,
234    pub term: u64,
235    pub seq_in_entry: u32,
236    pub cf: u32,
237    pub key: Vec<u8>,
238    /// "put" | "delete" | "merge"
239    pub op: String,
240    pub value: Vec<u8>,
241    pub is_snapshot: bool,
242}
243
244/// Configuration for [`crate::StateletClient::subscribe_committed`].
245///
246/// `cf` uses `Option<u32>` so that `None` means "the client's default CF" while
247/// `Some(0)` selects CF 0 (matching the Python keyword default of `cf=None`).
248pub struct SubscribeCommittedOptions<'a> {
249    /// First Raft offset to deliver when no checkpoint applies (0 = live-only).
250    pub from_offset: u64,
251    pub cf: Option<u32>,
252    pub key_prefix: Vec<u8>,
253    pub include_values: bool,
254    pub subscription_id: Option<String>,
255    pub checkpoint: Option<&'a dyn CheckpointStore>,
256    pub auto_commit: bool,
257    pub shard_id: u64,
258}
259
260impl<'a> Default for SubscribeCommittedOptions<'a> {
261    fn default() -> Self {
262        Self {
263            from_offset: 0,
264            cf: None,
265            key_prefix: Vec::new(),
266            include_values: true,
267            subscription_id: None,
268            checkpoint: None,
269            auto_commit: true,
270            shard_id: 0,
271        }
272    }
273}
274
275/// One item received from the committed feed stream — the SDK-level mirror of
276/// `proto::committed_feed_item::Item`.
277#[derive(Debug, Clone)]
278pub enum FeedItem {
279    Change(CommittedChange),
280    /// High-watermark offset advanced with no matching change.
281    Heartbeat(u64),
282    /// The requested offset fell below the compaction floor.
283    Compacted {
284        earliest_offset: u64,
285        snapshot_offset: u64,
286    },
287}
288
289impl FeedItem {
290    /// Convert a wire `CommittedFeedItem` into the SDK-level [`FeedItem`].
291    /// Returns `None` for an empty oneof.
292    pub fn from_proto(item: proto::CommittedFeedItem) -> Option<FeedItem> {
293        use proto::committed_feed_item::Item;
294        match item.item? {
295            Item::Change(c) => Some(FeedItem::Change(CommittedChange {
296                offset: c.offset,
297                term: c.term,
298                seq_in_entry: c.seq_in_entry,
299                cf: c.cf,
300                key: c.key,
301                op: c.op,
302                value: c.value,
303                is_snapshot: false,
304            })),
305            Item::Heartbeat(hw) => Some(FeedItem::Heartbeat(hw)),
306            Item::Compacted(n) => Some(FeedItem::Compacted {
307                earliest_offset: n.earliest_offset,
308                snapshot_offset: n.snapshot_offset,
309            }),
310        }
311    }
312}
313
314/// A source of committed-feed items for one server-streaming connection.
315///
316/// `recv` yields the next [`FeedItem`], `Ok(None)` on a clean end-of-stream, and
317/// `Err` on a disconnect (which drives reconnect).
318#[tonic::async_trait]
319pub trait FeedStream: Send {
320    async fn recv(&mut self) -> Result<Option<FeedItem>, tonic::Status>;
321}
322
323/// Transport abstraction used by the consumer driver: open a feed stream and
324/// page a scan. The real [`crate::StateletClient`] implements this over gRPC;
325/// tests provide a fake.
326#[tonic::async_trait]
327pub trait FeedTransport: Send {
328    type Stream: FeedStream;
329
330    /// Open a server-streaming committed feed from `from_offset`.
331    async fn open_feed(
332        &mut self,
333        shard_id: u64,
334        from_offset: u64,
335        cf: u32,
336        key_prefix: &[u8],
337        include_values: bool,
338    ) -> Result<Self::Stream, tonic::Status>;
339
340    /// Page the scan; returns `(entries, next_cursor)` with `next_cursor = None`
341    /// when there are no more results.
342    async fn scan_page(
343        &mut self,
344        prefix: &[u8],
345        cursor: Option<&[u8]>,
346        limit: u32,
347        cf: u32,
348    ) -> Result<(Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>), tonic::Status>;
349}
350
351/// Error returned by the consumer driver: either a transport error or an error
352/// from the user's handler / checkpoint store.
353#[derive(Debug)]
354pub enum ConsumeError<E> {
355    Transport(tonic::Status),
356    Checkpoint(io::Error),
357    Handler(E),
358}
359
360impl<E: std::fmt::Display> std::fmt::Display for ConsumeError<E> {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            ConsumeError::Transport(s) => write!(f, "transport error: {s}"),
364            ConsumeError::Checkpoint(e) => write!(f, "checkpoint error: {e}"),
365            ConsumeError::Handler(e) => write!(f, "handler error: {e}"),
366        }
367    }
368}
369
370impl<E: std::fmt::Debug + std::fmt::Display> std::error::Error for ConsumeError<E> {}
371
372/// Bounded exponential backoff for reconnect.
373const BACKOFF_BASE: Duration = Duration::from_millis(500);
374const BACKOFF_MAX: Duration = Duration::from_secs(30);
375
376/// Hook used by the driver to wait between reconnect attempts. The default
377/// sleeps via tokio; tests override it to avoid real delays.
378#[tonic::async_trait]
379pub trait Sleeper: Send {
380    async fn sleep(&mut self, dur: Duration);
381}
382
383/// Default tokio-backed [`Sleeper`].
384pub struct TokioSleeper;
385
386#[tonic::async_trait]
387impl Sleeper for TokioSleeper {
388    async fn sleep(&mut self, dur: Duration) {
389        tokio::time::sleep(dur).await;
390    }
391}
392
393/// Run the durable change-feed consumer driving the canonical Phase-5b
394/// algorithm. This is generic over the [`FeedTransport`] so it is unit-testable
395/// without a live server; [`crate::StateletClient::subscribe_committed`] is the
396/// public entry point wrapping the gRPC transport.
397///
398/// `handler` is invoked for each [`CommittedChange`] in stable Raft-offset
399/// order. Returning `Ok(true)` continues; `Ok(false)` stops the consumer
400/// cleanly; `Err(e)` stops with [`ConsumeError::Handler`]. With
401/// `auto_commit = true` each change's offset is committed *after* the handler
402/// returns — giving at-least-once delivery.
403pub async fn run_consumer<T, S, H, E>(
404    transport: &mut T,
405    sleeper: &mut S,
406    opts: SubscribeCommittedOptions<'_>,
407    default_cf: u32,
408    mut handler: H,
409) -> Result<(), ConsumeError<E>>
410where
411    T: FeedTransport,
412    S: Sleeper,
413    H: FnMut(CommittedChange) -> Result<bool, E>,
414{
415    let effective_cf = opts.cf.unwrap_or(default_cf);
416    let do_commit = opts.checkpoint.is_some() && opts.subscription_id.is_some() && opts.auto_commit;
417    let sub_id = opts.subscription_id.clone();
418
419    let commit = |offset: u64| -> Result<(), ConsumeError<E>> {
420        if do_commit {
421            let ckpt = opts.checkpoint.unwrap();
422            let sid = sub_id.as_deref().unwrap();
423            ckpt.commit(sid, offset).map_err(ConsumeError::Checkpoint)?;
424        }
425        Ok(())
426    };
427
428    // Resolve the resume start: checkpoint+1 wins over from_offset.
429    let mut next_offset = opts.from_offset;
430    if let (Some(ckpt), Some(sid)) = (opts.checkpoint, sub_id.as_deref()) {
431        if let Some(saved) = ckpt.load(sid).map_err(ConsumeError::Checkpoint)? {
432            next_offset = saved + 1;
433        }
434    }
435
436    // Track the highest offset observed so reconnect can resume.
437    let mut last_offset = next_offset.saturating_sub(1);
438    let mut backoff = BACKOFF_BASE;
439
440    loop {
441        let stream = transport
442            .open_feed(
443                opts.shard_id,
444                next_offset,
445                effective_cf,
446                &opts.key_prefix,
447                opts.include_values,
448            )
449            .await;
450        let mut stream = match stream {
451            Ok(s) => s,
452            Err(_status) => {
453                next_offset = last_offset + 1;
454                sleeper.sleep(backoff).await;
455                backoff = next_backoff(backoff);
456                continue;
457            }
458        };
459
460        let mut stream_err = false;
461        let mut compacted = false;
462        loop {
463            let item = match stream.recv().await {
464                Ok(Some(item)) => item,
465                Ok(None) => {
466                    // Clean end-of-stream: reconnect to keep tailing.
467                    next_offset = last_offset + 1;
468                    break;
469                }
470                Err(_status) => {
471                    stream_err = true;
472                    break;
473                }
474            };
475
476            match item {
477                FeedItem::Change(change) => {
478                    let offset = change.offset;
479                    match handler(change).map_err(ConsumeError::Handler)? {
480                        true => {}
481                        false => return Ok(()),
482                    }
483                    if offset > last_offset {
484                        last_offset = offset;
485                    }
486                    next_offset = last_offset + 1;
487                    commit(offset)?;
488                }
489                FeedItem::Heartbeat(hw) => {
490                    // Filtered consumer matched nothing up to this watermark;
491                    // advance + commit so a later resume skips the gap.
492                    if hw > last_offset {
493                        last_offset = hw;
494                        next_offset = hw + 1;
495                        commit(hw)?;
496                    }
497                }
498                FeedItem::Compacted {
499                    earliest_offset,
500                    snapshot_offset,
501                } => {
502                    if snapshot_offset > 0 {
503                        // Rebuild baseline from a full prefix scan, then resume
504                        // strictly after the snapshot watermark.
505                        let mut cursor: Option<Vec<u8>> = None;
506                        loop {
507                            let (entries, next) = transport
508                                .scan_page(&opts.key_prefix, cursor.as_deref(), 500, effective_cf)
509                                .await
510                                .map_err(ConsumeError::Transport)?;
511                            for (key, value) in entries {
512                                let change = CommittedChange {
513                                    offset: snapshot_offset,
514                                    term: 0,
515                                    seq_in_entry: 0,
516                                    cf: effective_cf,
517                                    key,
518                                    op: "put".to_string(),
519                                    value,
520                                    is_snapshot: true,
521                                };
522                                match handler(change).map_err(ConsumeError::Handler)? {
523                                    true => {}
524                                    false => return Ok(()),
525                                }
526                            }
527                            match next {
528                                Some(c) => cursor = Some(c),
529                                None => break,
530                            }
531                        }
532                        if snapshot_offset > last_offset {
533                            last_offset = snapshot_offset;
534                        }
535                        commit(snapshot_offset)?;
536                        next_offset = snapshot_offset + 1;
537                    } else {
538                        // Old server: no state-machine watermark available;
539                        // resume at the compaction floor (pre-Phase-5a).
540                        next_offset = earliest_offset;
541                        last_offset = last_offset.max(earliest_offset.saturating_sub(1));
542                    }
543                    compacted = true;
544                    break;
545                }
546            }
547        }
548
549        if stream_err {
550            // Disconnect: resume from the next unprocessed offset after a
551            // bounded exponential backoff. at-least-once on reconnect.
552            next_offset = last_offset + 1;
553            sleeper.sleep(backoff).await;
554            backoff = next_backoff(backoff);
555        } else {
556            // A clean stream end / compaction reconnect is not a failure; reset
557            // backoff so transient cleans don't accumulate delay.
558            let _ = compacted;
559            backoff = BACKOFF_BASE;
560        }
561    }
562}
563
564fn next_backoff(cur: Duration) -> Duration {
565    let next = cur.saturating_mul(2);
566    if next > BACKOFF_MAX {
567        BACKOFF_MAX
568    } else {
569        next
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    //! Unit tests for the durable change-feed consumer (CDC Phase 5c, #824).
576    //!
577    //! These drive [`run_consumer`] and [`FileCheckpointStore`] against a fake
578    //! transport (no live server), mirroring the canonical Python tests (#823).
579
580    use super::*;
581    use std::convert::Infallible;
582    use std::sync::atomic::{AtomicUsize, Ordering};
583    use std::sync::{Arc, Mutex as StdMutex};
584
585    // ── fakes ────────────────────────────────────────────────────────────
586
587    fn change(offset: u64, key: &[u8], value: &[u8]) -> FeedItem {
588        FeedItem::Change(CommittedChange {
589            offset,
590            term: 1,
591            seq_in_entry: 0,
592            cf: 0,
593            key: key.to_vec(),
594            op: "put".to_string(),
595            value: value.to_vec(),
596            is_snapshot: false,
597        })
598    }
599
600    /// One scripted stream step: either an item or a disconnect error.
601    enum Step {
602        Item(FeedItem),
603        Err,
604    }
605
606    struct FakeStream {
607        script: Vec<Step>,
608        pos: usize,
609    }
610
611    #[tonic::async_trait]
612    impl FeedStream for FakeStream {
613        async fn recv(&mut self) -> Result<Option<FeedItem>, tonic::Status> {
614            if self.pos >= self.script.len() {
615                return Ok(None); // clean end-of-stream
616            }
617            let step = &self.script[self.pos];
618            self.pos += 1;
619            match step {
620                Step::Item(it) => Ok(Some(it.clone())),
621                Step::Err => Err(tonic::Status::unavailable("disconnect")),
622            }
623        }
624    }
625
626    type ScanPage = (Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>);
627
628    struct FakeTransport {
629        streams: Vec<Vec<Step>>,
630        scan_pages: Vec<ScanPage>,
631        requests: Arc<StdMutex<Vec<u64>>>, // recorded from_offset per open_feed
632        scan_calls: Arc<StdMutex<usize>>,
633    }
634
635    #[tonic::async_trait]
636    impl FeedTransport for FakeTransport {
637        type Stream = FakeStream;
638
639        async fn open_feed(
640            &mut self,
641            _shard_id: u64,
642            from_offset: u64,
643            _cf: u32,
644            _key_prefix: &[u8],
645            _include_values: bool,
646        ) -> Result<Self::Stream, tonic::Status> {
647            self.requests.lock().unwrap().push(from_offset);
648            let script = if self.streams.is_empty() {
649                // Nothing more scripted: a consumer that keeps looping must not
650                // spin forever (tests stop via the handler first).
651                vec![Step::Err]
652            } else {
653                self.streams.remove(0)
654            };
655            Ok(FakeStream { script, pos: 0 })
656        }
657
658        async fn scan_page(
659            &mut self,
660            _prefix: &[u8],
661            _cursor: Option<&[u8]>,
662            _limit: u32,
663            _cf: u32,
664        ) -> Result<ScanPage, tonic::Status> {
665            *self.scan_calls.lock().unwrap() += 1;
666            if self.scan_pages.is_empty() {
667                Ok((vec![], None))
668            } else {
669                Ok(self.scan_pages.remove(0))
670            }
671        }
672    }
673
674    /// A [`Sleeper`] that records call count and never actually sleeps.
675    struct NoopSleeper {
676        calls: Arc<AtomicUsize>,
677    }
678
679    #[tonic::async_trait]
680    impl Sleeper for NoopSleeper {
681        async fn sleep(&mut self, _dur: Duration) {
682            self.calls.fetch_add(1, Ordering::Relaxed);
683        }
684    }
685
686    /// Build a transport + a handler that collects up to `n` changes then stops.
687    async fn collect_n(
688        streams: Vec<Vec<Step>>,
689        scan_pages: Vec<ScanPage>,
690        opts_builder: impl for<'a> FnOnce(&'a dyn CheckpointStore) -> SubscribeCommittedOptions<'a>,
691        checkpoint: &dyn CheckpointStore,
692        n: usize,
693    ) -> (Vec<CommittedChange>, Vec<u64>, usize) {
694        let requests = Arc::new(StdMutex::new(Vec::new()));
695        let scan_calls = Arc::new(StdMutex::new(0usize));
696        let mut transport = FakeTransport {
697            streams,
698            scan_pages,
699            requests: requests.clone(),
700            scan_calls: scan_calls.clone(),
701        };
702        let mut sleeper = NoopSleeper {
703            calls: Arc::new(AtomicUsize::new(0)),
704        };
705        let collected = Arc::new(StdMutex::new(Vec::new()));
706        let collected2 = collected.clone();
707        let opts = opts_builder(checkpoint);
708        let res: Result<(), ConsumeError<Infallible>> =
709            run_consumer(&mut transport, &mut sleeper, opts, 0, move |ch| {
710                let mut c = collected2.lock().unwrap();
711                c.push(ch);
712                Ok(c.len() < n)
713            })
714            .await;
715        res.expect("consumer should stop cleanly");
716        let out = collected.lock().unwrap().clone();
717        let reqs = requests.lock().unwrap().clone();
718        let scans = *scan_calls.lock().unwrap();
719        (out, reqs, scans)
720    }
721
722    /// Create an isolated per-test directory and a checkpoint path inside it, so
723    /// the "no leftover temp file" assertion never sees another test's files.
724    fn tmp_ckpt(name: &str) -> (FileCheckpointStore, std::path::PathBuf) {
725        let mut dir = std::env::temp_dir();
726        dir.push(format!("statelet-cdc-test-{}-{}", std::process::id(), name));
727        let _ = std::fs::remove_dir_all(&dir);
728        std::fs::create_dir_all(&dir).unwrap();
729        let p = dir.join("ck.json");
730        (FileCheckpointStore::open(&p).unwrap(), p)
731    }
732
733    // ── FileCheckpointStore ──────────────────────────────────────────────
734
735    #[test]
736    fn file_checkpoint_roundtrip() {
737        let (store, p) = tmp_ckpt("roundtrip");
738        assert_eq!(store.load("sub-a").unwrap(), None);
739        store.commit("sub-a", 7).unwrap();
740        store.commit("sub-b", 11).unwrap();
741        assert_eq!(store.load("sub-a").unwrap(), Some(7));
742        assert_eq!(store.load("sub-b").unwrap(), Some(11));
743        // Re-open from disk: state survives.
744        let store2 = FileCheckpointStore::open(&p).unwrap();
745        assert_eq!(store2.load("sub-a").unwrap(), Some(7));
746        assert_eq!(store2.load("sub-b").unwrap(), Some(11));
747        let _ = std::fs::remove_file(&p);
748    }
749
750    #[test]
751    fn file_checkpoint_atomic_no_tmp_leftover() {
752        let (store, p) = tmp_ckpt("atomic");
753        store.commit("s", 3).unwrap();
754        store.commit("s", 4).unwrap();
755        let dir = p.parent().unwrap();
756        let leftovers: Vec<_> = std::fs::read_dir(dir)
757            .unwrap()
758            .filter_map(|e| e.ok())
759            .filter(|e| e.file_name().to_string_lossy().starts_with(".ckpt-"))
760            .collect();
761        assert!(leftovers.is_empty(), "leftover temp files: {leftovers:?}");
762        assert_eq!(
763            FileCheckpointStore::open(&p).unwrap().load("s").unwrap(),
764            Some(4)
765        );
766        let _ = std::fs::remove_file(&p);
767    }
768
769    #[test]
770    fn file_checkpoint_special_chars_in_subscription_id() {
771        // A subscription_id with comma / colon / quote / backslash must
772        // round-trip through disk without corrupting the rest of the map. A
773        // naive comma/colon split would have dropped every entry on reload.
774        let (store, p) = tmp_ckpt("special-chars");
775        let weird = r#"tenant:42,topic="orders\path""#;
776        store.commit(weird, 9).unwrap();
777        store.commit("plain", 3).unwrap();
778        let reopened = FileCheckpointStore::open(&p).unwrap();
779        assert_eq!(reopened.load(weird).unwrap(), Some(9));
780        assert_eq!(reopened.load("plain").unwrap(), Some(3));
781        let _ = std::fs::remove_file(&p);
782    }
783
784    #[test]
785    fn file_checkpoint_corrupt_file_is_empty() {
786        let (_unused, p) = tmp_ckpt("corrupt");
787        std::fs::write(&p, b"{ this is not json").unwrap();
788        let store = FileCheckpointStore::open(&p).unwrap();
789        assert_eq!(store.load("s").unwrap(), None);
790    }
791
792    // ── start-offset resolution ──────────────────────────────────────────
793
794    #[tokio::test]
795    async fn start_from_checkpoint_plus_one() {
796        let (ck, p) = tmp_ckpt("ckpt-plus-one");
797        ck.commit("sub", 41).unwrap();
798        let (_out, reqs, _) = collect_n(
799            vec![vec![Step::Item(change(42, b"k", b""))]],
800            vec![],
801            |c| SubscribeCommittedOptions {
802                subscription_id: Some("sub".to_string()),
803                checkpoint: Some(c),
804                ..Default::default()
805            },
806            &ck,
807            1,
808        )
809        .await;
810        assert_eq!(reqs[0], 42, "from_offset should be checkpoint(41)+1");
811        let _ = std::fs::remove_file(&p);
812    }
813
814    #[tokio::test]
815    async fn start_from_offset_when_no_checkpoint() {
816        let (ck, p) = tmp_ckpt("from-offset");
817        let (_out, reqs, _) = collect_n(
818            vec![vec![Step::Item(change(100, b"k", b""))]],
819            vec![],
820            |c| SubscribeCommittedOptions {
821                from_offset: 100,
822                subscription_id: Some("sub".to_string()),
823                checkpoint: Some(c),
824                ..Default::default()
825            },
826            &ck,
827            1,
828        )
829        .await;
830        assert_eq!(reqs[0], 100);
831        let _ = std::fs::remove_file(&p);
832    }
833
834    // ── change delivery + auto-commit ────────────────────────────────────
835
836    #[tokio::test]
837    async fn change_delivered_and_committed_after_handler() {
838        let (ck, p) = tmp_ckpt("delivered");
839        let (out, _reqs, _) = collect_n(
840            vec![vec![
841                Step::Item(change(5, b"a", b"v")),
842                Step::Item(change(6, b"b", b"")),
843            ]],
844            vec![],
845            |c| SubscribeCommittedOptions {
846                subscription_id: Some("sub".to_string()),
847                checkpoint: Some(c),
848                ..Default::default()
849            },
850            &ck,
851            2,
852        )
853        .await;
854        assert_eq!(out.len(), 2);
855        assert_eq!(out[0].offset, 5);
856        assert_eq!(out[0].key, b"a");
857        assert_eq!(out[0].op, "put");
858        assert_eq!(out[0].value, b"v");
859        assert!(!out[0].is_snapshot);
860        // Offset 5 committed after its handler ran (before the stop on 6).
861        assert_eq!(ck.load("sub").unwrap(), Some(5));
862        let _ = std::fs::remove_file(&p);
863    }
864
865    #[tokio::test]
866    async fn no_commit_when_auto_commit_false() {
867        let (ck, p) = tmp_ckpt("no-commit");
868        collect_n(
869            vec![vec![
870                Step::Item(change(5, b"a", b"")),
871                Step::Item(change(6, b"b", b"")),
872            ]],
873            vec![],
874            |c| SubscribeCommittedOptions {
875                subscription_id: Some("sub".to_string()),
876                checkpoint: Some(c),
877                auto_commit: false,
878                ..Default::default()
879            },
880            &ck,
881            2,
882        )
883        .await;
884        assert_eq!(ck.load("sub").unwrap(), None);
885        let _ = std::fs::remove_file(&p);
886    }
887
888    // ── heartbeat ────────────────────────────────────────────────────────
889
890    #[tokio::test]
891    async fn heartbeat_advances_and_commits_without_delivery() {
892        let (ck, p) = tmp_ckpt("heartbeat");
893        let (out, _reqs, _) = collect_n(
894            vec![vec![
895                Step::Item(FeedItem::Heartbeat(50)),
896                Step::Item(change(51, b"k", b"")),
897            ]],
898            vec![],
899            |c| SubscribeCommittedOptions {
900                subscription_id: Some("sub".to_string()),
901                checkpoint: Some(c),
902                ..Default::default()
903            },
904            &ck,
905            1,
906        )
907        .await;
908        assert_eq!(out[0].offset, 51, "heartbeat is not delivered");
909        // Heartbeat committed its watermark before the change was delivered.
910        assert_eq!(ck.load("sub").unwrap(), Some(50));
911        let _ = std::fs::remove_file(&p);
912    }
913
914    // ── compaction bootstrap ─────────────────────────────────────────────
915
916    #[tokio::test]
917    async fn compacted_triggers_bootstrap_scan() {
918        let (ck, p) = tmp_ckpt("compacted");
919        let (out, reqs, scans) = collect_n(
920            vec![
921                vec![Step::Item(FeedItem::Compacted {
922                    earliest_offset: 10,
923                    snapshot_offset: 20,
924                })],
925                vec![Step::Item(change(21, b"live", b""))],
926            ],
927            vec![
928                (
929                    vec![(b"k1".to_vec(), b"v1".to_vec())],
930                    Some(b"cursor1".to_vec()),
931                ),
932                (vec![(b"k2".to_vec(), b"v2".to_vec())], None),
933            ],
934            |c| SubscribeCommittedOptions {
935                subscription_id: Some("sub".to_string()),
936                checkpoint: Some(c),
937                key_prefix: b"k".to_vec(),
938                cf: Some(0),
939                ..Default::default()
940            },
941            &ck,
942            3,
943        )
944        .await;
945        // Two synthetic snapshot puts then the live tail.
946        assert!(out[0].is_snapshot && out[0].op == "put" && out[0].offset == 20);
947        assert_eq!(
948            (out[0].key.as_slice(), out[0].value.as_slice()),
949            (b"k1".as_ref(), b"v1".as_ref())
950        );
951        assert!(out[1].is_snapshot && out[1].offset == 20 && out[1].key == b"k2");
952        assert!(out[2].offset == 21 && !out[2].is_snapshot);
953        assert_eq!(
954            ck.load("sub").unwrap(),
955            Some(20),
956            "snapshot_offset committed"
957        );
958        assert_eq!(reqs[1], 21, "resume from snapshot_offset+1");
959        assert_eq!(scans, 2, "two scan pages");
960        let _ = std::fs::remove_file(&p);
961    }
962
963    #[tokio::test]
964    async fn compacted_old_server_resumes_at_earliest() {
965        let (ck, p) = tmp_ckpt("old-server");
966        let (out, reqs, scans) = collect_n(
967            vec![
968                vec![Step::Item(FeedItem::Compacted {
969                    earliest_offset: 99,
970                    snapshot_offset: 0,
971                })],
972                vec![Step::Item(change(99, b"k", b""))],
973            ],
974            vec![],
975            |c| SubscribeCommittedOptions {
976                subscription_id: Some("sub".to_string()),
977                checkpoint: Some(c),
978                ..Default::default()
979            },
980            &ck,
981            1,
982        )
983        .await;
984        assert_eq!(out[0].offset, 99);
985        assert_eq!(scans, 0, "old-server path must not bootstrap scan");
986        assert_eq!(reqs[1], 99, "resume at earliest_offset");
987        let _ = std::fs::remove_file(&p);
988    }
989
990    // ── reconnect ────────────────────────────────────────────────────────
991
992    #[tokio::test]
993    async fn stream_error_reconnects_from_last_plus_one() {
994        let (ck, p) = tmp_ckpt("reconnect");
995        let (out, reqs, _) = collect_n(
996            vec![
997                vec![Step::Item(change(5, b"a", b"")), Step::Err],
998                vec![Step::Item(change(6, b"b", b""))],
999            ],
1000            vec![],
1001            |c| SubscribeCommittedOptions {
1002                subscription_id: Some("sub".to_string()),
1003                checkpoint: Some(c),
1004                ..Default::default()
1005            },
1006            &ck,
1007            2,
1008        )
1009        .await;
1010        assert_eq!(out[0].offset, 5);
1011        assert_eq!(out[1].offset, 6);
1012        assert_eq!(reqs[1], 6, "reconnect from last+1");
1013        let _ = std::fs::remove_file(&p);
1014    }
1015}