Skip to main content

spate_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 therefore never block behind data. The backpressure design
10//! relies on that to stay deadlock-free.
11
12use super::ack::AckTx;
13use super::gate::AdvanceCounter;
14use super::tracker::{PartitionTracker, ResolveOutcome};
15use super::{AckMsg, AckRef, BatchId};
16use crate::clock::{SharedClock, SystemClock};
17use crate::error::FatalError;
18use crate::hash::FastMap;
19use crate::record::PartitionId;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicU32, Ordering};
22use std::time::Instant;
23
24/// Registration of a newly issued batch, sent issuer → checkpointer.
25#[derive(Clone, Copy, Debug)]
26struct Registration {
27    id: BatchId,
28    last_offset: i64,
29}
30
31/// Counters from one [`Checkpointer::drain`] call, for metrics.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub struct DrainStats {
34    /// Resolutions applied to a tracker.
35    pub applied: usize,
36    /// Registrations or resolutions discarded because their epoch is not
37    /// current or their partition is not assigned (normal after rebalance).
38    pub stale_epoch: usize,
39    /// Duplicate resolutions (already resolved or already advanced).
40    pub duplicates: usize,
41    /// Resolutions that never found a registration, indicating a driver bug.
42    pub unknown: usize,
43}
44
45/// Creates acknowledgment handles on pipeline threads.
46///
47/// One issuer per pipeline thread. Within an epoch, a partition must be
48/// issued from exactly one issuer; the runtime guarantees this, since a
49/// partition is owned by exactly one thread. Sequence numbering is
50/// issuer-local. Cloning yields an issuer with fresh sequence state for use
51/// by another thread and another set of partitions.
52#[derive(Debug)]
53pub struct AckIssuer {
54    ack_tx: crossbeam_channel::Sender<AckMsg>,
55    reg_tx: crossbeam_channel::Sender<Registration>,
56    shared_epoch: Arc<AtomicU32>,
57    local_epoch: u32,
58    seqs: FastMap<PartitionId, u64>,
59}
60
61impl Clone for AckIssuer {
62    fn clone(&self) -> Self {
63        AckIssuer {
64            ack_tx: self.ack_tx.clone(),
65            reg_tx: self.reg_tx.clone(),
66            shared_epoch: Arc::clone(&self.shared_epoch),
67            local_epoch: self.local_epoch,
68            seqs: FastMap::default(),
69        }
70    }
71}
72
73impl AckIssuer {
74    /// Issue the acknowledgment handle for a new source poll batch whose
75    /// highest contained offset is `last_offset`.
76    ///
77    /// Wait-free: one atomic load, one unbounded send, one allocation for
78    /// the batch's shared state.
79    pub fn issue(&mut self, partition: PartitionId, last_offset: i64) -> AckRef {
80        let epoch = self.shared_epoch.load(Ordering::Acquire);
81        if epoch != self.local_epoch {
82            // New assignment epoch: sequences restart at zero.
83            self.local_epoch = epoch;
84            self.seqs.clear();
85        }
86        let seq_slot = self.seqs.entry(partition).or_insert(0);
87        let seq = *seq_slot;
88        *seq_slot += 1;
89
90        let id = BatchId {
91            partition,
92            epoch,
93            seq,
94        };
95        // Registration is sent before any AckRef exists, so a resolution
96        // observed by the checkpointer always has its registration already
97        // in the registration channel (drain exploits this causality).
98        let _ = self.reg_tx.send(Registration { id, last_offset });
99        AckRef::new(id, last_offset, AckTx::Channel(self.ack_tx.clone()))
100    }
101}
102
103/// Aggregates batch resolutions into per-partition committable watermarks.
104///
105/// ```
106/// use spate_core::checkpoint::{AckStatus, Checkpointer};
107/// use spate_core::record::PartitionId;
108///
109/// let mut cp = Checkpointer::new();
110/// let p = PartitionId(0);
111/// cp.begin_epoch(&[p], 1);
112/// let mut issuer = cp.handle();
113///
114/// let ack = issuer.issue(p, 99); // batch covering offsets ..=99
115/// drop(ack); // all records delivered
116///
117/// cp.drain();
118/// assert_eq!(cp.take_watermarks(), vec![(p, 100)]);
119/// assert_eq!(cp.take_watermarks(), vec![]); // idempotent until new acks
120/// ```
121pub struct Checkpointer {
122    ack_tx: crossbeam_channel::Sender<AckMsg>,
123    ack_rx: crossbeam_channel::Receiver<AckMsg>,
124    reg_tx: crossbeam_channel::Sender<Registration>,
125    reg_rx: crossbeam_channel::Receiver<Registration>,
126    shared_epoch: Arc<AtomicU32>,
127    epoch: u32,
128    trackers: FastMap<PartitionId, PartitionTracker>,
129    /// One advance counter per tracked partition: the controller-side half
130    /// of the pending-ceiling gate the owning driver reads at the poll
131    /// boundary. Created and dropped in lockstep with `trackers`.
132    gates: FastMap<PartitionId, AdvanceCounter>,
133    /// The highest partition admitted to the current epoch. `extend_epoch`
134    /// admits only partitions above it, so a revoked partition cannot return
135    /// in the epoch while the ledger stays one value. `trackers` alone cannot
136    /// enforce this: a revocation removes the tracker, so a re-add would look
137    /// fresh.
138    admitted_through: Option<PartitionId>,
139    /// Stamps the start of a stall, so a health check that ages it reads
140    /// the same timeline.
141    clock: SharedClock,
142}
143
144impl std::fmt::Debug for Checkpointer {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("Checkpointer")
147            .field("epoch", &self.epoch)
148            .field("trackers", &self.trackers)
149            .field("admitted_through", &self.admitted_through)
150            .finish_non_exhaustive()
151    }
152}
153
154impl Default for Checkpointer {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl Checkpointer {
161    /// A checkpointer with no assignment. Call [`begin_epoch`] when the
162    /// source reports its first assignment.
163    ///
164    /// [`begin_epoch`]: Checkpointer::begin_epoch
165    #[must_use]
166    pub fn new() -> Self {
167        let (ack_tx, ack_rx) = crossbeam_channel::unbounded();
168        let (reg_tx, reg_rx) = crossbeam_channel::unbounded();
169        Checkpointer {
170            ack_tx,
171            ack_rx,
172            reg_tx,
173            reg_rx,
174            shared_epoch: Arc::new(AtomicU32::new(0)),
175            epoch: 0,
176            trackers: FastMap::default(),
177            gates: FastMap::default(),
178            admitted_through: None,
179            clock: Arc::new(SystemClock),
180        }
181    }
182
183    /// Stamps stall starts on `clock`, the clock that ages them.
184    pub(crate) fn set_clock(&mut self, clock: SharedClock) {
185        self.clock = clock;
186    }
187
188    /// The current instant on the clock that stamps stall starts, for
189    /// ageing them.
190    pub(crate) fn now(&self) -> Instant {
191        self.clock.now()
192    }
193
194    /// An issuer handle for a pipeline thread.
195    #[must_use]
196    pub fn handle(&self) -> AckIssuer {
197        AckIssuer {
198            ack_tx: self.ack_tx.clone(),
199            reg_tx: self.reg_tx.clone(),
200            shared_epoch: Arc::clone(&self.shared_epoch),
201            local_epoch: self.shared_epoch.load(Ordering::Acquire),
202            seqs: FastMap::default(),
203        }
204    }
205
206    /// Start a new assignment epoch covering exactly `partitions`. Every
207    /// rebalance bumps the epoch; in-flight batches from earlier epochs
208    /// resolve as stale and their offsets are re-delivered by the source
209    /// (at-least-once). Epochs must be strictly increasing.
210    ///
211    /// Ordering contract: the runtime calls this *before* distributing the
212    /// new assignment's lanes to pipeline threads, so issuers observe the
213    /// new epoch before issuing for it.
214    pub fn begin_epoch(&mut self, partitions: &[PartitionId], epoch: u32) {
215        assert!(
216            epoch > self.epoch || (self.epoch == 0 && self.trackers.is_empty()),
217            "assignment epochs must be strictly increasing: {} -> {epoch}",
218            self.epoch
219        );
220        self.epoch = epoch;
221        self.trackers = partitions
222            .iter()
223            .map(|&p| (p, PartitionTracker::new()))
224            .collect();
225        self.gates = partitions
226            .iter()
227            .map(|&p| (p, AdvanceCounter::new()))
228            .collect();
229        // A new epoch resets the admission mark: every issuer restarts its
230        // sequences on the epoch change, so a partition may legitimately
231        // reappear here.
232        self.admitted_through = partitions.iter().copied().max();
233        // Publish after trackers exist, so an issuer that observes the new
234        // epoch has its registrations accepted.
235        self.shared_epoch.store(epoch, Ordering::Release);
236    }
237
238    /// Add partitions to the *current* epoch without disturbing existing
239    /// trackers (additive lane gains, [`SourceEvent::LanesAdded`]). The
240    /// epoch does not change, so in-flight batches for existing partitions
241    /// keep resolving. Only partitions above every partition already admitted
242    /// to the epoch may be added. A partition revoked mid-epoch can only
243    /// return in a new epoch, and re-adding a live partition would discard
244    /// its ack state.
245    ///
246    /// Ordering contract: as with [`Checkpointer::begin_epoch`], call this
247    /// *before* distributing the new lanes to pipeline threads.
248    ///
249    /// # Errors
250    ///
251    /// Returns a [`FatalError`] if a partition is still live, or is at or
252    /// below the highest partition admitted to this epoch, which covers every
253    /// partition revoked earlier in it. Both are source bugs. The revoked
254    /// case is the dangerous one. Its tracker is
255    /// gone, so it *looks* fresh, while issuers keep their sequence counters
256    /// until the epoch changes. Admitting it would pair a mid-sequence
257    /// registration with a tracker expecting zero, and
258    /// [`PartitionTracker::register`] would panic on the controller thread,
259    /// taking down the pipeline with a message naming neither this method
260    /// nor the contract that was broken.
261    ///
262    /// [`SourceEvent::LanesAdded`]: crate::source::SourceEvent::LanesAdded
263    /// [`PartitionTracker::register`]: crate::checkpoint::PartitionTracker::register
264    pub fn extend_epoch(&mut self, partitions: &[PartitionId]) -> Result<(), FatalError> {
265        // Check before mutating. A rejected extension must leave the epoch
266        // exactly as it was.
267        for &p in partitions {
268            let why = if self.trackers.contains_key(&p) {
269                "is already tracked".to_owned()
270            } else if let Some(mark) = self.admitted_through.filter(|&mark| p <= mark) {
271                format!(
272                    "is not above partition {}, the highest admitted in this epoch",
273                    mark.0
274                )
275            } else {
276                continue;
277            };
278            return Err(FatalError {
279                component: "checkpoint".into(),
280                reason: format!(
281                    "additive assignment added partition {} which {why}; every added \
282                     lane must carry a partition above any already admitted in this \
283                     epoch (a returning partition needs a new epoch)",
284                    p.0
285                ),
286            });
287        }
288        for &p in partitions {
289            self.trackers.insert(p, PartitionTracker::new());
290            self.gates.insert(p, AdvanceCounter::new());
291        }
292        self.admitted_through = self.admitted_through.max(partitions.iter().copied().max());
293        Ok(())
294    }
295
296    /// Drop tracking for revoked partitions mid-epoch (partial revocation
297    /// or shutdown). Later resolutions for them are discarded as stale.
298    /// A partition revoked this way can only return in a *new* epoch.
299    pub fn revoke(&mut self, partitions: &[PartitionId]) {
300        for p in partitions {
301            self.trackers.remove(p);
302            self.gates.remove(p);
303        }
304    }
305
306    /// The advance counter for a tracked partition, cloned for the gate
307    /// handed to the partition's owning driver. `None` for a partition not
308    /// in the current epoch.
309    #[must_use]
310    pub(crate) fn advance_handle(&self, partition: PartitionId) -> Option<AdvanceCounter> {
311        self.gates.get(&partition).cloned()
312    }
313
314    /// Whether `partition` is tracked in the current epoch: assigned or
315    /// added, and not revoked since.
316    #[must_use]
317    pub(crate) fn tracks(&self, partition: PartitionId) -> bool {
318        self.trackers.contains_key(&partition)
319    }
320
321    /// Apply all pending registrations and resolutions.
322    ///
323    /// Two passes exploit the causal order guaranteed by [`AckIssuer`]
324    /// (registration is sent before the batch's `AckRef` exists). A
325    /// resolution whose registration has not been drained yet is retried
326    /// once after re-draining registrations; if it is still unknown, the
327    /// driver is buggy and the resolution is counted and dropped.
328    pub fn drain(&mut self) -> DrainStats {
329        let mut stats = DrainStats::default();
330        self.drain_registrations(&mut stats);
331
332        let mut deferred = Vec::new();
333        while let Ok(msg) = self.ack_rx.try_recv() {
334            self.apply(msg, &mut stats, Some(&mut deferred));
335        }
336
337        if !deferred.is_empty() {
338            self.drain_registrations(&mut stats);
339            for msg in deferred {
340                self.apply(msg, &mut stats, None);
341            }
342        }
343        stats
344    }
345
346    fn drain_registrations(&mut self, stats: &mut DrainStats) {
347        while let Ok(reg) = self.reg_rx.try_recv() {
348            if reg.id.epoch != self.epoch {
349                stats.stale_epoch += 1;
350                continue;
351            }
352            match self.trackers.get_mut(&reg.id.partition) {
353                Some(tracker) => tracker.register(reg.id.seq, reg.last_offset),
354                // Revoked mid-epoch while the issuer still held the lane.
355                None => stats.stale_epoch += 1,
356            }
357        }
358    }
359
360    fn apply(&mut self, msg: AckMsg, stats: &mut DrainStats, defer: Option<&mut Vec<AckMsg>>) {
361        if msg.id.epoch != self.epoch {
362            stats.stale_epoch += 1;
363            return;
364        }
365        let Some(tracker) = self.trackers.get_mut(&msg.id.partition) else {
366            stats.stale_epoch += 1;
367            return;
368        };
369        match tracker.resolve(msg.id.seq, msg.status) {
370            ResolveOutcome::Applied => stats.applied += 1,
371            ResolveOutcome::Duplicate | ResolveOutcome::AlreadyAdvanced => stats.duplicates += 1,
372            ResolveOutcome::Unregistered => match defer {
373                Some(deferred) => deferred.push(msg),
374                None => {
375                    debug_assert!(false, "resolution without registration: {:?}", msg.id);
376                    stats.unknown += 1;
377                }
378            },
379        }
380    }
381
382    /// Watermarks that advanced since the last call: `(partition,
383    /// committable offset)` pairs ready for `Source::commit`. Empty when
384    /// nothing moved, so callers skip the commit entirely.
385    #[must_use]
386    pub fn take_watermarks(&mut self) -> Vec<(PartitionId, i64)> {
387        let mut out = Vec::new();
388        for (&p, t) in &mut self.trackers {
389            let before = t.pending();
390            let watermark = t.advance_on(&*self.clock);
391            // Retired batches reopen the partition's pending gate; the
392            // owning driver reads the counter at its poll boundary.
393            let retired = before - t.pending();
394            if retired > 0
395                && let Some(gate) = self.gates.get(&p)
396            {
397                gate.add(retired as u64);
398            }
399            if let Some(w) = watermark {
400                out.push((p, w));
401            }
402        }
403        out.sort_unstable_by_key(|&(p, _)| p);
404        out
405    }
406
407    /// Unadvanced batches for one partition (backpressure trigger).
408    #[must_use]
409    pub fn pending(&self, partition: PartitionId) -> usize {
410        self.trackers
411            .get(&partition)
412            .map_or(0, PartitionTracker::pending)
413    }
414
415    /// The largest per-partition pending count.
416    #[must_use]
417    pub fn max_pending(&self) -> usize {
418        self.trackers
419            .values()
420            .map(PartitionTracker::pending)
421            .max()
422            .unwrap_or(0)
423    }
424
425    /// Unadvanced batches for every tracked partition, sorted by partition.
426    ///
427    /// Every partition in the current epoch appears, including one with
428    /// nothing unacknowledged, so the result doubles as the live set. A
429    /// partition leaves it by being revoked or by a new epoch replacing it,
430    /// never by falling quiet.
431    #[must_use]
432    pub fn pending_by_partition(&self) -> Vec<(PartitionId, usize)> {
433        let mut out: Vec<_> = self
434            .trackers
435            .iter()
436            .map(|(&p, t)| (p, t.pending()))
437            .collect();
438        out.sort_unstable_by_key(|&(p, _)| p);
439        out
440    }
441
442    /// Partitions whose watermark is permanently stalled behind a failed
443    /// batch, with the stall start on the checkpointer's clock (health-probe
444    /// input).
445    #[must_use]
446    pub fn stalled_partitions(&self) -> Vec<(PartitionId, Instant)> {
447        let mut out: Vec<_> = self
448            .trackers
449            .iter()
450            .filter_map(|(&p, t)| t.stalled_since().map(|since| (p, since)))
451            .collect();
452        out.sort_unstable_by_key(|&(p, _)| p);
453        out
454    }
455}
456
457#[cfg(all(test, not(loom)))]
458mod tests {
459    use super::*;
460
461    const P0: PartitionId = PartitionId(0);
462    const P1: PartitionId = PartitionId(1);
463
464    /// The clock it holds leaves `Checkpointer` `UnwindSafe` and
465    /// `RefUnwindSafe`.
466    #[test]
467    fn checkpointer_stays_unwind_safe() {
468        fn unwind_safe<T: std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
469        unwind_safe::<Checkpointer>();
470    }
471
472    /// The hand-written `Debug` names the type and its epoch.
473    #[test]
474    fn debug_names_the_checkpointer_and_its_epoch() {
475        let (cp, _issuer) = checkpointer(&[P0]);
476        let text = format!("{cp:?}");
477        assert!(text.starts_with("Checkpointer {"), "{text}");
478        assert!(text.contains("epoch: 1"), "{text}");
479    }
480
481    fn checkpointer(partitions: &[PartitionId]) -> (Checkpointer, AckIssuer) {
482        let mut cp = Checkpointer::new();
483        cp.begin_epoch(partitions, 1);
484        let issuer = cp.handle();
485        (cp, issuer)
486    }
487
488    /// The advance counters behind the drivers' pending gates: bumped by
489    /// exactly the batches an advance retires, frozen by a stalled head,
490    /// dropped on revoke, fresh per epoch.
491    #[test]
492    fn advance_counters_track_retired_batches() {
493        let (mut cp, mut issuer) = checkpointer(&[P0]);
494        let gate = cp.advance_handle(P0).expect("gate for a tracked partition");
495        drop(issuer.issue(P0, 9));
496        drop(issuer.issue(P0, 19));
497        cp.drain();
498        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
499        assert_eq!(gate.get(), 2, "both retired batches counted");
500
501        // A failed batch stalls advancement: nothing further retires.
502        issuer.issue(P0, 29).fail();
503        drop(issuer.issue(P0, 39));
504        cp.drain();
505        assert!(cp.take_watermarks().is_empty());
506        assert_eq!(gate.get(), 2, "a stalled head retires nothing");
507
508        // Revocation drops the gate; a new epoch starts a fresh counter.
509        cp.revoke(&[P0]);
510        assert!(cp.advance_handle(P0).is_none());
511        cp.begin_epoch(&[P0], 2);
512        let fresh = cp.advance_handle(P0).expect("fresh gate");
513        assert_eq!(fresh.get(), 0);
514    }
515
516    #[test]
517    fn issue_drain_take_happy_path() {
518        let (mut cp, mut issuer) = checkpointer(&[P0]);
519        drop(issuer.issue(P0, 99));
520        drop(issuer.issue(P0, 199));
521        let stats = cp.drain();
522        assert_eq!(stats.applied, 2);
523        assert_eq!(
524            stats,
525            DrainStats {
526                applied: 2,
527                ..Default::default()
528            }
529        );
530        assert_eq!(cp.take_watermarks(), vec![(P0, 200)]);
531    }
532
533    #[test]
534    fn extend_epoch_adds_partitions_without_disturbing_inflight_acks() {
535        let (mut cp, mut issuer) = checkpointer(&[P0]);
536        // In flight on P0 before the extension...
537        let ack = issuer.issue(P0, 99);
538        cp.extend_epoch(&[P1]).unwrap();
539        // ...still resolves after it, because the epoch did not change.
540        drop(ack);
541        drop(issuer.issue(P1, 9));
542        let stats = cp.drain();
543        assert_eq!(stats.applied, 2);
544        assert_eq!(stats.stale_epoch, 0);
545        assert_eq!(cp.take_watermarks(), vec![(P0, 100), (P1, 10)]);
546    }
547
548    #[test]
549    fn extend_epoch_rejects_a_live_partition() {
550        let (mut cp, _issuer) = checkpointer(&[P0]);
551        let err = cp.extend_epoch(&[P0]).unwrap_err();
552        assert!(err.reason.contains("already tracked"), "{err}");
553    }
554
555    #[test]
556    fn extend_epoch_rejects_a_partition_revoked_earlier_in_the_epoch() {
557        // The dangerous half of the contract. `revoke` drops the tracker, so
558        // a re-add looks fresh, while issuers keep their sequence counters
559        // until the epoch changes, so the next batch registers mid-sequence
560        // against a tracker expecting zero. Without this rejection that panics
561        // inside `PartitionTracker::register`, on the controller thread,
562        // naming neither this method nor the contract it broke.
563        let (mut cp, mut issuer) = checkpointer(&[P0]);
564        drop(issuer.issue(P0, 9));
565        cp.drain();
566        cp.revoke(&[P0]);
567
568        let err = cp.extend_epoch(&[P0]).unwrap_err();
569        assert_eq!(err.component, "checkpoint");
570        assert!(err.reason.contains("is not above partition 0"), "{err}");
571
572        // Rejected means unchanged. The partition is still revoked, so the
573        // issuer's next batch is discarded as stale rather than registered,
574        // both its registration and its resolution.
575        drop(issuer.issue(P0, 19));
576        let stats = cp.drain();
577        assert_eq!(stats.applied, 0);
578        assert_eq!(stats.stale_epoch, 2);
579
580        // A new epoch is how it returns.
581        cp.begin_epoch(&[P0], 2);
582        cp.extend_epoch(&[P1]).unwrap();
583    }
584
585    /// An extension with no partitions leaves the mark where it was, so a
586    /// partition revoked earlier in the epoch stays refused.
587    #[test]
588    fn an_empty_extension_keeps_the_admission_mark() {
589        let (mut cp, _issuer) = checkpointer(&[P0]);
590        cp.revoke(&[P0]);
591        cp.extend_epoch(&[]).unwrap();
592        let err = cp.extend_epoch(&[P0]).unwrap_err();
593        assert!(err.reason.contains("is not above partition 0"), "{err}");
594    }
595
596    /// A partition below the highest admitted is refused even though it was
597    /// never in the epoch, and the refusal leaves the epoch unchanged.
598    #[test]
599    fn extend_epoch_rejects_a_partition_below_the_highest_admitted() {
600        let mut cp = Checkpointer::new();
601        cp.begin_epoch(&[], 1);
602        let mut issuer = cp.handle();
603        cp.extend_epoch(&[PartitionId(2)]).unwrap();
604
605        let err = cp.extend_epoch(&[P1]).unwrap_err();
606        assert_eq!(err.component, "checkpoint");
607        assert!(err.reason.contains("is not above partition 2"), "{err}");
608
609        drop(issuer.issue(P1, 9));
610        let stats = cp.drain();
611        assert_eq!(stats.applied, 0);
612        assert_eq!(stats.stale_epoch, 2);
613    }
614
615    /// Extending and revoking one partition at a time keeps succeeding for
616    /// the whole epoch, and the checkpointer holds state for the live
617    /// partitions and one admission mark.
618    #[test]
619    fn admission_holds_only_live_partitions_over_extend_and_revoke() {
620        let mut cp = Checkpointer::new();
621        cp.begin_epoch(&[], 1);
622        for n in 1..=1000 {
623            cp.extend_epoch(&[PartitionId(n)]).unwrap();
624            cp.revoke(&[PartitionId(n - 1)]);
625            assert!(cp.trackers.len() <= 2, "{} trackers", cp.trackers.len());
626            assert!(cp.gates.len() <= 2, "{} gates", cp.gates.len());
627        }
628        assert_eq!(cp.admitted_through, Some(PartitionId(1000)));
629    }
630
631    #[test]
632    fn take_watermarks_is_empty_until_new_progress() {
633        let (mut cp, mut issuer) = checkpointer(&[P0]);
634        drop(issuer.issue(P0, 9));
635        cp.drain();
636        assert_eq!(cp.take_watermarks(), vec![(P0, 10)]);
637        assert_eq!(cp.take_watermarks(), vec![]);
638        drop(issuer.issue(P0, 19));
639        cp.drain();
640        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
641    }
642
643    #[test]
644    fn out_of_order_acks_across_partitions() {
645        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
646        let a0 = issuer.issue(P0, 9);
647        let a1 = issuer.issue(P0, 19);
648        let b0 = issuer.issue(P1, 99);
649        // P0's second batch and P1's batch resolve before P0's first.
650        drop(a1);
651        drop(b0);
652        cp.drain();
653        assert_eq!(cp.take_watermarks(), vec![(P1, 100)]);
654        assert_eq!(cp.pending(P0), 2);
655        drop(a0);
656        cp.drain();
657        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
658    }
659
660    #[test]
661    fn failed_batch_stalls_partition_and_reports() {
662        let (mut cp, mut issuer) = checkpointer(&[P0]);
663        let bad = issuer.issue(P0, 9);
664        bad.fail();
665        drop(bad);
666        drop(issuer.issue(P0, 19));
667        cp.drain();
668        assert_eq!(cp.take_watermarks(), vec![]);
669        let stalled = cp.stalled_partitions();
670        assert_eq!(stalled.len(), 1);
671        assert_eq!(stalled[0].0, P0);
672    }
673
674    #[test]
675    fn stale_epoch_acks_are_discarded() {
676        let (mut cp, mut issuer) = checkpointer(&[P0]);
677        let old = issuer.issue(P0, 9);
678        cp.begin_epoch(&[P0], 2);
679        drop(old); // resolves with epoch 1
680        let stats = cp.drain();
681        assert_eq!(stats.applied, 0);
682        // Both the registration and the resolution are stale.
683        assert_eq!(stats.stale_epoch, 2);
684        assert_eq!(cp.take_watermarks(), vec![]);
685
686        // The issuer picks up the new epoch and sequences restart.
687        drop(issuer.issue(P0, 49));
688        let stats = cp.drain();
689        assert_eq!(stats.applied, 1);
690        assert_eq!(cp.take_watermarks(), vec![(P0, 50)]);
691    }
692
693    #[test]
694    fn revoke_mid_flight_discards_later_acks() {
695        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
696        let in_flight = issuer.issue(P1, 9);
697        cp.drain(); // registration lands first
698        cp.revoke(&[P1]);
699        drop(in_flight);
700        let stats = cp.drain();
701        assert_eq!(stats.stale_epoch, 1);
702        assert_eq!(cp.take_watermarks(), vec![]);
703        assert_eq!(cp.pending(P1), 0);
704    }
705
706    #[test]
707    fn registration_and_ack_in_same_drain() {
708        // Issue and resolve between two drains. The resolution's
709        // registration is found via the causality retry.
710        let (mut cp, mut issuer) = checkpointer(&[P0]);
711        drop(issuer.issue(P0, 9));
712        let stats = cp.drain();
713        assert_eq!(stats.applied, 1);
714        assert_eq!(stats.unknown, 0);
715        assert_eq!(cp.take_watermarks(), vec![(P0, 10)]);
716    }
717
718    #[test]
719    #[expect(clippy::disallowed_methods, reason = "the test joins both threads")]
720    fn cross_thread_issue_and_resolve() {
721        let (mut cp, issuer) = checkpointer(&[P0, P1]);
722        let handles: Vec<_> = [P0, P1]
723            .into_iter()
724            .map(|p| {
725                let mut issuer = issuer.clone();
726                std::thread::spawn(move || {
727                    for i in 0..100i64 {
728                        drop(issuer.issue(p, (i + 1) * 10 - 1));
729                    }
730                })
731            })
732            .collect();
733        for h in handles {
734            h.join().unwrap();
735        }
736        let stats = cp.drain();
737        assert_eq!(stats.applied, 200);
738        assert_eq!(stats.unknown, 0);
739        assert_eq!(cp.take_watermarks(), vec![(P0, 1000), (P1, 1000)]);
740    }
741
742    #[test]
743    fn pending_counts_feed_backpressure() {
744        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
745        let held: Vec<_> = (0..5).map(|i| issuer.issue(P0, i)).collect();
746        drop(issuer.issue(P1, 9));
747        cp.drain();
748        assert_eq!(cp.pending(P0), 5);
749        assert_eq!(cp.max_pending(), 5);
750        drop(held);
751        cp.drain();
752        let _ = cp.take_watermarks();
753        assert_eq!(cp.max_pending(), 0);
754    }
755
756    /// Every partition in the epoch is listed, sorted, including an idle one
757    /// at zero. A caller publishing a per-partition series retains against
758    /// this list, so omitting an idle partition would drop and re-register
759    /// its series on every quiet cycle.
760    #[test]
761    fn pending_by_partition_lists_every_tracked_partition() {
762        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
763        let held: Vec<_> = (0..3).map(|i| issuer.issue(P0, i)).collect();
764        cp.drain();
765        assert_eq!(cp.pending_by_partition(), vec![(P0, 3), (P1, 0)]);
766        assert_eq!(
767            cp.max_pending(),
768            cp.pending_by_partition()
769                .iter()
770                .map(|&(_, n)| n)
771                .max()
772                .unwrap_or(0),
773            "the aggregate is the max of the per-partition counts"
774        );
775
776        // Revocation drops the partition from the list, which is what
777        // releases its series.
778        drop(held);
779        cp.drain();
780        let _ = cp.take_watermarks();
781        cp.revoke(&[P0]);
782        assert_eq!(cp.pending_by_partition(), vec![(P1, 0)]);
783    }
784
785    #[test]
786    #[should_panic(expected = "strictly increasing")]
787    fn epoch_regression_panics() {
788        let mut cp = Checkpointer::new();
789        cp.begin_epoch(&[P0], 5);
790        cp.begin_epoch(&[P0], 5);
791    }
792}
793
794#[cfg(all(test, not(loom)))]
795mod proptests {
796    use super::*;
797    use proptest::prelude::*;
798
799    #[derive(Clone, Debug)]
800    enum Op {
801        Issue { partition: u8, fail: bool },
802        ResolveOldest,
803        Rebalance { partitions: Vec<u8> },
804        Extend { partition: u8 },
805        Revoke { partition: u8 },
806        DrainAndTake,
807    }
808
809    fn ops() -> impl Strategy<Value = Vec<Op>> {
810        prop::collection::vec(
811            prop_oneof![
812                (0..16u8, any::<bool>())
813                    .prop_map(|(partition, fail)| Op::Issue { partition, fail }),
814                Just(Op::ResolveOldest),
815                prop::collection::vec(0..3u8, 1..3)
816                    .prop_map(|partitions| Op::Rebalance { partitions }),
817                (0..16u8).prop_map(|partition| Op::Extend { partition }),
818                (0..16u8).prop_map(|partition| Op::Revoke { partition }),
819                Just(Op::DrainAndTake),
820            ],
821            0..120,
822        )
823    }
824
825    proptest! {
826        /// Watermarks are per-partition monotonic, never move for
827        /// unassigned or revoked partitions, and acknowledgments issued under
828        /// an old epoch never affect a newer epoch's watermarks. An extension
829        /// succeeds exactly when its partition is not live and is above every
830        /// partition admitted to the epoch.
831        #[test]
832        fn epoch_churn_never_leaks_stale_acks(ops in ops()) {
833            let mut cp = Checkpointer::new();
834            let mut epoch = 1u32;
835            let mut assigned: Vec<PartitionId> = vec![PartitionId(0), PartitionId(1), PartitionId(2)];
836            cp.begin_epoch(&assigned, epoch);
837            let mut mark: Option<PartitionId> = assigned.iter().copied().max();
838            // One issuer for every partition: a partition re-admitted while it
839            // holds a sequence counter registers mid-sequence and panics, so
840            // the shared issuer also checks that no such re-admission occurs.
841            let mut issuer = cp.handle();
842            let mut offsets: FastMap<PartitionId, i64> =
843                FastMap::default();
844            // Held (unresolved) acks with the epoch they were issued under.
845            let mut held: std::collections::VecDeque<(AckRef, u32, bool)> =
846                std::collections::VecDeque::new();
847            let mut last_watermark: FastMap<PartitionId, i64> =
848                FastMap::default();
849
850            for op in ops {
851                match op {
852                    Op::Issue { partition, fail } => {
853                        let p = PartitionId(u32::from(partition));
854                        if !assigned.contains(&p) {
855                            continue;
856                        }
857                        let next = offsets.entry(p).or_insert(0);
858                        *next += 10;
859                        let ack = issuer.issue(p, *next - 1);
860                        if fail {
861                            ack.fail();
862                        }
863                        held.push_back((ack, epoch, fail));
864                    }
865                    Op::ResolveOldest => {
866                        held.pop_front(); // drop resolves it
867                    }
868                    Op::Rebalance { partitions } => {
869                        epoch += 1;
870                        assigned = partitions
871                            .into_iter()
872                            .map(|p| PartitionId(u32::from(p)))
873                            .collect::<std::collections::BTreeSet<_>>()
874                            .into_iter()
875                            .collect();
876                        cp.begin_epoch(&assigned, epoch);
877                        mark = assigned.iter().copied().max();
878                        // Sequences and offsets restart with the epoch;
879                        // watermark monotonicity is per-epoch.
880                        offsets.clear();
881                        last_watermark.clear();
882                    }
883                    Op::Extend { partition } => {
884                        let p = PartitionId(u32::from(partition));
885                        // The model's own rule: never live, and above every
886                        // partition admitted to the epoch.
887                        let legal = !assigned.contains(&p) && mark.is_none_or(|m| p > m);
888                        let trackers_before = cp.trackers.len();
889                        let result = cp.extend_epoch(&[p]);
890                        prop_assert_eq!(result.is_ok(), legal, "extend {:?} under mark {:?}", p, mark);
891                        if legal {
892                            assigned.push(p);
893                            mark = Some(p);
894                        } else {
895                            prop_assert_eq!(cp.trackers.len(), trackers_before);
896                            prop_assert_eq!(cp.admitted_through, mark);
897                        }
898                    }
899                    Op::Revoke { partition } => {
900                        let p = PartitionId(u32::from(partition));
901                        cp.revoke(&[p]);
902                        assigned.retain(|&q| q != p);
903                    }
904                    Op::DrainAndTake => {
905                        cp.drain();
906                        for (p, w) in cp.take_watermarks() {
907                            prop_assert!(
908                                assigned.contains(&p),
909                                "watermark for unassigned partition {p:?}"
910                            );
911                            if let Some(&prev) = last_watermark.get(&p) {
912                                prop_assert!(w > prev, "watermark not monotonic for {p:?}");
913                            }
914                            last_watermark.insert(p, w);
915                        }
916                    }
917                }
918            }
919
920            // Resolve everything still held (stale epochs included), then
921            // verify stale resolutions changed nothing they shouldn't.
922            let stale_epochs: Vec<u32> =
923                held.iter().map(|&(_, e, _)| e).filter(|&e| e != epoch).collect();
924            held.clear();
925            let stats = cp.drain();
926            prop_assert!(stats.unknown == 0, "driver-bug resolutions: {stats:?}");
927            for (p, w) in cp.take_watermarks() {
928                prop_assert!(assigned.contains(&p));
929                if let Some(&prev) = last_watermark.get(&p) {
930                    prop_assert!(w > prev);
931                }
932            }
933            // Sanity: if there were stale-epoch acks, they were counted.
934            if !stale_epochs.is_empty() {
935                prop_assert!(stats.stale_epoch > 0);
936            }
937        }
938    }
939}