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    /// Unadvanced batches for every tracked partition, sorted by partition.
387    ///
388    /// Every partition in the current epoch appears, including one with
389    /// nothing unacknowledged, so the result doubles as the live set. A
390    /// partition leaves it by being revoked or by a new epoch replacing it,
391    /// never by falling quiet.
392    #[must_use]
393    pub fn pending_by_partition(&self) -> Vec<(PartitionId, usize)> {
394        let mut out: Vec<_> = self
395            .trackers
396            .iter()
397            .map(|(&p, t)| (p, t.pending()))
398            .collect();
399        out.sort_unstable_by_key(|&(p, _)| p);
400        out
401    }
402
403    /// Partitions whose watermark is permanently stalled behind a failed
404    /// batch, with the stall start (health-probe input).
405    #[must_use]
406    pub fn stalled_partitions(&self) -> Vec<(PartitionId, Instant)> {
407        let mut out: Vec<_> = self
408            .trackers
409            .iter()
410            .filter_map(|(&p, t)| t.stalled_since().map(|since| (p, since)))
411            .collect();
412        out.sort_unstable_by_key(|&(p, _)| p);
413        out
414    }
415}
416
417#[cfg(all(test, not(loom)))]
418mod tests {
419    use super::*;
420
421    const P0: PartitionId = PartitionId(0);
422    const P1: PartitionId = PartitionId(1);
423
424    fn checkpointer(partitions: &[PartitionId]) -> (Checkpointer, AckIssuer) {
425        let mut cp = Checkpointer::new();
426        cp.begin_epoch(partitions, 1);
427        let issuer = cp.handle();
428        (cp, issuer)
429    }
430
431    /// The advance counters behind the drivers' pending gates: bumped by
432    /// exactly the batches an advance retires, frozen by a stalled head,
433    /// dropped on revoke, fresh per epoch.
434    #[test]
435    fn advance_counters_track_retired_batches() {
436        let (mut cp, mut issuer) = checkpointer(&[P0]);
437        let gate = cp.advance_handle(P0).expect("gate for a tracked partition");
438        drop(issuer.issue(P0, 9));
439        drop(issuer.issue(P0, 19));
440        cp.drain();
441        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
442        assert_eq!(gate.get(), 2, "both retired batches counted");
443
444        // A failed batch stalls advancement: nothing further retires.
445        issuer.issue(P0, 29).fail();
446        drop(issuer.issue(P0, 39));
447        cp.drain();
448        assert!(cp.take_watermarks().is_empty());
449        assert_eq!(gate.get(), 2, "a stalled head retires nothing");
450
451        // Revocation drops the gate; a new epoch starts a fresh counter.
452        cp.revoke(&[P0]);
453        assert!(cp.advance_handle(P0).is_none());
454        cp.begin_epoch(&[P0], 2);
455        let fresh = cp.advance_handle(P0).expect("fresh gate");
456        assert_eq!(fresh.get(), 0);
457    }
458
459    #[test]
460    fn issue_drain_take_happy_path() {
461        let (mut cp, mut issuer) = checkpointer(&[P0]);
462        drop(issuer.issue(P0, 99));
463        drop(issuer.issue(P0, 199));
464        let stats = cp.drain();
465        assert_eq!(stats.applied, 2);
466        assert_eq!(
467            stats,
468            DrainStats {
469                applied: 2,
470                ..Default::default()
471            }
472        );
473        assert_eq!(cp.take_watermarks(), vec![(P0, 200)]);
474    }
475
476    #[test]
477    fn extend_epoch_adds_partitions_without_disturbing_inflight_acks() {
478        let (mut cp, mut issuer) = checkpointer(&[P0]);
479        // In flight on P0 before the extension...
480        let ack = issuer.issue(P0, 99);
481        cp.extend_epoch(&[P1]).unwrap();
482        // ...still resolves after it, because the epoch did not change.
483        drop(ack);
484        drop(issuer.issue(P1, 9));
485        let stats = cp.drain();
486        assert_eq!(stats.applied, 2);
487        assert_eq!(stats.stale_epoch, 0);
488        assert_eq!(cp.take_watermarks(), vec![(P0, 100), (P1, 10)]);
489    }
490
491    #[test]
492    fn extend_epoch_rejects_a_live_partition() {
493        let (mut cp, _issuer) = checkpointer(&[P0]);
494        let err = cp.extend_epoch(&[P0]).unwrap_err();
495        assert!(err.reason.contains("already tracked"), "{err}");
496    }
497
498    #[test]
499    fn extend_epoch_rejects_a_partition_revoked_earlier_in_the_epoch() {
500        // The dangerous half of the contract. `revoke` drops the tracker, so
501        // a re-add looks fresh, while issuers keep their sequence counters
502        // until the epoch changes, so the next batch registers mid-sequence
503        // against a tracker expecting zero. Without this rejection that panics
504        // inside `PartitionTracker::register`, on the controller thread,
505        // naming neither this method nor the contract it broke.
506        let (mut cp, mut issuer) = checkpointer(&[P0]);
507        drop(issuer.issue(P0, 9));
508        cp.drain();
509        cp.revoke(&[P0]);
510
511        let err = cp.extend_epoch(&[P0]).unwrap_err();
512        assert_eq!(err.component, "checkpoint");
513        assert!(
514            err.reason.contains("revoked earlier in this epoch"),
515            "{err}"
516        );
517
518        // Rejected means unchanged. The partition is still revoked, so the
519        // issuer's next batch is discarded as stale rather than registered,
520        // both its registration and its resolution.
521        drop(issuer.issue(P0, 19));
522        let stats = cp.drain();
523        assert_eq!(stats.applied, 0);
524        assert_eq!(stats.stale_epoch, 2);
525
526        // A new epoch is how it returns.
527        cp.begin_epoch(&[P0], 2);
528        cp.extend_epoch(&[P1]).unwrap();
529    }
530
531    #[test]
532    fn take_watermarks_is_empty_until_new_progress() {
533        let (mut cp, mut issuer) = checkpointer(&[P0]);
534        drop(issuer.issue(P0, 9));
535        cp.drain();
536        assert_eq!(cp.take_watermarks(), vec![(P0, 10)]);
537        assert_eq!(cp.take_watermarks(), vec![]);
538        drop(issuer.issue(P0, 19));
539        cp.drain();
540        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
541    }
542
543    #[test]
544    fn out_of_order_acks_across_partitions() {
545        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
546        let a0 = issuer.issue(P0, 9);
547        let a1 = issuer.issue(P0, 19);
548        let b0 = issuer.issue(P1, 99);
549        // P0's second batch and P1's batch resolve before P0's first.
550        drop(a1);
551        drop(b0);
552        cp.drain();
553        assert_eq!(cp.take_watermarks(), vec![(P1, 100)]);
554        assert_eq!(cp.pending(P0), 2);
555        drop(a0);
556        cp.drain();
557        assert_eq!(cp.take_watermarks(), vec![(P0, 20)]);
558    }
559
560    #[test]
561    fn failed_batch_stalls_partition_and_reports() {
562        let (mut cp, mut issuer) = checkpointer(&[P0]);
563        let bad = issuer.issue(P0, 9);
564        bad.fail();
565        drop(bad);
566        drop(issuer.issue(P0, 19));
567        cp.drain();
568        assert_eq!(cp.take_watermarks(), vec![]);
569        let stalled = cp.stalled_partitions();
570        assert_eq!(stalled.len(), 1);
571        assert_eq!(stalled[0].0, P0);
572    }
573
574    #[test]
575    fn stale_epoch_acks_are_discarded() {
576        let (mut cp, mut issuer) = checkpointer(&[P0]);
577        let old = issuer.issue(P0, 9);
578        cp.begin_epoch(&[P0], 2);
579        drop(old); // resolves with epoch 1
580        let stats = cp.drain();
581        assert_eq!(stats.applied, 0);
582        // Both the registration and the resolution are stale.
583        assert_eq!(stats.stale_epoch, 2);
584        assert_eq!(cp.take_watermarks(), vec![]);
585
586        // The issuer picks up the new epoch and sequences restart.
587        drop(issuer.issue(P0, 49));
588        let stats = cp.drain();
589        assert_eq!(stats.applied, 1);
590        assert_eq!(cp.take_watermarks(), vec![(P0, 50)]);
591    }
592
593    #[test]
594    fn revoke_mid_flight_discards_later_acks() {
595        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
596        let in_flight = issuer.issue(P1, 9);
597        cp.drain(); // registration lands first
598        cp.revoke(&[P1]);
599        drop(in_flight);
600        let stats = cp.drain();
601        assert_eq!(stats.stale_epoch, 1);
602        assert_eq!(cp.take_watermarks(), vec![]);
603        assert_eq!(cp.pending(P1), 0);
604    }
605
606    #[test]
607    fn registration_and_ack_in_same_drain() {
608        // Issue and resolve between two drains. The resolution's
609        // registration is found via the causality retry.
610        let (mut cp, mut issuer) = checkpointer(&[P0]);
611        drop(issuer.issue(P0, 9));
612        let stats = cp.drain();
613        assert_eq!(stats.applied, 1);
614        assert_eq!(stats.unknown, 0);
615        assert_eq!(cp.take_watermarks(), vec![(P0, 10)]);
616    }
617
618    #[test]
619    fn cross_thread_issue_and_resolve() {
620        let (mut cp, issuer) = checkpointer(&[P0, P1]);
621        let handles: Vec<_> = [P0, P1]
622            .into_iter()
623            .map(|p| {
624                let mut issuer = issuer.clone();
625                std::thread::spawn(move || {
626                    for i in 0..100i64 {
627                        drop(issuer.issue(p, (i + 1) * 10 - 1));
628                    }
629                })
630            })
631            .collect();
632        for h in handles {
633            h.join().unwrap();
634        }
635        let stats = cp.drain();
636        assert_eq!(stats.applied, 200);
637        assert_eq!(stats.unknown, 0);
638        assert_eq!(cp.take_watermarks(), vec![(P0, 1000), (P1, 1000)]);
639    }
640
641    #[test]
642    fn pending_counts_feed_backpressure() {
643        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
644        let held: Vec<_> = (0..5).map(|i| issuer.issue(P0, i)).collect();
645        drop(issuer.issue(P1, 9));
646        cp.drain();
647        assert_eq!(cp.pending(P0), 5);
648        assert_eq!(cp.max_pending(), 5);
649        drop(held);
650        cp.drain();
651        let _ = cp.take_watermarks();
652        assert_eq!(cp.max_pending(), 0);
653    }
654
655    /// Every partition in the epoch is listed, sorted, including an idle one
656    /// at zero. A caller publishing a per-partition series retains against
657    /// this list, so omitting an idle partition would drop and re-register
658    /// its series on every quiet cycle.
659    #[test]
660    fn pending_by_partition_lists_every_tracked_partition() {
661        let (mut cp, mut issuer) = checkpointer(&[P0, P1]);
662        let held: Vec<_> = (0..3).map(|i| issuer.issue(P0, i)).collect();
663        cp.drain();
664        assert_eq!(cp.pending_by_partition(), vec![(P0, 3), (P1, 0)]);
665        assert_eq!(
666            cp.max_pending(),
667            cp.pending_by_partition()
668                .iter()
669                .map(|&(_, n)| n)
670                .max()
671                .unwrap_or(0),
672            "the aggregate is the max of the per-partition counts"
673        );
674
675        // Revocation drops the partition from the list, which is what
676        // releases its series.
677        drop(held);
678        cp.drain();
679        let _ = cp.take_watermarks();
680        cp.revoke(&[P0]);
681        assert_eq!(cp.pending_by_partition(), vec![(P1, 0)]);
682    }
683
684    #[test]
685    #[should_panic(expected = "strictly increasing")]
686    fn epoch_regression_panics() {
687        let mut cp = Checkpointer::new();
688        cp.begin_epoch(&[P0], 5);
689        cp.begin_epoch(&[P0], 5);
690    }
691}
692
693#[cfg(all(test, not(loom)))]
694mod proptests {
695    use super::*;
696    use proptest::prelude::*;
697
698    #[derive(Clone, Debug)]
699    enum Op {
700        Issue { partition: u8, fail: bool },
701        ResolveOldest,
702        Rebalance { partitions: Vec<u8> },
703        DrainAndTake,
704    }
705
706    fn ops() -> impl Strategy<Value = Vec<Op>> {
707        prop::collection::vec(
708            prop_oneof![
709                (0..3u8, any::<bool>()).prop_map(|(partition, fail)| Op::Issue { partition, fail }),
710                Just(Op::ResolveOldest),
711                prop::collection::vec(0..3u8, 1..3)
712                    .prop_map(|partitions| Op::Rebalance { partitions }),
713                Just(Op::DrainAndTake),
714            ],
715            0..120,
716        )
717    }
718
719    proptest! {
720        /// Watermarks are per-partition monotonic, never move for
721        /// unassigned partitions, and acknowledgments issued under an old
722        /// epoch never affect a newer epoch's watermarks.
723        #[test]
724        fn epoch_churn_never_leaks_stale_acks(ops in ops()) {
725            let mut cp = Checkpointer::new();
726            let mut epoch = 1u32;
727            let mut assigned: Vec<PartitionId> = vec![PartitionId(0), PartitionId(1), PartitionId(2)];
728            cp.begin_epoch(&assigned, epoch);
729            let mut issuer = cp.handle();
730            let mut offsets: std::collections::HashMap<PartitionId, i64> =
731                std::collections::HashMap::new();
732            // Held (unresolved) acks with the epoch they were issued under.
733            let mut held: std::collections::VecDeque<(AckRef, u32, bool)> =
734                std::collections::VecDeque::new();
735            let mut last_watermark: std::collections::HashMap<PartitionId, i64> =
736                std::collections::HashMap::new();
737
738            for op in ops {
739                match op {
740                    Op::Issue { partition, fail } => {
741                        let p = PartitionId(u32::from(partition));
742                        if !assigned.contains(&p) {
743                            continue;
744                        }
745                        let next = offsets.entry(p).or_insert(0);
746                        *next += 10;
747                        let ack = issuer.issue(p, *next - 1);
748                        if fail {
749                            ack.fail();
750                        }
751                        held.push_back((ack, epoch, fail));
752                    }
753                    Op::ResolveOldest => {
754                        held.pop_front(); // drop resolves it
755                    }
756                    Op::Rebalance { partitions } => {
757                        epoch += 1;
758                        assigned = partitions
759                            .into_iter()
760                            .map(|p| PartitionId(u32::from(p)))
761                            .collect::<std::collections::BTreeSet<_>>()
762                            .into_iter()
763                            .collect();
764                        cp.begin_epoch(&assigned, epoch);
765                        // Sequences and offsets restart with the epoch;
766                        // watermark monotonicity is per-epoch.
767                        offsets.clear();
768                        last_watermark.clear();
769                    }
770                    Op::DrainAndTake => {
771                        cp.drain();
772                        for (p, w) in cp.take_watermarks() {
773                            prop_assert!(
774                                assigned.contains(&p),
775                                "watermark for unassigned partition {p:?}"
776                            );
777                            if let Some(&prev) = last_watermark.get(&p) {
778                                prop_assert!(w > prev, "watermark not monotonic for {p:?}");
779                            }
780                            last_watermark.insert(p, w);
781                        }
782                    }
783                }
784            }
785
786            // Resolve everything still held (stale epochs included), then
787            // verify stale resolutions changed nothing they shouldn't.
788            let stale_epochs: Vec<u32> =
789                held.iter().map(|&(_, e, _)| e).filter(|&e| e != epoch).collect();
790            held.clear();
791            let stats = cp.drain();
792            prop_assert!(stats.unknown == 0, "driver-bug resolutions: {stats:?}");
793            for (p, w) in cp.take_watermarks() {
794                prop_assert!(assigned.contains(&p));
795                if let Some(&prev) = last_watermark.get(&p) {
796                    prop_assert!(w > prev);
797                }
798            }
799            // Sanity: if there were stale-epoch acks, they were counted.
800            if !stale_epochs.is_empty() {
801                prop_assert!(stats.stale_epoch > 0);
802            }
803        }
804    }
805}