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