Skip to main content

etl_core/checkpoint/
tracker.rs

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