Skip to main content

etl_core/checkpoint/
checkpointer.rs

1//! The checkpointer: turns asynchronous batch resolutions into per-partition
2//! committable watermarks.
3//!
4//! Ownership model: the pipeline runtime owns the [`Checkpointer`]
5//! (`&mut self`, single-threaded); each pipeline thread owns an
6//! [`AckIssuer`] and creates one [`AckRef`] per source poll batch. Both
7//! directions are wait-free for producers: issuing sends a registration on
8//! an unbounded channel, and batch resolution happens in `AckRef`'s drop
9//! path. Acks can therefore never block behind data — the invariant that
10//! makes the backpressure design deadlock-free.
11
12use super::ack::AckTx;
13use super::tracker::{PartitionTracker, ResolveOutcome};
14use super::{AckMsg, AckRef, BatchId};
15use crate::record::PartitionId;
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU32, Ordering};
19use std::time::Instant;
20
21/// Registration of a newly issued batch, sent issuer → checkpointer.
22#[derive(Clone, Copy, Debug)]
23struct Registration {
24    id: BatchId,
25    last_offset: i64,
26}
27
28/// Counters from one [`Checkpointer::drain`] call, for metrics.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub struct DrainStats {
31    /// Resolutions applied to a tracker.
32    pub applied: usize,
33    /// Registrations or resolutions discarded because their epoch is not
34    /// current or their partition is not assigned (normal after rebalance).
35    pub stale_epoch: usize,
36    /// Duplicate resolutions (already resolved or already advanced).
37    pub duplicates: usize,
38    /// Resolutions that never found a registration — driver bug.
39    pub unknown: usize,
40}
41
42/// Creates acknowledgement handles on pipeline threads.
43///
44/// One issuer per pipeline thread. Within an epoch, a partition must be
45/// issued from exactly one issuer (the runtime guarantees this: a partition
46/// is owned by exactly one thread) — sequence numbering is issuer-local.
47/// Cloning yields an issuer with fresh sequence state for use by another
48/// thread and another set of partitions.
49#[derive(Debug)]
50pub struct AckIssuer {
51    ack_tx: crossbeam_channel::Sender<AckMsg>,
52    reg_tx: crossbeam_channel::Sender<Registration>,
53    shared_epoch: Arc<AtomicU32>,
54    local_epoch: u32,
55    seqs: HashMap<PartitionId, u64>,
56}
57
58impl Clone for AckIssuer {
59    fn clone(&self) -> Self {
60        AckIssuer {
61            ack_tx: self.ack_tx.clone(),
62            reg_tx: self.reg_tx.clone(),
63            shared_epoch: Arc::clone(&self.shared_epoch),
64            local_epoch: self.local_epoch,
65            seqs: HashMap::new(),
66        }
67    }
68}
69
70impl AckIssuer {
71    /// Issue the acknowledgement handle for a new source poll batch whose
72    /// highest contained offset is `last_offset`.
73    ///
74    /// Wait-free: one atomic load, one unbounded send, one allocation for
75    /// the batch's shared state.
76    pub fn issue(&mut self, partition: PartitionId, last_offset: i64) -> AckRef {
77        let epoch = self.shared_epoch.load(Ordering::Acquire);
78        if epoch != self.local_epoch {
79            // New assignment epoch: sequences restart at zero.
80            self.local_epoch = epoch;
81            self.seqs.clear();
82        }
83        let seq_slot = self.seqs.entry(partition).or_insert(0);
84        let seq = *seq_slot;
85        *seq_slot += 1;
86
87        let id = BatchId {
88            partition,
89            epoch,
90            seq,
91        };
92        // Registration is sent before any AckRef exists, so a resolution
93        // observed by the checkpointer always has its registration already
94        // in the registration channel (drain exploits this causality).
95        let _ = self.reg_tx.send(Registration { id, last_offset });
96        AckRef::new(id, last_offset, AckTx::Channel(self.ack_tx.clone()))
97    }
98}
99
100/// Aggregates batch resolutions into per-partition committable watermarks.
101///
102/// ```
103/// use etl_core::checkpoint::{AckStatus, Checkpointer};
104/// use etl_core::record::PartitionId;
105///
106/// let mut cp = Checkpointer::new();
107/// let p = PartitionId(0);
108/// cp.begin_epoch(&[p], 1);
109/// let mut issuer = cp.handle();
110///
111/// let ack = issuer.issue(p, 99); // batch covering offsets ..=99
112/// drop(ack); // all records delivered
113///
114/// cp.drain();
115/// assert_eq!(cp.take_watermarks(), vec![(p, 100)]);
116/// assert_eq!(cp.take_watermarks(), vec![]); // idempotent until new acks
117/// ```
118#[derive(Debug)]
119pub struct Checkpointer {
120    ack_tx: crossbeam_channel::Sender<AckMsg>,
121    ack_rx: crossbeam_channel::Receiver<AckMsg>,
122    reg_tx: crossbeam_channel::Sender<Registration>,
123    reg_rx: crossbeam_channel::Receiver<Registration>,
124    shared_epoch: Arc<AtomicU32>,
125    epoch: u32,
126    trackers: HashMap<PartitionId, PartitionTracker>,
127}
128
129impl Default for Checkpointer {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl Checkpointer {
136    /// A checkpointer with no assignment. Call [`begin_epoch`] when the
137    /// source reports its first assignment.
138    ///
139    /// [`begin_epoch`]: Checkpointer::begin_epoch
140    #[must_use]
141    pub fn new() -> Self {
142        let (ack_tx, ack_rx) = crossbeam_channel::unbounded();
143        let (reg_tx, reg_rx) = crossbeam_channel::unbounded();
144        Checkpointer {
145            ack_tx,
146            ack_rx,
147            reg_tx,
148            reg_rx,
149            shared_epoch: Arc::new(AtomicU32::new(0)),
150            epoch: 0,
151            trackers: HashMap::new(),
152        }
153    }
154
155    /// An issuer handle for a pipeline thread.
156    #[must_use]
157    pub fn handle(&self) -> AckIssuer {
158        AckIssuer {
159            ack_tx: self.ack_tx.clone(),
160            reg_tx: self.reg_tx.clone(),
161            shared_epoch: Arc::clone(&self.shared_epoch),
162            local_epoch: self.shared_epoch.load(Ordering::Acquire),
163            seqs: HashMap::new(),
164        }
165    }
166
167    /// Start a new assignment epoch covering exactly `partitions`. Every
168    /// rebalance bumps the epoch; in-flight batches from earlier epochs
169    /// resolve as stale and their offsets are re-delivered by the source
170    /// (at-least-once). Epochs must be strictly increasing.
171    ///
172    /// Ordering contract: the runtime calls this *before* distributing the
173    /// new assignment's lanes to pipeline threads, so issuers observe the
174    /// new epoch before issuing for it.
175    pub fn begin_epoch(&mut self, partitions: &[PartitionId], epoch: u32) {
176        assert!(
177            epoch > self.epoch || (self.epoch == 0 && self.trackers.is_empty()),
178            "assignment epochs must be strictly increasing: {} -> {epoch}",
179            self.epoch
180        );
181        self.epoch = epoch;
182        self.trackers = partitions
183            .iter()
184            .map(|&p| (p, PartitionTracker::new()))
185            .collect();
186        // Publish after trackers exist: an issuer that observes the new
187        // epoch will have its registrations accepted.
188        self.shared_epoch.store(epoch, Ordering::Release);
189    }
190
191    /// Drop tracking for revoked partitions mid-epoch (partial revocation
192    /// or shutdown). Later resolutions for them are discarded as stale.
193    /// A partition revoked this way can only return in a *new* epoch.
194    pub fn revoke(&mut self, partitions: &[PartitionId]) {
195        for p in partitions {
196            self.trackers.remove(p);
197        }
198    }
199
200    /// Apply all pending registrations and resolutions.
201    ///
202    /// Two passes exploit the causal order guaranteed by [`AckIssuer`]
203    /// (registration is sent before the batch's `AckRef` exists): a
204    /// resolution whose registration has not been drained yet is retried
205    /// once after re-draining registrations; if it is still unknown, the
206    /// driver is buggy and the resolution is counted and dropped.
207    pub fn drain(&mut self) -> DrainStats {
208        let mut stats = DrainStats::default();
209        self.drain_registrations(&mut stats);
210
211        let mut deferred = Vec::new();
212        while let Ok(msg) = self.ack_rx.try_recv() {
213            self.apply(msg, &mut stats, Some(&mut deferred));
214        }
215
216        if !deferred.is_empty() {
217            self.drain_registrations(&mut stats);
218            for msg in deferred {
219                self.apply(msg, &mut stats, None);
220            }
221        }
222        stats
223    }
224
225    fn drain_registrations(&mut self, stats: &mut DrainStats) {
226        while let Ok(reg) = self.reg_rx.try_recv() {
227            if reg.id.epoch != self.epoch {
228                stats.stale_epoch += 1;
229                continue;
230            }
231            match self.trackers.get_mut(&reg.id.partition) {
232                Some(tracker) => tracker.register(reg.id.seq, reg.last_offset),
233                // Revoked mid-epoch while the issuer still held the lane.
234                None => stats.stale_epoch += 1,
235            }
236        }
237    }
238
239    fn apply(&mut self, msg: AckMsg, stats: &mut DrainStats, defer: Option<&mut Vec<AckMsg>>) {
240        if msg.id.epoch != self.epoch {
241            stats.stale_epoch += 1;
242            return;
243        }
244        let Some(tracker) = self.trackers.get_mut(&msg.id.partition) else {
245            stats.stale_epoch += 1;
246            return;
247        };
248        match tracker.resolve(msg.id.seq, msg.status) {
249            ResolveOutcome::Applied => stats.applied += 1,
250            ResolveOutcome::Duplicate | ResolveOutcome::AlreadyAdvanced => stats.duplicates += 1,
251            ResolveOutcome::Unregistered => match defer {
252                Some(deferred) => deferred.push(msg),
253                None => {
254                    debug_assert!(false, "resolution without registration: {:?}", msg.id);
255                    stats.unknown += 1;
256                }
257            },
258        }
259    }
260
261    /// Watermarks that advanced since the last call: `(partition,
262    /// committable offset)` pairs ready for `Source::commit`. Empty when
263    /// nothing moved — callers skip the commit entirely.
264    #[must_use]
265    pub fn take_watermarks(&mut self) -> Vec<(PartitionId, i64)> {
266        let mut out: Vec<_> = self
267            .trackers
268            .iter_mut()
269            .filter_map(|(&p, t)| t.advance().map(|w| (p, w)))
270            .collect();
271        out.sort_unstable_by_key(|&(p, _)| p);
272        out
273    }
274
275    /// Unadvanced batches for one partition (backpressure trigger).
276    #[must_use]
277    pub fn pending(&self, partition: PartitionId) -> usize {
278        self.trackers
279            .get(&partition)
280            .map_or(0, PartitionTracker::pending)
281    }
282
283    /// The largest per-partition pending count.
284    #[must_use]
285    pub fn max_pending(&self) -> usize {
286        self.trackers
287            .values()
288            .map(PartitionTracker::pending)
289            .max()
290            .unwrap_or(0)
291    }
292
293    /// Partitions whose watermark is permanently stalled behind a failed
294    /// batch, with the stall start (health-probe input).
295    #[must_use]
296    pub fn stalled_partitions(&self) -> Vec<(PartitionId, Instant)> {
297        let mut out: Vec<_> = self
298            .trackers
299            .iter()
300            .filter_map(|(&p, t)| t.stalled_since().map(|since| (p, since)))
301            .collect();
302        out.sort_unstable_by_key(|&(p, _)| p);
303        out
304    }
305}
306
307#[cfg(all(test, not(loom)))]
308mod tests {
309    use super::*;
310
311    const P0: PartitionId = PartitionId(0);
312    const P1: PartitionId = PartitionId(1);
313
314    fn checkpointer(partitions: &[PartitionId]) -> (Checkpointer, AckIssuer) {
315        let mut cp = Checkpointer::new();
316        cp.begin_epoch(partitions, 1);
317        let issuer = cp.handle();
318        (cp, issuer)
319    }
320
321    #[test]
322    fn issue_drain_take_happy_path() {
323        let (mut cp, mut issuer) = checkpointer(&[P0]);
324        drop(issuer.issue(P0, 99));
325        drop(issuer.issue(P0, 199));
326        let stats = cp.drain();
327        assert_eq!(stats.applied, 2);
328        assert_eq!(
329            stats,
330            DrainStats {
331                applied: 2,
332                ..Default::default()
333            }
334        );
335        assert_eq!(cp.take_watermarks(), vec![(P0, 200)]);
336    }
337
338    #[test]
339    fn take_watermarks_is_empty_until_new_progress() {
340        let (mut cp, mut issuer) = checkpointer(&[P0]);
341        drop(issuer.issue(P0, 9));
342        cp.drain();
343        assert_eq!(cp.take_watermarks(), vec![(P0, 10)]);
344        assert_eq!(cp.take_watermarks(), vec![]);
345        drop(issuer.issue(P0, 19));
346        cp.drain();
347        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
348    }
349
350    #[test]
351    fn out_of_order_acks_across_partitions() {
352        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
353        let a0 = issuer.issue(P0, 9);
354        let a1 = issuer.issue(P0, 19);
355        let b0 = issuer.issue(P1, 99);
356        // P0's second batch and P1's batch resolve before P0's first.
357        drop(a1);
358        drop(b0);
359        cp.drain();
360        assert_eq!(cp.take_watermarks(), vec![(P1, 100)]);
361        assert_eq!(cp.pending(P0), 2);
362        drop(a0);
363        cp.drain();
364        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
365    }
366
367    #[test]
368    fn failed_batch_stalls_partition_and_reports() {
369        let (mut cp, mut issuer) = checkpointer(&[P0]);
370        let bad = issuer.issue(P0, 9);
371        bad.fail();
372        drop(bad);
373        drop(issuer.issue(P0, 19));
374        cp.drain();
375        assert_eq!(cp.take_watermarks(), vec![]);
376        let stalled = cp.stalled_partitions();
377        assert_eq!(stalled.len(), 1);
378        assert_eq!(stalled[0].0, P0);
379    }
380
381    #[test]
382    fn stale_epoch_acks_are_discarded() {
383        let (mut cp, mut issuer) = checkpointer(&[P0]);
384        let old = issuer.issue(P0, 9);
385        cp.begin_epoch(&[P0], 2);
386        drop(old); // resolves with epoch 1
387        let stats = cp.drain();
388        assert_eq!(stats.applied, 0);
389        // Both the registration and the resolution are stale.
390        assert_eq!(stats.stale_epoch, 2);
391        assert_eq!(cp.take_watermarks(), vec![]);
392
393        // The issuer picks up the new epoch and sequences restart.
394        drop(issuer.issue(P0, 49));
395        let stats = cp.drain();
396        assert_eq!(stats.applied, 1);
397        assert_eq!(cp.take_watermarks(), vec![(P0, 50)]);
398    }
399
400    #[test]
401    fn revoke_mid_flight_discards_later_acks() {
402        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
403        let in_flight = issuer.issue(P1, 9);
404        cp.drain(); // registration lands first
405        cp.revoke(&[P1]);
406        drop(in_flight);
407        let stats = cp.drain();
408        assert_eq!(stats.stale_epoch, 1);
409        assert_eq!(cp.take_watermarks(), vec![]);
410        assert_eq!(cp.pending(P1), 0);
411    }
412
413    #[test]
414    fn registration_and_ack_in_same_drain() {
415        // Issue and resolve between two drains: the resolution's
416        // registration is found via the causality retry.
417        let (mut cp, mut issuer) = checkpointer(&[P0]);
418        drop(issuer.issue(P0, 9));
419        let stats = cp.drain();
420        assert_eq!(stats.applied, 1);
421        assert_eq!(stats.unknown, 0);
422        assert_eq!(cp.take_watermarks(), vec![(P0, 10)]);
423    }
424
425    #[test]
426    fn cross_thread_issue_and_resolve() {
427        let (mut cp, issuer) = checkpointer(&[P0, P1]);
428        let handles: Vec<_> = [P0, P1]
429            .into_iter()
430            .map(|p| {
431                let mut issuer = issuer.clone();
432                std::thread::spawn(move || {
433                    for i in 0..100i64 {
434                        drop(issuer.issue(p, (i + 1) * 10 - 1));
435                    }
436                })
437            })
438            .collect();
439        for h in handles {
440            h.join().unwrap();
441        }
442        let stats = cp.drain();
443        assert_eq!(stats.applied, 200);
444        assert_eq!(stats.unknown, 0);
445        assert_eq!(cp.take_watermarks(), vec![(P0, 1000), (P1, 1000)]);
446    }
447
448    #[test]
449    fn pending_counts_feed_backpressure() {
450        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
451        let held: Vec<_> = (0..5).map(|i| issuer.issue(P0, i)).collect();
452        drop(issuer.issue(P1, 9));
453        cp.drain();
454        assert_eq!(cp.pending(P0), 5);
455        assert_eq!(cp.max_pending(), 5);
456        drop(held);
457        cp.drain();
458        let _ = cp.take_watermarks();
459        assert_eq!(cp.max_pending(), 0);
460    }
461
462    #[test]
463    #[should_panic(expected = "strictly increasing")]
464    fn epoch_regression_panics() {
465        let mut cp = Checkpointer::new();
466        cp.begin_epoch(&[P0], 5);
467        cp.begin_epoch(&[P0], 5);
468    }
469}
470
471#[cfg(all(test, not(loom)))]
472mod proptests {
473    use super::*;
474    use proptest::prelude::*;
475
476    #[derive(Clone, Debug)]
477    enum Op {
478        Issue { partition: u8, fail: bool },
479        ResolveOldest,
480        Rebalance { partitions: Vec<u8> },
481        DrainAndTake,
482    }
483
484    fn ops() -> impl Strategy<Value = Vec<Op>> {
485        prop::collection::vec(
486            prop_oneof![
487                (0..3u8, any::<bool>()).prop_map(|(partition, fail)| Op::Issue { partition, fail }),
488                Just(Op::ResolveOldest),
489                prop::collection::vec(0..3u8, 1..3)
490                    .prop_map(|partitions| Op::Rebalance { partitions }),
491                Just(Op::DrainAndTake),
492            ],
493            0..120,
494        )
495    }
496
497    proptest! {
498        /// Watermarks are per-partition monotonic, never move for
499        /// unassigned partitions, and acknowledgements issued under an old
500        /// epoch never affect a newer epoch's watermarks.
501        #[test]
502        fn epoch_churn_never_leaks_stale_acks(ops in ops()) {
503            let mut cp = Checkpointer::new();
504            let mut epoch = 1u32;
505            let mut assigned: Vec<PartitionId> = vec![PartitionId(0), PartitionId(1), PartitionId(2)];
506            cp.begin_epoch(&assigned, epoch);
507            let mut issuer = cp.handle();
508            let mut offsets: std::collections::HashMap<PartitionId, i64> =
509                std::collections::HashMap::new();
510            // Held (unresolved) acks with the epoch they were issued under.
511            let mut held: std::collections::VecDeque<(AckRef, u32, bool)> =
512                std::collections::VecDeque::new();
513            let mut last_watermark: std::collections::HashMap<PartitionId, i64> =
514                std::collections::HashMap::new();
515
516            for op in ops {
517                match op {
518                    Op::Issue { partition, fail } => {
519                        let p = PartitionId(u32::from(partition));
520                        if !assigned.contains(&p) {
521                            continue;
522                        }
523                        let next = offsets.entry(p).or_insert(0);
524                        *next += 10;
525                        let ack = issuer.issue(p, *next - 1);
526                        if fail {
527                            ack.fail();
528                        }
529                        held.push_back((ack, epoch, fail));
530                    }
531                    Op::ResolveOldest => {
532                        held.pop_front(); // drop resolves it
533                    }
534                    Op::Rebalance { partitions } => {
535                        epoch += 1;
536                        assigned = partitions
537                            .into_iter()
538                            .map(|p| PartitionId(u32::from(p)))
539                            .collect::<std::collections::BTreeSet<_>>()
540                            .into_iter()
541                            .collect();
542                        cp.begin_epoch(&assigned, epoch);
543                        // Sequences and offsets restart with the epoch;
544                        // watermark monotonicity is per-epoch.
545                        offsets.clear();
546                        last_watermark.clear();
547                    }
548                    Op::DrainAndTake => {
549                        cp.drain();
550                        for (p, w) in cp.take_watermarks() {
551                            prop_assert!(
552                                assigned.contains(&p),
553                                "watermark for unassigned partition {p:?}"
554                            );
555                            if let Some(&prev) = last_watermark.get(&p) {
556                                prop_assert!(w > prev, "watermark not monotonic for {p:?}");
557                            }
558                            last_watermark.insert(p, w);
559                        }
560                    }
561                }
562            }
563
564            // Resolve everything still held (stale epochs included), then
565            // verify stale resolutions changed nothing they shouldn't.
566            let stale_epochs: Vec<u32> =
567                held.iter().map(|&(_, e, _)| e).filter(|&e| e != epoch).collect();
568            held.clear();
569            let stats = cp.drain();
570            prop_assert!(stats.unknown == 0, "driver-bug resolutions: {stats:?}");
571            for (p, w) in cp.take_watermarks() {
572                prop_assert!(assigned.contains(&p));
573                if let Some(&prev) = last_watermark.get(&p) {
574                    prop_assert!(w > prev);
575                }
576            }
577            // Sanity: if there were stale-epoch acks, they were counted.
578            if !stale_epochs.is_empty() {
579                prop_assert!(stats.stale_epoch > 0);
580            }
581        }
582    }
583}