Skip to main content

spate_core/checkpoint/
tracker.rs

1//! Per-partition contiguity tracking of batch acknowledgments.
2//!
3//! A [`PartitionTracker`] receives batch registrations in sequence order
4//! and resolutions in *any* order, and computes the committable watermark:
5//! the offset just past the contiguous prefix of delivered batches. A
6//! failed batch at the head of that prefix stalls the watermark permanently.
7//! The at-least-once invariant is that a source offset is never committed
8//! past unacknowledged or failed data.
9//!
10//! Synchronous and tokio-free; all methods are `&mut self`. The concurrency
11//! of the ack path lives in [`AckRef`](super::AckRef)'s atomics and the
12//! checkpointer's channel.
13
14use super::AckStatus;
15use crate::clock::{Clock, SystemClock};
16use std::collections::VecDeque;
17use std::time::Instant;
18
19/// Result of applying one resolution to the tracker.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21#[must_use]
22pub enum ResolveOutcome {
23    /// The batch moved from pending to delivered or failed.
24    Applied,
25    /// The batch was already resolved (a duplicate resolution message).
26    Duplicate,
27    /// The sequence number precedes the tracked window; the batch already
28    /// advanced through the watermark. Duplicate of a consumed resolution.
29    AlreadyAdvanced,
30    /// The sequence number is ahead of every registration seen so far. With
31    /// a correctly ordered driver this means the registration is still in
32    /// flight; the caller should retry after draining registrations.
33    Unregistered,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37enum SlotState {
38    Pending,
39    Delivered,
40    Failed,
41}
42
43#[derive(Clone, Copy, Debug)]
44struct Slot {
45    last_offset: i64,
46    state: SlotState,
47}
48
49/// Contiguity tracker for one partition within one assignment epoch.
50///
51/// Sequence numbers start at 0 and must be registered contiguously; a gap
52/// is a driver bug and panics. Resolutions arrive out of order (sharded
53/// sinks ack whenever their flushes complete).
54///
55/// ```
56/// use spate_core::checkpoint::{AckStatus, PartitionTracker};
57///
58/// let mut t = PartitionTracker::new();
59/// t.register(0, 99); // batch 0 covers offsets ..=99
60/// t.register(1, 199);
61/// let _ = t.resolve(1, AckStatus::Delivered); // out of order
62/// assert_eq!(t.advance(), None); // batch 0 still pending
63/// let _ = t.resolve(0, AckStatus::Delivered);
64/// assert_eq!(t.advance(), Some(200)); // committable position
65/// ```
66#[derive(Debug, Default)]
67pub struct PartitionTracker {
68    /// Sequence number of `ring[0]`.
69    head_seq: u64,
70    /// Next expected registration sequence (`head_seq + ring.len()`).
71    next_seq: u64,
72    ring: VecDeque<Slot>,
73    /// Set when a failed batch reaches the head of the ring; permanent.
74    stalled_since: Option<Instant>,
75    /// Sequence of the failed batch that caused the stall.
76    stalled_seq: Option<u64>,
77}
78
79impl PartitionTracker {
80    /// A fresh tracker expecting its first registration at sequence 0.
81    #[must_use]
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Register a newly issued batch. Sequences must be contiguous.
87    ///
88    /// Registering after a stall is legal (batches already in flight when
89    /// the failure surfaced still arrive); they will never advance the
90    /// watermark.
91    ///
92    /// # Panics
93    ///
94    /// Panics on a sequence gap. The driver issued batches out of order,
95    /// which would silently corrupt watermark accounting.
96    pub fn register(&mut self, seq: u64, last_offset: i64) {
97        assert_eq!(
98            seq, self.next_seq,
99            "batch registration gap: expected seq {}, got {seq}",
100            self.next_seq
101        );
102        self.ring.push_back(Slot {
103            last_offset,
104            state: SlotState::Pending,
105        });
106        self.next_seq += 1;
107    }
108
109    /// Apply one resolution. Out-of-order and cross-batch interleavings are
110    /// expected; duplicates and unknown sequences are reported (and
111    /// `debug_assert`ed) rather than corrupting state.
112    pub fn resolve(&mut self, seq: u64, status: AckStatus) -> ResolveOutcome {
113        if seq < self.head_seq {
114            debug_assert!(false, "resolution for already-advanced batch {seq}");
115            return ResolveOutcome::AlreadyAdvanced;
116        }
117        let Ok(idx) = usize::try_from(seq - self.head_seq) else {
118            return ResolveOutcome::Unregistered;
119        };
120        let Some(slot) = self.ring.get_mut(idx) else {
121            return ResolveOutcome::Unregistered;
122        };
123        if slot.state != SlotState::Pending {
124            debug_assert!(false, "duplicate resolution for batch {seq}");
125            return ResolveOutcome::Duplicate;
126        }
127        slot.state = match status {
128            AckStatus::Delivered => SlotState::Delivered,
129            AckStatus::Failed => SlotState::Failed,
130        };
131        ResolveOutcome::Applied
132    }
133
134    /// Pop the contiguous delivered prefix and return the new committable
135    /// offset (one past the last delivered batch), or `None` if the
136    /// watermark did not move.
137    ///
138    /// If the batch at the head has failed, the tracker stalls permanently.
139    /// Everything delivered *before* the failure is still reported (commit
140    /// up to the failure is correct), but nothing past it ever will be.
141    pub fn advance(&mut self) -> Option<i64> {
142        self.advance_on(&SystemClock)
143    }
144
145    /// [`advance`](Self::advance), stamping a new stall with `clock`.
146    pub(crate) fn advance_on(&mut self, clock: &dyn Clock) -> Option<i64> {
147        let mut committable = None;
148        while let Some(slot) = self.ring.front() {
149            match slot.state {
150                SlotState::Delivered => {
151                    committable = Some(slot.last_offset + 1);
152                    self.ring.pop_front();
153                    self.head_seq += 1;
154                }
155                SlotState::Failed => {
156                    if self.stalled_since.is_none() {
157                        self.stalled_since = Some(clock.now());
158                        self.stalled_seq = Some(self.head_seq);
159                    }
160                    break;
161                }
162                SlotState::Pending => break,
163            }
164        }
165        committable
166    }
167
168    /// Whether a failed batch has reached the head and blocked the
169    /// watermark. Permanent once set.
170    #[must_use]
171    pub fn stalled(&self) -> bool {
172        self.stalled_since.is_some()
173    }
174
175    /// When the stall was first observed, for watermark-age alerting.
176    #[must_use]
177    pub fn stalled_since(&self) -> Option<Instant> {
178        self.stalled_since
179    }
180
181    /// Sequence number of the failed batch that caused the stall.
182    #[must_use]
183    pub fn stalled_seq(&self) -> Option<u64> {
184        self.stalled_seq
185    }
186
187    /// Batches issued but not yet advanced through the watermark. Used as the
188    /// backpressure trigger, and it bounds tracker memory and replay size.
189    #[must_use]
190    pub fn pending(&self) -> usize {
191        self.ring.len()
192    }
193}
194
195#[cfg(all(test, not(loom)))]
196mod tests {
197    use super::*;
198
199    fn delivered(t: &mut PartitionTracker, seq: u64) {
200        assert_eq!(
201            t.resolve(seq, AckStatus::Delivered),
202            ResolveOutcome::Applied
203        );
204    }
205
206    fn failed(t: &mut PartitionTracker, seq: u64) {
207        assert_eq!(t.resolve(seq, AckStatus::Failed), ResolveOutcome::Applied);
208    }
209
210    #[test]
211    fn happy_path_in_order() {
212        let mut t = PartitionTracker::new();
213        t.register(0, 9);
214        t.register(1, 19);
215        t.register(2, 29);
216        delivered(&mut t, 0);
217        assert_eq!(t.advance(), Some(10));
218        delivered(&mut t, 1);
219        delivered(&mut t, 2);
220        assert_eq!(t.advance(), Some(30));
221        assert_eq!(t.advance(), None);
222        assert_eq!(t.pending(), 0);
223    }
224
225    #[test]
226    fn single_batch() {
227        let mut t = PartitionTracker::new();
228        t.register(0, 41);
229        assert_eq!(t.advance(), None);
230        delivered(&mut t, 0);
231        assert_eq!(t.advance(), Some(42));
232    }
233
234    #[test]
235    fn out_of_order_resolution_waits_for_head() {
236        let mut t = PartitionTracker::new();
237        for seq in 0..4 {
238            t.register(seq, (seq as i64 + 1) * 10 - 1);
239        }
240        delivered(&mut t, 2);
241        delivered(&mut t, 3);
242        assert_eq!(t.advance(), None);
243        delivered(&mut t, 0);
244        assert_eq!(t.advance(), Some(10));
245        delivered(&mut t, 1);
246        assert_eq!(t.advance(), Some(40));
247        assert_eq!(t.pending(), 0);
248    }
249
250    #[test]
251    fn failure_at_head_stalls_permanently() {
252        let mut t = PartitionTracker::new();
253        t.register(0, 9);
254        t.register(1, 19);
255        failed(&mut t, 0);
256        delivered(&mut t, 1);
257        assert_eq!(t.advance(), None);
258        assert!(t.stalled());
259        assert_eq!(t.stalled_seq(), Some(0));
260        assert!(t.stalled_since().is_some());
261        // Nothing past the failure ever commits, even new deliveries.
262        t.register(2, 29);
263        delivered(&mut t, 2);
264        assert_eq!(t.advance(), None);
265        assert_eq!(t.pending(), 3);
266    }
267
268    #[test]
269    fn advance_commits_up_to_failure() {
270        let mut t = PartitionTracker::new();
271        t.register(0, 9);
272        t.register(1, 19);
273        t.register(2, 29);
274        delivered(&mut t, 0);
275        failed(&mut t, 1);
276        delivered(&mut t, 2);
277        // Batch 0 is committable; the failure blocks 1 and 2.
278        assert_eq!(t.advance(), Some(10));
279        assert!(t.stalled());
280        assert_eq!(t.stalled_seq(), Some(1));
281    }
282
283    #[test]
284    fn failure_behind_pending_stalls_only_when_reached() {
285        let mut t = PartitionTracker::new();
286        t.register(0, 9);
287        t.register(1, 19);
288        failed(&mut t, 1);
289        assert_eq!(t.advance(), None);
290        assert!(!t.stalled(), "failure not yet at head");
291        delivered(&mut t, 0);
292        assert_eq!(t.advance(), Some(10));
293        assert!(t.stalled());
294    }
295
296    #[test]
297    fn duplicate_and_stale_resolutions_are_reported() {
298        let mut t = PartitionTracker::new();
299        t.register(0, 9);
300        t.register(1, 19);
301        delivered(&mut t, 0);
302        assert_eq!(t.advance(), Some(10));
303
304        // Unknown: not yet registered.
305        assert_eq!(
306            t.resolve(5, AckStatus::Delivered),
307            ResolveOutcome::Unregistered
308        );
309
310        // These two trip debug_assert in debug builds; validate the outcomes
311        // in release builds only.
312        if cfg!(not(debug_assertions)) {
313            assert_eq!(
314                t.resolve(0, AckStatus::Delivered),
315                ResolveOutcome::AlreadyAdvanced
316            );
317            delivered(&mut t, 1);
318            assert_eq!(
319                t.resolve(1, AckStatus::Delivered),
320                ResolveOutcome::Duplicate
321            );
322        }
323    }
324
325    #[test]
326    #[should_panic(expected = "batch registration gap")]
327    fn registration_gap_panics() {
328        let mut t = PartitionTracker::new();
329        t.register(0, 9);
330        t.register(2, 29);
331    }
332
333    #[test]
334    fn watermark_is_monotonic() {
335        let mut t = PartitionTracker::new();
336        let mut last = 0;
337        for seq in 0..100u64 {
338            t.register(seq, (seq as i64 + 1) * 7);
339        }
340        // Resolve in a scrambled but deterministic order.
341        for step in 0..100u64 {
342            let seq = (step * 37) % 100;
343            delivered(&mut t, seq);
344            if let Some(w) = t.advance() {
345                assert!(w > last, "watermark went backwards: {last} -> {w}");
346                last = w;
347            }
348        }
349        assert_eq!(last, 100 * 7 + 1);
350        assert_eq!(t.pending(), 0);
351    }
352}
353
354#[cfg(all(test, not(loom)))]
355mod proptests {
356    use super::*;
357    use proptest::prelude::*;
358    use std::collections::BTreeMap;
359
360    #[derive(Clone, Debug)]
361    enum Op {
362        Register {
363            last_offset_step: i64,
364        },
365        /// Resolve the `pick`-th unresolved batch (mod count), failing if
366        /// `fail` is set.
367        Resolve {
368            pick: usize,
369            fail: bool,
370        },
371        Advance,
372    }
373
374    fn ops() -> impl Strategy<Value = Vec<Op>> {
375        prop::collection::vec(
376            prop_oneof![
377                (1..100i64).prop_map(|last_offset_step| Op::Register { last_offset_step }),
378                (any::<usize>(), any::<bool>()).prop_map(|(pick, fail)| Op::Resolve { pick, fail }),
379                Just(Op::Advance),
380            ],
381            0..200,
382        )
383    }
384
385    /// Reference model: every registered batch with its resolution, plus
386    /// the committed prefix length.
387    #[derive(Default)]
388    struct Reference {
389        batches: BTreeMap<u64, (i64, Option<AckStatus>)>,
390        committed: u64,
391        next_seq: u64,
392        next_offset: i64,
393        stalled: bool,
394    }
395
396    impl Reference {
397        /// Longest contiguous delivered prefix from `committed`, unless a
398        /// failure blocks it first.
399        fn expected_advance(&mut self) -> Option<i64> {
400            let mut committable = None;
401            while let Some(&(last_offset, status)) = self.batches.get(&self.committed) {
402                match status {
403                    Some(AckStatus::Delivered) => {
404                        committable = Some(last_offset + 1);
405                        self.batches.remove(&self.committed);
406                        self.committed += 1;
407                    }
408                    Some(AckStatus::Failed) => {
409                        self.stalled = true;
410                        break;
411                    }
412                    None => break,
413                }
414            }
415            committable
416        }
417    }
418
419    proptest! {
420        #[test]
421        fn tracker_matches_reference_model(ops in ops()) {
422            let mut tracker = PartitionTracker::new();
423            let mut reference = Reference::default();
424            let mut unresolved: Vec<u64> = Vec::new();
425            let mut watermark = None;
426
427            for op in ops {
428                match op {
429                    Op::Register { last_offset_step } => {
430                        reference.next_offset += last_offset_step;
431                        let seq = reference.next_seq;
432                        reference.next_seq += 1;
433                        reference.batches.insert(seq, (reference.next_offset, None));
434                        tracker.register(seq, reference.next_offset);
435                        unresolved.push(seq);
436                    }
437                    Op::Resolve { pick, fail } => {
438                        if unresolved.is_empty() {
439                            continue;
440                        }
441                        let seq = unresolved.swap_remove(pick % unresolved.len());
442                        let status = if fail { AckStatus::Failed } else { AckStatus::Delivered };
443                        reference.batches.get_mut(&seq).unwrap().1 = Some(status);
444                        prop_assert_eq!(tracker.resolve(seq, status), ResolveOutcome::Applied);
445                    }
446                    Op::Advance => {
447                        let got = tracker.advance();
448                        let expected = reference.expected_advance();
449                        prop_assert_eq!(got, expected);
450                        if let Some(w) = got {
451                            if let Some(prev) = watermark {
452                                prop_assert!(w > prev, "watermark not monotonic");
453                            }
454                            watermark = Some(got.unwrap());
455                        }
456                        prop_assert_eq!(tracker.stalled(), reference.stalled);
457                    }
458                }
459                prop_assert_eq!(
460                    tracker.pending(),
461                    reference.batches.len(),
462                    "pending mismatch"
463                );
464            }
465
466            // Final settle: one more advance must also agree.
467            let got = tracker.advance();
468            let expected = reference.expected_advance();
469            prop_assert_eq!(got, expected);
470            prop_assert_eq!(tracker.stalled(), reference.stalled);
471        }
472    }
473}