1use 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#[derive(Clone, Copy, Debug)]
26struct Registration {
27 id: BatchId,
28 last_offset: i64,
29}
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub struct DrainStats {
34 pub applied: usize,
36 pub stale_epoch: usize,
39 pub duplicates: usize,
41 pub unknown: usize,
43}
44
45#[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 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 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 let _ = self.reg_tx.send(Registration { id, last_offset });
99 AckRef::new(id, last_offset, AckTx::Channel(self.ack_tx.clone()))
100 }
101}
102
103pub 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 gates: FastMap<PartitionId, AdvanceCounter>,
133 admitted_through: Option<PartitionId>,
139 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 #[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 pub(crate) fn set_clock(&mut self, clock: SharedClock) {
185 self.clock = clock;
186 }
187
188 pub(crate) fn now(&self) -> Instant {
191 self.clock.now()
192 }
193
194 #[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 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 self.admitted_through = partitions.iter().copied().max();
233 self.shared_epoch.store(epoch, Ordering::Release);
236 }
237
238 pub fn extend_epoch(&mut self, partitions: &[PartitionId]) -> Result<(), FatalError> {
265 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 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 #[must_use]
310 pub(crate) fn advance_handle(&self, partition: PartitionId) -> Option<AdvanceCounter> {
311 self.gates.get(&partition).cloned()
312 }
313
314 #[must_use]
317 pub(crate) fn tracks(&self, partition: PartitionId) -> bool {
318 self.trackers.contains_key(&partition)
319 }
320
321 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(®.id.partition) {
353 Some(tracker) => tracker.register(reg.id.seq, reg.last_offset),
354 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 let ack = issuer.issue(P0, 99);
538 cp.extend_epoch(&[P1]).unwrap();
539 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 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 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 cp.begin_epoch(&[P0], 2);
582 cp.extend_epoch(&[P1]).unwrap();
583 }
584
585 #[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 #[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 #[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 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); let stats = cp.drain();
681 assert_eq!(stats.applied, 0);
682 assert_eq!(stats.stale_epoch, 2);
684 assert_eq!(cp.take_watermarks(), vec![]);
685
686 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(); 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 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 #[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 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 #[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 let mut issuer = cp.handle();
842 let mut offsets: FastMap<PartitionId, i64> =
843 FastMap::default();
844 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(); }
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 offsets.clear();
881 last_watermark.clear();
882 }
883 Op::Extend { partition } => {
884 let p = PartitionId(u32::from(partition));
885 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 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 if !stale_epochs.is_empty() {
935 prop_assert!(stats.stale_epoch > 0);
936 }
937 }
938 }
939}