Skip to main content

spate_core/coordination/
driver.rs

1//! Reusable source-side choreography for coordinated sources.
2//!
3//! A coordinated source owns two jobs: reading its data (lanes, fetchers,
4//! offsets, all connector-specific) and translating [`CoordinationEvent`]s
5//! into the controller's assignment protocol while keeping fenced-tenancy
6//! bookkeeping straight (source-generic). [`CoordinationDriver`] owns the
7//! second job. A source embeds one next to a [`SplitSource`]
8//! implementation and delegates `poll_events`/`commit` to it.
9//!
10//! # Tenancies, lanes, partitions
11//!
12//! Every continuous ownership span of a split (from `Gained` to whatever
13//! ends it) is one **tenancy**, and each tenancy gets a fresh, never
14//! reused [`PartitionId`] and a fresh, never reused [`LaneId`]. A lane
15//! materializes exactly once, when its tenancy's split is staged for
16//! opening, and lives untouched until the tenancy ends. Gains are
17//! additive ([`SourceEvent::LanesAdded`]); a peer's split arriving never
18//! drains flowing lanes. Watermarks come back keyed by partition, so a
19//! late drain-commit from a lane that lost its split resolves to a retired
20//! tenancy and is skipped. A stale write is never folded, committed, or
21//! resurrected.
22//!
23//! # Event choreography
24//!
25//! One controller event per [`poll_events`](CoordinationDriver::poll_events)
26//! call, in priority order:
27//!
28//! 1. Pending losses → partial [`SourceEvent::LanesRevoked`] (barrier
29//!    sized one party per lane, matching the runtime's drain contract).
30//!    Once delivered, the retired tenancies they belonged to have
31//!    absorbed every late watermark they can see and are pruned.
32//! 2. Staged gains → [`SourceEvent::LanesAdded`] with lanes for the
33//!    newly-gained splits only; existing lanes and their in-flight acks
34//!    are untouched.
35//! 3. Otherwise poll the coordinator (the idle wait delegates there),
36//!    fold its events into the tenancy table, sweep for completions, and
37//!    advance any in-flight cooperative revocations (a revoked split, once
38//!    its intake is stopped and its tail acked, takes a final fenced
39//!    commit and is handed back barrier-less).
40//! 4. [`CoordinationEvent::AllComplete`] → [`SourceEvent::Drained`];
41//!    [`CoordinationEvent::Stalled`] → a fatal error by default
42//!    (see [`stall_drains`](CoordinationDriver::stall_drains)).
43//! 5. Nothing staged and some lane newly at end-of-input
44//!    ([`SplitSource::take_finishing`]) → [`SourceEvent::CommitReady`],
45//!    so the runtime chases the final acks instead of waiting out its
46//!    commit tick.
47
48#![expect(deprecated, reason = "v0.4.0 removes this with spate-coordination")]
49
50use super::{
51    ControlWaker, CoordinationError, CoordinationErrorKind, CoordinationEvent, LeaseEpoch,
52    SplitCoordinator, SplitId, SplitPlanner, SplitProgress, SplitSpec,
53};
54use crate::error::{ErrorClass, SourceError};
55use crate::record::PartitionId;
56use crate::source::{DrainBarrier, LaneId, SourceEvent, SourceLane};
57use std::collections::BTreeMap;
58use std::fmt;
59use std::time::Duration;
60
61/// Everything the driver hands a source when a split's lane is
62/// materialized. This happens exactly once per tenancy, when the gain is
63/// staged into a [`SourceEvent::LanesAdded`].
64#[derive(Debug)]
65#[non_exhaustive]
66pub struct SplitOpening<'a> {
67    /// The split to read.
68    pub split: &'a SplitSpec,
69    /// Authoritative progress to resume from (already validated via
70    /// [`SplitSource::validate_resume`]); `None` for a fresh split.
71    pub resume: Option<&'a SplitProgress>,
72    /// Lane id minted for this tenancy's lifetime; never reused by this
73    /// source.
74    pub lane: LaneId,
75    /// Stable partition id for this tenancy, the key under which this
76    /// split's watermarks come back to [`CoordinationDriver::commit`].
77    pub partition: PartitionId,
78    /// Fencing token of the current tenancy.
79    pub epoch: LeaseEpoch,
80    /// Wakes the control-plane wait. Clone it into the lane and signal it
81    /// the moment the lane decides end-of-input or reports poison.
82    /// Otherwise the driver notices only between waits, and the split's
83    /// completion waits out an idle timeout.
84    pub waker: &'a ControlWaker,
85}
86
87/// What the driver needs from the embedding source.
88///
89/// Implement it on the source's lane-assembly context (the sub-struct that
90/// holds what lane construction needs), not on the source itself. The
91/// driver lives beside that context as a sibling field, so both can be
92/// borrowed disjointly.
93#[deprecated(
94    since = "0.3.0",
95    note = "removed in v0.4.0, where a work feed replaces split coordination; see https://github.com/spate-etl/spate/issues/1088"
96)]
97pub trait SplitSource {
98    /// The data-plane lane type produced for gained splits.
99    type Lane: SourceLane;
100
101    /// Materialize the lane for a gained (or re-assigned) split. Spawn
102    /// fetchers here; never block on data.
103    fn open_split(&mut self, opening: SplitOpening<'_>) -> Result<Self::Lane, SourceError>;
104
105    /// Drift-check carried progress against this instance's view of the
106    /// split (etag pins, schema versions) before it is trusted; the default
107    /// accepts everything.
108    ///
109    /// A rejection is raised before the tenancy is recorded, so the split is
110    /// never opened, and the driver reports it as poison: one delivery
111    /// attempt is consumed, the split is handed back for another instance,
112    /// and at the attempt cap it is quarantined. The error's class then
113    /// decides this run. [`ErrorClass::Fatal`] stops the pipeline; any other
114    /// class is logged and the run continues with the split left to the
115    /// coordinator.
116    fn validate_resume(
117        &self,
118        split: &SplitSpec,
119        progress: &SplitProgress,
120    ) -> Result<(), SourceError> {
121        let _ = (split, progress);
122        Ok(())
123    }
124
125    /// Snapshot the split's committable progress at an acked watermark. The
126    /// snapshot carries the opaque resume state plus whether that watermark
127    /// completes the split (fully delivered **and** fully acknowledged; the
128    /// source owns its eof/emitted accounting).
129    fn encode_commit(
130        &mut self,
131        split: &SplitId,
132        watermark: i64,
133    ) -> Result<SplitProgress, SourceError>;
134
135    /// Completion sweep for an owned split with no new watermark this
136    /// tick (empty splits; tails acked exactly at the previous commit).
137    /// Returns `Some(terminal progress)` when complete, `None` while data
138    /// is in flight.
139    fn sweep(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError>;
140
141    /// The split's lane is being retired (lost, fenced, completed, or
142    /// shutdown). Detach its fetcher; never abort it, because the pipeline
143    /// thread may still be draining the lane. Must not block.
144    ///
145    /// This is the end of the tenancy. The driver never calls
146    /// [`SplitSource::encode_commit`] or [`SplitSource::sweep`] for the
147    /// split afterwards (its tenancy is retired first, and retired
148    /// tenancies absorb late watermarks), so the source may drop the
149    /// split's state here.
150    fn close_split(&mut self, split: &SplitId);
151
152    /// Splits whose lanes decided end-of-input since the last call (the
153    /// edge, not the level). The driver surfaces them as
154    /// [`SourceEvent::CommitReady`] so the runtime chases their final acks
155    /// instead of waiting out the commit tick. The split then completes
156    /// (and frees its working-set slot) within milliseconds of its last
157    /// record becoming sink-durable. A latency hint only; the default
158    /// reports none.
159    fn take_finishing(&mut self) -> Vec<SplitId> {
160        Vec::new()
161    }
162
163    /// Begin a cooperative revocation of an owned split. Stop its intake at a
164    /// safe boundary while **keeping** its commit state, so the tail can
165    /// still be chased to a final fenced commit. Unlike
166    /// [`close_split`](SplitSource::close_split) (which ends the tenancy and
167    /// lets the source drop the split's state), the split stays commit- and
168    /// sweep-adjacent here. The driver keeps committing its acked
169    /// watermarks and then calls [`drain_ready`](SplitSource::drain_ready)
170    /// until the drain finishes.
171    ///
172    /// Return `true` to accept the revocation, `false` to decline it (the
173    /// default). **Contract: return `false` for any split this source has
174    /// not opened or has already closed or completed.** The driver also
175    /// guards this (it declines tenancies without an open lane and feeds
176    /// the decline back to the backend), but the source must not rely on
177    /// that alone.
178    ///
179    /// Declining is safe but not free. The split still leaves, because a
180    /// revocation is the leader's decision; the backend forces the release
181    /// instead and this split's uncommitted tail replays under its next
182    /// owner. (The backend cancels a revocation the leader takes back
183    /// before the decline lands, and then the split stays and keeps being
184    /// read.) A source that *can* stop intake at a safe boundary should,
185    /// because that is the difference between a replay-free move and a
186    /// bounded-duplicate one.
187    ///
188    /// While a split is draining, [`encode_commit`](SplitSource::encode_commit)
189    /// must never report it `completed`. A drain cut can look terminal to
190    /// the source (everything emitted is acked) while the split is
191    /// half-read; the driver strips a `completed` flag it sees here and
192    /// logs an error.
193    fn begin_revoke(&mut self, split: &SplitId) -> bool {
194        let _ = split;
195        false
196    }
197
198    /// Poll a draining split for its final progress. Returns
199    /// `Some(progress)` with `completed: false` once every record it emitted
200    /// is acked **and** that watermark is committed, so the resume point
201    /// handed to the next owner covers everything this instance produced (a
202    /// replay-free transfer); `None` while any of that tail is still in
203    /// flight, to be retried on the next poll. Never reports `completed`; a
204    /// revocation gives the split away rather than finishing it. The default
205    /// reports `None`.
206    ///
207    /// **Level-triggered, unlike [`take_finishing`](SplitSource::take_finishing):**
208    /// keep returning `Some` on every poll until the driver retires the
209    /// split. The final store commit can defer on a store hiccup and is
210    /// re-attempted from a fresh `drain_ready` answer, so an
211    /// edge-triggered implementation would stall the drain until the
212    /// backend forced it.
213    ///
214    /// A source that accepts a revocation in
215    /// [`begin_revoke`](SplitSource::begin_revoke) **must** eventually
216    /// answer `Some` here. The default `None` never finishes a drain; it
217    /// pairs with the default `begin_revoke`, which declines.
218    fn drain_ready(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
219        let _ = split;
220        Ok(None)
221    }
222}
223
224#[derive(Debug, PartialEq, Eq, Clone, Copy)]
225enum TenancyState {
226    /// Owned; lane live or staged to open.
227    Live,
228    /// Owned, but intake has stopped at a safe boundary for a cooperative
229    /// revocation, and the lane is draining toward one final fenced commit.
230    /// Still commit-eligible (tick commits keep folding its acked
231    /// watermarks) and never swept, because a revocation gives the split
232    /// away rather than completing it. Becomes `Retired` (drained, so
233    /// barrier-less) once that final commit lands, or fenced-`Retired` (the
234    /// loss path) if a peer fences it mid-drain.
235    Draining,
236    /// Ownership over (lost, fenced, failed, completed, released); entry
237    /// retained only to absorb late watermarks until its revocation is
238    /// delivered.
239    Retired,
240}
241
242#[derive(Debug)]
243struct Tenancy {
244    split: SplitSpec,
245    epoch: LeaseEpoch,
246    lane: Option<LaneId>,
247    state: TenancyState,
248    /// A commit for this tenancy was fenced: fold nothing, commit nothing.
249    fenced: bool,
250    /// Resume cache: the `Gained` carry, then every acked commit fold.
251    /// Acked means sink-durable, so respawning from it can only skip data
252    /// that is already safe. At-least-once holds even when the durable
253    /// store lags a Retryable commit behind.
254    progress: Option<SplitProgress>,
255    /// Terminal progress reached the store; nothing further to commit.
256    completed: bool,
257    /// This tenancy released its split through a cooperative revocation. Its
258    /// final commit is durable, so the peer resumes replay-free. Routes the
259    /// lane out through the barrier-less retired path, exactly like
260    /// `completed` (nothing is in flight behind a drained revocation).
261    handed_off: bool,
262}
263
264/// A poison report waiting to be re-offered to the backend.
265#[derive(Debug)]
266struct QueuedReport {
267    split: SplitId,
268    /// The tenancy the report concerns.
269    epoch: LeaseEpoch,
270    reason: String,
271}
272
273/// Source-side coordination choreography, embedded by a coordinated
274/// source; see the [module docs](self) for the protocol it implements.
275#[deprecated(
276    since = "0.3.0",
277    note = "removed in v0.4.0, where a work feed replaces split coordination; see https://github.com/spate-etl/spate/issues/1088"
278)]
279pub struct CoordinationDriver {
280    coordinator: Box<dyn SplitCoordinator>,
281    /// Parking half of the control-plane wakeup; the waker half is held by
282    /// the backend and by every lane this driver opened.
283    wait: crossbeam_channel::Receiver<()>,
284    waker: ControlWaker,
285    tenancies: BTreeMap<PartitionId, Tenancy>,
286    by_split: BTreeMap<SplitId, PartitionId>,
287    /// Lanes whose loss must still surface as a partial revoke.
288    pending_lost: Vec<LaneId>,
289    /// Lanes of completed tenancies: terminal progress reached the store,
290    /// so they leave without a drain barrier.
291    pending_retired: Vec<LaneId>,
292    /// Tenancies gained but not yet materialized: their lanes go out in
293    /// the next [`SourceEvent::LanesAdded`].
294    pending_open: Vec<PartitionId>,
295    /// Poison reports the backend refused, to re-offer.
296    /// A gain refused on resume has no tenancy, so until its report lands
297    /// nothing else here hands the split back and the backend keeps
298    /// renewing its lease. Loss or quarantine of the split drops its entry.
299    pending_poison: Vec<QueuedReport>,
300    all_complete: bool,
301    stalled: Option<(u64, u64)>,
302    stall_drains: bool,
303    started: bool,
304    next_partition: u32,
305    /// Lane ids are minted once per tenancy and never reused.
306    next_lane: u32,
307    stop: crate::source::StopSignal,
308}
309
310impl fmt::Debug for CoordinationDriver {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        f.debug_struct("CoordinationDriver")
313            .field("tenancies", &self.tenancies.len())
314            .field("live", &self.by_split.len())
315            .field("pending_lost", &self.pending_lost.len())
316            .field("pending_retired", &self.pending_retired.len())
317            .field("pending_open", &self.pending_open.len())
318            .field("all_complete", &self.all_complete)
319            .field("stalled", &self.stalled)
320            .field("started", &self.started)
321            .finish_non_exhaustive()
322    }
323}
324
325impl CoordinationDriver {
326    /// Wrap a coordinator handle.
327    #[must_use]
328    pub fn new(mut coordinator: Box<dyn SplitCoordinator>) -> CoordinationDriver {
329        let (waker, wait) = super::control_channel();
330        coordinator.set_waker(waker.clone());
331        CoordinationDriver {
332            coordinator,
333            wait,
334            waker,
335            tenancies: BTreeMap::new(),
336            by_split: BTreeMap::new(),
337            pending_lost: Vec::new(),
338            pending_retired: Vec::new(),
339            pending_open: Vec::new(),
340            pending_poison: Vec::new(),
341            all_complete: false,
342            stalled: None,
343            stall_drains: false,
344            started: false,
345            next_partition: 0,
346            next_lane: 0,
347            stop: crate::source::StopSignal::default(),
348        }
349    }
350
351    /// Treat [`CoordinationEvent::Stalled`] as a drain-with-warning
352    /// instead of a fatal error. Default `false`: a bounded job that
353    /// cannot finish because splits are quarantined fails loudly rather
354    /// than exiting as if it were complete.
355    #[must_use]
356    pub fn stall_drains(mut self, drains: bool) -> CoordinationDriver {
357        self.stall_drains = drains;
358        self
359    }
360
361    /// Join the job. Returns the empty `LanesAssigned` ready signal, which
362    /// the source must return from the *same* `poll_events` call. It bumps
363    /// the controller's assignment epoch and marks the pipeline running
364    /// while splits are still being claimed.
365    pub fn start<L>(
366        &mut self,
367        planner: Box<dyn SplitPlanner>,
368    ) -> Result<SourceEvent<L>, SourceError> {
369        assert!(!self.started, "CoordinationDriver::start called twice");
370        self.coordinator.start(planner).map_err(as_source_error)?;
371        self.started = true;
372        Ok(SourceEvent::LanesAssigned(Vec::new()))
373    }
374
375    /// Hand the run's stop to this driver and its coordinator. Call from
376    /// `Source::open` with [`SourceCtx::stop`](crate::source::SourceCtx::stop).
377    pub fn set_stop(&mut self, stop: crate::source::StopSignal) {
378        self.coordinator.set_stop(stop.clone());
379        self.stop = stop;
380    }
381
382    /// Coordinated `poll_events` body: surfaces at most one controller
383    /// event per call, per the [module docs](self).
384    pub fn poll_events<S: SplitSource>(
385        &mut self,
386        source: &mut S,
387        timeout: Duration,
388    ) -> Result<SourceEvent<S::Lane>, SourceError> {
389        assert!(self.started, "poll_events before start");
390
391        // Staged work can be consumed without producing an event (a batch of
392        // gains, every one of which was retired before it could open), and
393        // then the drain has to look again. Loop rather than recurse: nothing
394        // bounds how many such batches a backend produces back to back.
395        let mut park = timeout;
396        loop {
397            // 1. Losses first: stop lost lanes before anything else runs.
398            if !self.pending_lost.is_empty() {
399                let lanes = std::mem::take(&mut self.pending_lost);
400                let barrier = DrainBarrier::new(lanes.len());
401                return Ok(SourceEvent::LanesRevoked { lanes, barrier });
402            }
403
404            // 1b. Completed tenancies leave barrier-less.
405            if !self.pending_retired.is_empty() {
406                let lanes = std::mem::take(&mut self.pending_retired);
407                return Ok(SourceEvent::LanesRetired { lanes });
408            }
409
410            // Every queued revocation has been delivered and the controller
411            // has drained and committed those lanes, so retired tenancies
412            // have absorbed every late watermark they can see. Prune only
413            // those; `Draining` tenancies must survive to be advanced.
414            self.tenancies
415                .retain(|_, t| t.state != TenancyState::Retired);
416
417            // 2. Staged gains: additive lanes for the newly-gained splits
418            // only; existing lanes are untouched.
419            if !self.pending_open.is_empty() {
420                let lanes = self.open_pending(source)?;
421                if !lanes.is_empty() {
422                    return Ok(SourceEvent::LanesAdded(lanes));
423                }
424            }
425
426            // 3. Terminal states, once the choreography above has quiesced.
427            if let Some((completed, quarantined)) = self.stalled {
428                if self.stall_drains {
429                    tracing::warn!(
430                        completed,
431                        quarantined,
432                        "job stalled; draining as configured"
433                    );
434                    return Ok(SourceEvent::Drained);
435                }
436                return Err(SourceError::Client {
437                    class: ErrorClass::Fatal,
438                    reason: format!(
439                        "coordinated job stalled: {completed} splits completed but {quarantined} \
440                     are quarantined and out of delivery attempts; inspect \
441                     spate_coordination_splits_quarantined and requeue or exclude them"
442                    ),
443                });
444            }
445            if self.all_complete {
446                return Ok(SourceEvent::Drained);
447            }
448
449            // 4. Drain the coordinator (never blocks; the wait is ours, at
450            // the end of this function).
451            let events = self.coordinator.poll().map_err(as_source_error)?;
452
453            // 4b. Re-offer poison reports the backend refused; a refused
454            // report leaves a split held here with no tenancy behind it. A
455            // split this batch ends is dropped first, which saves the backend
456            // a report it would fence. A report that lands is fenced by its
457            // epoch and retires only the tenancy at that epoch.
458            for event in &events {
459                if let CoordinationEvent::Lost { split }
460                | CoordinationEvent::Quarantined { split, .. } = event
461                {
462                    self.drop_poison_reports(split);
463                }
464            }
465            for report in std::mem::take(&mut self.pending_poison) {
466                if !self.report_poison(&report.split, report.epoch, &report.reason) {
467                    self.pending_poison.push(report);
468                } else if let Some(partition) = self.tenancy_at(&report.split, report.epoch) {
469                    self.retire(source, partition, false);
470                }
471            }
472
473            // Apply every event even after one fails, and surface one
474            // failure afterwards. `poll` drained the batch, so an event
475            // skipped here is never re-offered: a skipped gain leaves a split
476            // this instance holds but never reads, and a skipped loss leaves
477            // a lane reading a split it no longer owns.
478            let mut surfaced: Option<SourceError> = None;
479            for event in events {
480                if let Err(e) = self.apply(source, event) {
481                    // First failure, except that a fatal one anywhere in the
482                    // batch takes its place: the controller reads only the
483                    // class of the error it is handed, so a fatal error
484                    // behind an earlier retryable one would let the run
485                    // continue past a stop the source asked for.
486                    match &surfaced {
487                        Some(kept) if is_fatal(kept) || !is_fatal(&e) => {}
488                        _ => surfaced = Some(e),
489                    }
490                }
491            }
492            if let Some(e) = surfaced {
493                return Err(e);
494            }
495
496            // 5. Completion sweep over live, uncommitted-terminal tenancies.
497            self.sweep(source)?;
498
499            // 5b. Advance in-flight cooperative revocations.
500            self.advance_drains(source)?;
501
502            if !self.pending_lost.is_empty()
503                || !self.pending_retired.is_empty()
504                || !self.pending_open.is_empty()
505                || self.all_complete
506                || self.stalled.is_some()
507            {
508                // Something is staged: go round and surface it on this same
509                // call, without parking on the way.
510                park = Duration::ZERO;
511                continue;
512            }
513            break;
514        }
515
516        // 5. Nothing staged: surface newly-finishing splits so the runtime
517        // chases their final acks instead of waiting out its commit tick.
518        let finishing = source.take_finishing();
519        if !finishing.is_empty() {
520            let partitions: Vec<PartitionId> = finishing
521                .iter()
522                .filter_map(|split| self.by_split.get(split).copied())
523                .collect();
524            if !partitions.is_empty() {
525                return Ok(SourceEvent::CommitReady { partitions });
526            }
527        }
528
529        // 6. Nothing to report: park here, not inside the backend. Both the
530        // backend and any lane deciding end-of-input or reporting poison
531        // signal this waker, ending the park before the timeout runs out.
532        if !park.is_zero() {
533            let _ = self.wait.recv_timeout(park);
534        }
535        Ok(SourceEvent::Idle)
536    }
537
538    /// Coordinated `commit` body: per-split fenced commits keyed by the
539    /// tenancy partition ids the driver minted.
540    ///
541    /// Returns a retryable error when a split's commit was deferred or the
542    /// stop began before every split was sent; the runtime then keeps every
543    /// position of the call pending.
544    pub fn commit<S: SplitSource>(
545        &mut self,
546        source: &mut S,
547        watermarks: &[(PartitionId, i64)],
548    ) -> Result<(), SourceError> {
549        let mut deferred = 0usize;
550        let mut unsent = 0usize;
551        for (i, &(partition, watermark)) in watermarks.iter().enumerate() {
552            if self.stop.is_set() {
553                unsent = watermarks.len() - i;
554                break;
555            }
556            let Some(split) = self.commit_target(partition) else {
557                continue;
558            };
559            let progress = source.encode_commit(&split, watermark)?;
560            if self.commit_progress(source, partition, &split, progress)?
561                == CommitDisposition::Deferred
562            {
563                deferred += 1;
564            }
565        }
566        if unsent > 0 || (deferred > 0 && self.stop.is_set()) {
567            let not_sent = if unsent > 0 {
568                format!(" and {unsent} not sent")
569            } else {
570                String::new()
571            };
572            return Err(SourceError::Client {
573                class: ErrorClass::Retryable,
574                reason: format!(
575                    "{deferred} split commit(s) deferred{not_sent} because the stop began; the \
576                     positions stay pending"
577                ),
578            });
579        }
580        if deferred > 0 {
581            return Err(SourceError::Client {
582                class: ErrorClass::Retryable,
583                reason: format!("{deferred} split commit(s) deferred; the positions stay pending"),
584            });
585        }
586        Ok(())
587    }
588
589    /// Coordinated `commit_final` body: the per-split commits of
590    /// [`commit`](CoordinationDriver::commit) sent as one batch through
591    /// [`SplitCoordinator::commit_final`].
592    ///
593    /// Returns the partitions whose commit was deferred; each such split may
594    /// replay under its next owner. A fatal answer is returned after every
595    /// other answer is settled.
596    pub fn commit_final<S: SplitSource>(
597        &mut self,
598        source: &mut S,
599        watermarks: &[(PartitionId, i64)],
600    ) -> Result<Vec<PartitionId>, SourceError> {
601        let mut partitions = Vec::new();
602        let mut batch = Vec::new();
603        for &(partition, watermark) in watermarks {
604            let Some(split) = self.commit_target(partition) else {
605                continue;
606            };
607            let progress = source.encode_commit(&split, watermark)?;
608            let progress = self.strip_drain_completion(partition, &split, progress);
609            partitions.push(partition);
610            batch.push((split, progress));
611        }
612        if batch.is_empty() {
613            return Ok(Vec::new());
614        }
615        let answers = self.coordinator.commit_final(&batch);
616        debug_assert_eq!(answers.len(), batch.len(), "one answer per commit");
617        let mut answers = answers.into_iter();
618        let mut fatal = None;
619        let mut unstored = Vec::new();
620        let mut deferred = Vec::new();
621        for (partition, (split, progress)) in partitions.into_iter().zip(batch) {
622            // A missing answer counts as an unsent commit.
623            let answer = answers.next().unwrap_or_else(|| {
624                Err(CoordinationError::new(
625                    CoordinationErrorKind::Retryable,
626                    "no answer for this commit",
627                ))
628            });
629            let cause = answer.as_ref().err().map(ToString::to_string);
630            match self.classify(source, partition, &split, answer) {
631                Ok(disposition) => {
632                    if matches!(disposition, CommitDisposition::Deferred) {
633                        unstored.push(partition);
634                        deferred.push((split, cause.unwrap_or_default()));
635                    }
636                    self.settle_commit(source, partition, progress, disposition);
637                }
638                Err(e) => {
639                    fatal.get_or_insert(e);
640                }
641            }
642        }
643        if let Some(e) = fatal {
644            return Err(e);
645        }
646        if !unstored.is_empty() {
647            tracing::warn!(
648                splits = ?deferred,
649                "final commit left {} split(s) uncommitted; their progress since the last \
650                 durable commit may replay under the next owner",
651                deferred.len()
652            );
653        }
654        Ok(unstored)
655    }
656
657    /// The split a commit for `partition` goes to, or `None` for a pruned,
658    /// retired, fenced or completed tenancy, whose position is not written.
659    fn commit_target(&self, partition: PartitionId) -> Option<SplitId> {
660        // A pruned tenancy is a drain commit that arrived after its
661        // retirement was fully delivered. Its data replays under the new
662        // owner.
663        let tenancy = self.tenancies.get(&partition)?;
664        if tenancy.state == TenancyState::Retired || tenancy.fenced || tenancy.completed {
665            return None;
666        }
667        Some(tenancy.split.id.clone())
668    }
669
670    /// Report the tenancy of `split` at `epoch` as poison: consumes a
671    /// delivery attempt and hands the split back for another worker (or
672    /// quarantine, at the cap). The tenancy's lane is retired through the
673    /// normal loss path. A report the coordinator answers `Retryable` is
674    /// re-offered on each poll, and the lane is retired once it is accepted.
675    /// A report for a tenancy this instance no longer holds is dropped.
676    pub fn fail<S: SplitSource>(
677        &mut self,
678        source: &mut S,
679        split: &SplitId,
680        epoch: LeaseEpoch,
681        reason: &str,
682    ) -> Result<(), SourceError> {
683        let Some(partition) = self.tenancy_at(split, epoch) else {
684            let held_epoch = self
685                .by_split
686                .get(split)
687                .and_then(|p| self.tenancies.get(p))
688                .map(|t| t.epoch.0);
689            tracing::debug!(
690                split = %split,
691                reported_epoch = epoch.0,
692                held_epoch,
693                "dropping a failure report for a tenancy this instance does not hold"
694            );
695            return Ok(());
696        };
697        match self.coordinator.fail(split, epoch, reason) {
698            Ok(()) => {}
699            // Fenced: the reported tenancy has ended; the retire below still applies.
700            Err(e) if e.kind == CoordinationErrorKind::Fenced => {}
701            // Retryable: the split stays held and the report is re-offered
702            // on the next poll; the retire happens once it lands.
703            Err(e) if e.kind == CoordinationErrorKind::Retryable => {
704                tracing::warn!(
705                    split = %split,
706                    error = %e,
707                    "poison report refused; retrying while this instance holds the split"
708                );
709                if !self
710                    .pending_poison
711                    .iter()
712                    .any(|queued| &queued.split == split && queued.epoch == epoch)
713                {
714                    self.pending_poison.push(QueuedReport {
715                        split: split.clone(),
716                        epoch,
717                        reason: reason.to_string(),
718                    });
719                }
720                return Ok(());
721            }
722            Err(e) => return Err(as_source_error(e)),
723        }
724        self.retire(source, partition, false);
725        Ok(())
726    }
727
728    /// Depart the job through [`SplitCoordinator::depart`], held splits or
729    /// none, so peers take over without waiting out a lease. Call from the
730    /// source's `Drop`; it blocks for at most the backend's bound.
731    pub fn release(&mut self) {
732        if !self.started {
733            return;
734        }
735        let held: Vec<SplitId> = self.by_split.keys().cloned().collect();
736        if let Err(e) = self.coordinator.depart(&held) {
737            tracing::warn!(error = %e, "graceful departure reported an error; anything not handed back expires");
738        }
739    }
740
741    /// Current live split → lane view (pause/resume bookkeeping, tests).
742    #[must_use]
743    pub fn assignments(&self) -> Vec<(SplitId, LaneId)> {
744        self.by_split
745            .iter()
746            .filter_map(|(split, partition)| {
747                let lane = self.tenancies.get(partition)?.lane?;
748                Some((split.clone(), lane))
749            })
750            .collect()
751    }
752
753    /// Report a gain the source refused as poison, for a split with no
754    /// tenancy to retire. The rejection is what the caller returns, so a
755    /// report the backend refuses is queued for retry rather than put in
756    /// its place.
757    fn report_rejected_gain(
758        &mut self,
759        split: &SplitId,
760        epoch: LeaseEpoch,
761        rejection: &SourceError,
762    ) {
763        let reason = format!("carried progress rejected on resume: {rejection}");
764        if !self.report_poison(split, epoch, &reason) {
765            self.pending_poison.push(QueuedReport {
766                split: split.clone(),
767                epoch,
768                reason,
769            });
770        }
771    }
772
773    /// Drop the queued poison reports for a split this instance no longer holds.
774    fn drop_poison_reports(&mut self, split: &SplitId) {
775        self.pending_poison.retain(|queued| &queued.split != split);
776    }
777
778    /// The live tenancy of `split`, if it is the one at `epoch`.
779    fn tenancy_at(&self, split: &SplitId, epoch: LeaseEpoch) -> Option<PartitionId> {
780        let &partition = self.by_split.get(split)?;
781        (self.tenancies.get(&partition)?.epoch == epoch).then_some(partition)
782    }
783
784    /// Offer one poison report to the backend. `false` means the backend
785    /// refused it and this instance is still holding the split.
786    fn report_poison(&mut self, split: &SplitId, epoch: LeaseEpoch, reason: &str) -> bool {
787        match self.coordinator.fail(split, epoch, reason) {
788            Ok(()) => true,
789            // Fenced: the reported tenancy has ended.
790            Err(e) if e.kind == CoordinationErrorKind::Fenced => true,
791            Err(e) => {
792                tracing::warn!(
793                    split = %split,
794                    error = %e,
795                    "poison report refused; retrying while this instance holds the split"
796                );
797                false
798            }
799        }
800    }
801
802    fn apply<S: SplitSource>(
803        &mut self,
804        source: &mut S,
805        event: CoordinationEvent,
806    ) -> Result<(), SourceError> {
807        match event {
808            CoordinationEvent::Gained {
809                split,
810                epoch,
811                progress,
812            } => {
813                if let Some(&stale) = self.by_split.get(&split.id) {
814                    // Backend contract violation (re-gain without a loss);
815                    // retire the stale tenancy defensively and continue.
816                    tracing::warn!(split = %split.id, "gained a split already held; retiring stale tenancy");
817                    self.retire(source, stale, false);
818                }
819                if let Some(progress) = progress.as_ref()
820                    && let Err(e) = source.validate_resume(&split, progress)
821                {
822                    // Report before the rejection leaves: no tenancy was
823                    // recorded, so nothing else here releases the split and
824                    // the backend keeps renewing its lease.
825                    self.report_rejected_gain(&split.id, epoch, &e);
826                    return Err(e);
827                }
828                let partition = PartitionId(self.next_partition);
829                self.next_partition += 1;
830                self.by_split.insert(split.id.clone(), partition);
831                self.tenancies.insert(
832                    partition,
833                    Tenancy {
834                        split,
835                        epoch,
836                        lane: None,
837                        state: TenancyState::Live,
838                        fenced: false,
839                        progress,
840                        completed: false,
841                        handed_off: false,
842                    },
843                );
844                self.pending_open.push(partition);
845            }
846            CoordinationEvent::RevokeRequested { split } => {
847                // The leader wants this split back. Accept only a split held
848                // live with an OPEN lane, un-fenced and not yet completed,
849                // and only if the source can stop its intake at a safe
850                // boundary: a tenancy gained but not yet opened has no drain
851                // to finish and would sit in `Draining` forever. A refusal is
852                // declined back to the backend, which forces the release; the
853                // split leaves either way.
854                //
855                // A repeat request for a tenancy already draining is accepted
856                // silently, with no second `begin_revoke` and no decline.
857                if let Some(&partition) = self.by_split.get(&split)
858                    && self
859                        .tenancies
860                        .get(&partition)
861                        .is_some_and(|t| t.state == TenancyState::Draining && !t.fenced)
862                {
863                    return Ok(());
864                }
865                let accepted = match self.by_split.get(&split) {
866                    Some(&partition) => {
867                        let eligible = self.tenancies.get(&partition).is_some_and(|t| {
868                            t.state == TenancyState::Live
869                                && t.lane.is_some()
870                                && !t.fenced
871                                && !t.completed
872                        });
873                        if eligible && source.begin_revoke(&split) {
874                            // Re-borrow after `begin_revoke`; the guard
875                            // reads above kept no mutable borrow across it.
876                            if let Some(t) = self.tenancies.get_mut(&partition) {
877                                t.state = TenancyState::Draining;
878                            }
879                            true
880                        } else {
881                            false
882                        }
883                    }
884                    None => false,
885                };
886                if !accepted && let Err(e) = self.coordinator.decline_revoke(&split) {
887                    // Liveness cost only: the backend forces the release at
888                    // its own deadline regardless.
889                    tracing::warn!(split = %split, error = %e, "revocation decline failed");
890                }
891            }
892            CoordinationEvent::Lost { split } => {
893                self.drop_poison_reports(&split);
894                if let Some(&partition) = self.by_split.get(&split) {
895                    self.retire(source, partition, false);
896                }
897                // Else: already retired (e.g. we fenced on commit first).
898            }
899            CoordinationEvent::Quarantined { split, attempts } => {
900                tracing::warn!(split = %split, attempts, "split quarantined");
901                self.drop_poison_reports(&split);
902                if let Some(&partition) = self.by_split.get(&split) {
903                    self.retire(source, partition, false);
904                }
905            }
906            CoordinationEvent::AllComplete => {
907                if self.next_partition == 0 {
908                    tracing::info!(
909                        "coordinated job completed without this instance holding any split — \
910                         the job finished before this instance's first rebalance window, or \
911                         the fleet has more replicas than splits (see the scaling-out guide)"
912                    );
913                }
914                self.all_complete = true;
915            }
916            CoordinationEvent::Stalled {
917                completed,
918                quarantined,
919            } => {
920                self.stalled = Some((completed, quarantined));
921            }
922        }
923        Ok(())
924    }
925
926    /// End a tenancy: detach its fetchers, queue its lane for revocation,
927    /// keep the entry to absorb late watermarks until the next assignment.
928    fn retire<S: SplitSource>(&mut self, source: &mut S, partition: PartitionId, fenced: bool) {
929        let Some(tenancy) = self.tenancies.get_mut(&partition) else {
930            return;
931        };
932        if tenancy.state == TenancyState::Retired {
933            if fenced {
934                tenancy.fenced = true;
935            }
936            return;
937        }
938        tenancy.state = TenancyState::Retired;
939        tenancy.fenced |= fenced;
940        self.by_split.remove(&tenancy.split.id);
941        let split = tenancy.split.id.clone();
942        self.pending_poison.retain(|queued| queued.split != split);
943        if let Some(lane) = tenancy.lane.take() {
944            if tenancy.completed || tenancy.handed_off {
945                self.pending_retired.push(lane);
946            } else {
947                self.pending_lost.push(lane);
948            }
949        }
950        source.close_split(&split);
951    }
952
953    /// Materialize lanes for the staged gains only. Each tenancy opens
954    /// exactly once, with a lane id minted for its lifetime; a staged
955    /// tenancy that ended before it could open (gained then immediately
956    /// lost or fenced) is skipped, its retirement having already handled it.
957    ///
958    /// All-or-nothing: a failure part way through undoes the whole batch
959    /// and re-stages it. Anything else strands the lanes already built.
960    /// They never reach the runtime, yet their tenancies stay `Live`
961    /// holding a lane id, which the `lane.is_some()` guard then skips
962    /// forever. The splits would keep their leases, heartbeated and
963    /// unreadable, and the job would stall instead of failing.
964    fn open_pending<S: SplitSource>(
965        &mut self,
966        source: &mut S,
967    ) -> Result<Vec<S::Lane>, SourceError> {
968        let staged = std::mem::take(&mut self.pending_open);
969        let mut lanes = Vec::with_capacity(staged.len());
970        // Tenancies this call minted a lane for, so a failure can undo them.
971        let mut opened: Vec<PartitionId> = Vec::new();
972        for idx in 0..staged.len() {
973            let partition = staged[idx];
974            let Some(tenancy) = self.tenancies.get_mut(&partition) else {
975                continue; // retired and pruned before it could open
976            };
977            if tenancy.state != TenancyState::Live || tenancy.lane.is_some() {
978                continue;
979            }
980            let lane_id = LaneId(self.next_lane);
981            self.next_lane = self
982                .next_lane
983                .checked_add(1)
984                .expect("lane ids exhausted (u32)");
985            tenancy.lane = Some(lane_id);
986            let opening = SplitOpening {
987                split: &tenancy.split,
988                resume: tenancy.progress.as_ref(),
989                lane: lane_id,
990                partition,
991                epoch: tenancy.epoch,
992                waker: &self.waker,
993            };
994            match source.open_split(opening) {
995                Ok(lane) => {
996                    lanes.push(lane);
997                    opened.push(partition);
998                }
999                Err(e) => {
1000                    // Dropping the lanes detaches whatever `open_split`
1001                    // spawned for them; clearing `lane` lets the retry mint
1002                    // a fresh id (ids are burned, never reused).
1003                    drop(lanes);
1004                    for p in opened.iter().chain(std::iter::once(&partition)) {
1005                        if let Some(t) = self.tenancies.get_mut(p) {
1006                            t.lane = None;
1007                        }
1008                    }
1009                    self.pending_open = staged;
1010                    return Err(e);
1011                }
1012            }
1013        }
1014        Ok(lanes)
1015    }
1016
1017    fn sweep<S: SplitSource>(&mut self, source: &mut S) -> Result<(), SourceError> {
1018        let candidates: Vec<PartitionId> = self
1019            .tenancies
1020            .iter()
1021            .filter(|(_, t)| t.state == TenancyState::Live && !t.fenced && !t.completed)
1022            .map(|(&p, _)| p)
1023            .collect();
1024        for partition in candidates {
1025            let split = self.tenancies[&partition].split.id.clone();
1026            if let Some(progress) = source.sweep(&split)? {
1027                let _ = self.commit_progress(source, partition, &split, progress)?;
1028            }
1029        }
1030        Ok(())
1031    }
1032
1033    /// Advance every in-flight cooperative revocation. For each `Draining`
1034    /// tenancy whose drain has finished ([`SplitSource::drain_ready`]
1035    /// returns the final progress once its tail is acked and committed),
1036    /// take one last fenced commit (never `completed`; a revocation gives the
1037    /// split away rather than finishing it) and dispose of it:
1038    ///
1039    /// - durable → mark drained, hand the split back
1040    ///   ([`SplitCoordinator::release_drained`]), and retire it barrier-less;
1041    /// - fenced → a peer fenced this tenancy mid-drain; retire it through
1042    ///   the loss path (its bounded tail replays under the new owner);
1043    /// - deferred → the store lagged; stay `Draining` and re-attempt on
1044    ///   the next poll.
1045    fn advance_drains<S: SplitSource>(&mut self, source: &mut S) -> Result<(), SourceError> {
1046        let draining: Vec<PartitionId> = self
1047            .tenancies
1048            .iter()
1049            .filter(|(_, t)| t.state == TenancyState::Draining && !t.fenced)
1050            .map(|(&p, _)| p)
1051            .collect();
1052        for partition in draining {
1053            let split = self.tenancies[&partition].split.id.clone();
1054            let Some(progress) = source.drain_ready(&split)? else {
1055                continue; // tail still in flight, retry next poll
1056            };
1057            debug_assert!(
1058                !progress.completed,
1059                "a revocation commit gives the split away, it must not complete it"
1060            );
1061            self.commit_drained(source, partition, &split, progress)?;
1062        }
1063        Ok(())
1064    }
1065
1066    /// The disposition of one fenced commit attempt. Tick, sweep, and
1067    /// drain commits triage the backend's three answers identically; each
1068    /// caller owns only the durable arm and shares the fence/retry
1069    /// handling here.
1070    fn try_commit<S: SplitSource>(
1071        &mut self,
1072        source: &mut S,
1073        partition: PartitionId,
1074        split: &SplitId,
1075        progress: &SplitProgress,
1076    ) -> Result<CommitDisposition, SourceError> {
1077        let answer = self.coordinator.commit(split, progress);
1078        if let Err(e) = &answer
1079            && e.kind == CoordinationErrorKind::Retryable
1080        {
1081            tracing::warn!(split = %split, error = %e, "commit deferred; will retry");
1082        }
1083        self.classify(source, partition, split, answer)
1084    }
1085
1086    /// Sort the backend's answer to one fenced commit into its disposition,
1087    /// retiring a fenced tenancy.
1088    fn classify<S: SplitSource>(
1089        &mut self,
1090        source: &mut S,
1091        partition: PartitionId,
1092        split: &SplitId,
1093        answer: Result<(), CoordinationError>,
1094    ) -> Result<CommitDisposition, SourceError> {
1095        match answer {
1096            Ok(()) => Ok(CommitDisposition::Durable),
1097            Err(e) if e.kind == CoordinationErrorKind::Fenced => {
1098                // Nothing was written; the split belongs to a peer. Retire
1099                // with the fence flag so nothing of this tenancy is ever
1100                // folded or respawned (the matching Lost event may still
1101                // arrive and finds the tenancy already retired).
1102                tracing::warn!(split = %split, "commit fenced; split lost to a peer");
1103                self.retire(source, partition, true);
1104                Ok(CommitDisposition::Fenced)
1105            }
1106            Err(e) if e.kind == CoordinationErrorKind::Retryable => Ok(CommitDisposition::Deferred),
1107            Err(e) => Err(as_source_error(e)),
1108        }
1109    }
1110
1111    /// `progress` with `completed` cleared when the tenancy is draining.
1112    fn strip_drain_completion(
1113        &self,
1114        partition: PartitionId,
1115        split: &SplitId,
1116        progress: SplitProgress,
1117    ) -> SplitProgress {
1118        // A drain cut can look terminal to the source (every record it
1119        // emitted is acked). Committing it `completed: true` marks a
1120        // half-read split permanently done, and its next owner never
1121        // resumes it: silent data loss.
1122        if progress.completed
1123            && self
1124                .tenancies
1125                .get(&partition)
1126                .is_some_and(|t| t.state == TenancyState::Draining)
1127        {
1128            tracing::error!(
1129                split = %split,
1130                "source reported a draining split completed; forcing \
1131                 completed=false — a drain cut is never terminal"
1132            );
1133            SplitProgress::new(progress.watermark, progress.state)
1134        } else {
1135            progress
1136        }
1137    }
1138
1139    /// Shared fenced-commit path for tick commits and sweep commits.
1140    fn commit_progress<S: SplitSource>(
1141        &mut self,
1142        source: &mut S,
1143        partition: PartitionId,
1144        split: &SplitId,
1145        progress: SplitProgress,
1146    ) -> Result<CommitDisposition, SourceError> {
1147        let progress = self.strip_drain_completion(partition, split, progress);
1148        let disposition = self.try_commit(source, partition, split, &progress)?;
1149        self.settle_commit(source, partition, progress, disposition);
1150        Ok(disposition)
1151    }
1152
1153    /// Fold a live tenancy's commit outcome into its state.
1154    fn settle_commit<S: SplitSource>(
1155        &mut self,
1156        source: &mut S,
1157        partition: PartitionId,
1158        progress: SplitProgress,
1159        disposition: CommitDisposition,
1160    ) {
1161        match disposition {
1162            CommitDisposition::Durable => {
1163                let tenancy = self.tenancies.get_mut(&partition).expect("live tenancy");
1164                let completed = progress.completed;
1165                tenancy.progress = Some(progress);
1166                if completed {
1167                    tenancy.completed = true;
1168                    // A completed split frees its lane for the working set.
1169                    self.retire(source, partition, false);
1170                }
1171            }
1172            // Fenced: already retired inside `classify`.
1173            CommitDisposition::Fenced => {}
1174            CommitDisposition::Deferred => {
1175                // A deferred tick commit fails the whole `commit`, so the
1176                // runtime resends its positions on the next tick or in the
1177                // final commit; a deferred sweep's progress rides the
1178                // split's next commit. The resume cache still advances: the
1179                // watermark is acked (sink-durable), so respawning past it
1180                // cannot lose data.
1181                let tenancy = self.tenancies.get_mut(&partition).expect("live tenancy");
1182                tenancy.progress = Some(progress);
1183            }
1184        }
1185    }
1186
1187    /// Final fenced commit that ends a cooperative revocation. Same triage as
1188    /// [`commit_progress`](CoordinationDriver::commit_progress), but a
1189    /// durable commit hands the split back and retires it barrier-less
1190    /// instead of folding progress into a still-live tenancy.
1191    fn commit_drained<S: SplitSource>(
1192        &mut self,
1193        source: &mut S,
1194        partition: PartitionId,
1195        split: &SplitId,
1196        progress: SplitProgress,
1197    ) -> Result<(), SourceError> {
1198        // Same guard as `commit_progress`: drain progress is never terminal.
1199        let progress = if progress.completed {
1200            tracing::error!(
1201                split = %split,
1202                "drain_ready returned completed=true; forcing completed=false — \
1203                 a drain cut is never terminal"
1204            );
1205            SplitProgress::new(progress.watermark, progress.state)
1206        } else {
1207            progress
1208        };
1209        match self.try_commit(source, partition, split, &progress)? {
1210            CommitDisposition::Durable => {
1211                let tenancy = self
1212                    .tenancies
1213                    .get_mut(&partition)
1214                    .expect("draining tenancy");
1215                tenancy.progress = Some(progress);
1216                tenancy.handed_off = true;
1217                if let Err(e) = self
1218                    .coordinator
1219                    .release_drained(std::slice::from_ref(split))
1220                {
1221                    // Liveness cost only: the lease expires on its own and a
1222                    // peer takes over, with no data at risk. Retire anyway so
1223                    // the lane leaves this instance.
1224                    tracing::warn!(
1225                        split = %split,
1226                        error = %e,
1227                        "drain release failed; lease will expire and a peer will take over"
1228                    );
1229                }
1230                self.retire(source, partition, false);
1231            }
1232            // Fenced mid-drain: already retired through the loss path.
1233            CommitDisposition::Fenced => {}
1234            CommitDisposition::Deferred => {
1235                // Store lagged: stay `Draining` and re-attempt next poll.
1236                let tenancy = self
1237                    .tenancies
1238                    .get_mut(&partition)
1239                    .expect("draining tenancy");
1240                tenancy.progress = Some(progress);
1241            }
1242        }
1243        Ok(())
1244    }
1245}
1246
1247/// How the backend answered one fenced commit; see
1248/// [`CoordinationDriver::classify`].
1249#[derive(Clone, Copy, PartialEq, Eq)]
1250enum CommitDisposition {
1251    /// Durable write. The caller advances its own state.
1252    Durable,
1253    /// Fenced: nothing written, the split belongs to a peer; the tenancy
1254    /// has already been retired with the fence flag.
1255    Fenced,
1256    /// Retryable: the write may or may not have been stored, and each
1257    /// caller decides when the progress goes out again.
1258    Deferred,
1259}
1260
1261fn is_fatal(e: &SourceError) -> bool {
1262    let SourceError::Client { class, .. } = e;
1263    *class == ErrorClass::Fatal
1264}
1265
1266fn as_source_error(e: CoordinationError) -> SourceError {
1267    SourceError::Client {
1268        class: e.class(),
1269        reason: e.to_string(),
1270    }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276    use crate::checkpoint::AckRef;
1277    use crate::coordination::{PlanContext, PlanFinality, SplitPlan};
1278    use crate::pipeline::fakes::wait_for;
1279    use crate::record::RawPayload;
1280    use crate::source::PayloadBatch;
1281    use std::cell::RefCell;
1282    use std::collections::{HashMap, HashSet, VecDeque};
1283    use std::rc::Rc;
1284    use std::sync::{Arc, Mutex};
1285    use std::time::Instant;
1286
1287    // ------------------------------------------------------------------
1288    // Scripted coordinator double (the shape spate-test publishes).
1289
1290    #[derive(Default)]
1291    struct ScriptState {
1292        batches: VecDeque<Vec<CoordinationEvent>>,
1293        commit_outcomes: HashMap<String, VecDeque<CoordinationErrorKind>>,
1294        commits: Vec<(SplitId, SplitProgress)>,
1295        fail_outcomes: HashMap<String, VecDeque<CoordinationErrorKind>>,
1296        /// Every `fail` call, including the ones `fail_outcomes` refuses;
1297        /// the attempt is what a test asserts the driver made.
1298        fails: Vec<(SplitId, LeaseEpoch, String)>,
1299        released: Vec<SplitId>,
1300        /// The held set `depart` was called with, if it was.
1301        departed: Option<Vec<SplitId>>,
1302        /// Captured separately from `released` so a test can prove the
1303        /// driver takes the revocation-release path, not a plain hand-back.
1304        released_drained: Vec<SplitId>,
1305        /// Every `decline_revoke` call, so a test can prove the driver feeds
1306        /// a refusal back to the backend, which then forces the release.
1307        declined: Vec<SplitId>,
1308        started: bool,
1309        waker: Option<ControlWaker>,
1310        /// `poll` calls so far, counted after each drain.
1311        polls: usize,
1312        /// The signal `set_stop` handed over.
1313        stop: Option<crate::source::StopSignal>,
1314        /// A split whose commit sets this flag before it is answered.
1315        stop_on_commit: Option<(String, Arc<std::sync::atomic::AtomicBool>)>,
1316    }
1317
1318    #[derive(Clone, Default)]
1319    struct Script(Arc<Mutex<ScriptState>>);
1320
1321    impl Script {
1322        fn push(&self, events: Vec<CoordinationEvent>) {
1323            let mut st = self.0.lock().unwrap();
1324            st.batches.push_back(events);
1325            if let Some(w) = &st.waker {
1326                w.wake();
1327            }
1328        }
1329
1330        fn fail_next_commit(&self, split: &str, kind: CoordinationErrorKind) {
1331            self.0
1332                .lock()
1333                .unwrap()
1334                .commit_outcomes
1335                .entry(split.to_string())
1336                .or_default()
1337                .push_back(kind);
1338        }
1339
1340        fn fail_next_report(&self, split: &str, kind: CoordinationErrorKind) {
1341            self.0
1342                .lock()
1343                .unwrap()
1344                .fail_outcomes
1345                .entry(split.to_string())
1346                .or_default()
1347                .push_back(kind);
1348        }
1349
1350        fn commits(&self) -> Vec<(SplitId, SplitProgress)> {
1351            self.0.lock().unwrap().commits.clone()
1352        }
1353
1354        fn released(&self) -> Vec<SplitId> {
1355            self.0.lock().unwrap().released.clone()
1356        }
1357
1358        fn departed(&self) -> Option<Vec<SplitId>> {
1359            self.0.lock().unwrap().departed.clone()
1360        }
1361
1362        fn released_drained(&self) -> Vec<SplitId> {
1363            self.0.lock().unwrap().released_drained.clone()
1364        }
1365
1366        fn declined(&self) -> Vec<SplitId> {
1367            self.0.lock().unwrap().declined.clone()
1368        }
1369
1370        fn fails(&self) -> Vec<(SplitId, LeaseEpoch, String)> {
1371            self.0.lock().unwrap().fails.clone()
1372        }
1373
1374        fn polls(&self) -> usize {
1375            self.0.lock().unwrap().polls
1376        }
1377
1378        fn stop(&self) -> Option<crate::source::StopSignal> {
1379            self.0.lock().unwrap().stop.clone()
1380        }
1381
1382        fn stop_on_commit(&self, split: &str, flag: &Arc<std::sync::atomic::AtomicBool>) {
1383            self.0.lock().unwrap().stop_on_commit = Some((split.to_string(), Arc::clone(flag)));
1384        }
1385    }
1386
1387    struct ScriptedCoordinator(Script);
1388
1389    impl SplitCoordinator for ScriptedCoordinator {
1390        fn start(&mut self, _planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
1391            self.0.0.lock().unwrap().started = true;
1392            Ok(())
1393        }
1394
1395        fn set_waker(&mut self, waker: ControlWaker) {
1396            self.0.0.lock().unwrap().waker = Some(waker);
1397        }
1398
1399        fn set_stop(&mut self, stop: crate::source::StopSignal) {
1400            self.0.0.lock().unwrap().stop = Some(stop);
1401        }
1402
1403        fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
1404            let mut s = self.0.0.lock().unwrap();
1405            s.polls += 1;
1406            Ok(s.batches.pop_front().unwrap_or_default())
1407        }
1408
1409        fn commit(
1410            &mut self,
1411            split: &SplitId,
1412            progress: &SplitProgress,
1413        ) -> Result<(), CoordinationError> {
1414            let mut s = self.0.0.lock().unwrap();
1415            if let Some((target, flag)) = &s.stop_on_commit
1416                && target == split.as_str()
1417            {
1418                flag.store(true, std::sync::atomic::Ordering::SeqCst);
1419            }
1420            if let Some(kinds) = s.commit_outcomes.get_mut(split.as_str())
1421                && let Some(kind) = kinds.pop_front()
1422            {
1423                return Err(CoordinationError::new(kind, "scripted"));
1424            }
1425            s.commits.push((split.clone(), progress.clone()));
1426            Ok(())
1427        }
1428
1429        fn fail(
1430            &mut self,
1431            split: &SplitId,
1432            epoch: LeaseEpoch,
1433            reason: &str,
1434        ) -> Result<(), CoordinationError> {
1435            let mut s = self.0.0.lock().unwrap();
1436            s.fails.push((split.clone(), epoch, reason.to_string()));
1437            if let Some(kinds) = s.fail_outcomes.get_mut(split.as_str())
1438                && let Some(kind) = kinds.pop_front()
1439            {
1440                return Err(CoordinationError::new(kind, "scripted"));
1441            }
1442            Ok(())
1443        }
1444
1445        fn release(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
1446            self.0
1447                .0
1448                .lock()
1449                .unwrap()
1450                .released
1451                .extend(splits.iter().cloned());
1452            Ok(())
1453        }
1454
1455        fn depart(&mut self, held: &[SplitId]) -> Result<(), CoordinationError> {
1456            self.0.0.lock().unwrap().departed = Some(held.to_vec());
1457            Ok(())
1458        }
1459
1460        fn release_drained(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
1461            self.0
1462                .0
1463                .lock()
1464                .unwrap()
1465                .released_drained
1466                .extend(splits.iter().cloned());
1467            Ok(())
1468        }
1469
1470        fn decline_revoke(&mut self, split: &SplitId) -> Result<(), CoordinationError> {
1471            self.0.0.lock().unwrap().declined.push(split.clone());
1472            Ok(())
1473        }
1474    }
1475
1476    struct NoopPlanner;
1477
1478    impl SplitPlanner for NoopPlanner {
1479        fn fingerprint(&self) -> String {
1480            "test:v1".into()
1481        }
1482
1483        fn plan(&mut self, _ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError> {
1484            Ok(SplitPlan::new(vec![], PlanFinality::Final))
1485        }
1486    }
1487
1488    // ------------------------------------------------------------------
1489    // Stub data plane.
1490
1491    enum NoBatch {}
1492
1493    impl<'buf> PayloadBatch<'buf> for NoBatch {
1494        fn next_payload(&mut self) -> Option<RawPayload<'buf>> {
1495            match *self {}
1496        }
1497
1498        fn ack(&self) -> &AckRef {
1499            match *self {}
1500        }
1501    }
1502
1503    #[derive(Debug)]
1504    struct StubLane {
1505        lane: LaneId,
1506        partition: PartitionId,
1507    }
1508
1509    impl SourceLane for StubLane {
1510        type Batch<'a> = NoBatch;
1511
1512        fn id(&self) -> LaneId {
1513            self.lane
1514        }
1515
1516        fn partition(&self) -> PartitionId {
1517            self.partition
1518        }
1519
1520        fn poll(
1521            &mut self,
1522            _max: usize,
1523            _timeout: Duration,
1524        ) -> Result<Option<NoBatch>, SourceError> {
1525            Ok(None)
1526        }
1527    }
1528
1529    /// Recording SplitSource: every callback is logged; sweep and
1530    /// encode_commit outcomes are scripted per split.
1531    #[derive(Default)]
1532    struct TestSource {
1533        opened: Vec<(String, Option<i64>, LaneId, PartitionId, u64)>,
1534        closed: Vec<String>,
1535        encoded: Vec<(String, i64)>,
1536        sweeps: Rc<RefCell<HashMap<String, SplitProgress>>>,
1537        complete_at: HashMap<String, i64>,
1538        /// Split ids whose carried progress `validate_resume` refuses, and
1539        /// the class each refusal carries, so a batch can mix a drifted
1540        /// split with a sound one, and one class with another.
1541        reject_resume: HashMap<String, ErrorClass>,
1542        finishing: Vec<String>,
1543        /// Split ids whose `open_split` fails. Consumed per attempt, so a
1544        /// retry of the same split succeeds.
1545        fail_open: Vec<String>,
1546        /// Split ids for which `begin_revoke` accepts (returns true). Empty
1547        /// by default, so the double declines like the trait default.
1548        accept_revoke: HashSet<String>,
1549        /// Every `begin_revoke` call, in order (accepted or declined).
1550        begin_revoke_calls: Vec<String>,
1551        /// Every `drain_ready` call, in order. Proves a split did (or did
1552        /// not) transition to `Draining`.
1553        drain_ready_calls: Vec<String>,
1554        /// Scripted `drain_ready` results, sticky per split (returned on
1555        /// every poll until the tenancy retires), so a retryable final
1556        /// commit can be re-offered the same tail next poll.
1557        ready_progress: Rc<RefCell<HashMap<String, SplitProgress>>>,
1558    }
1559
1560    impl SplitSource for TestSource {
1561        type Lane = StubLane;
1562
1563        fn open_split(&mut self, o: SplitOpening<'_>) -> Result<StubLane, SourceError> {
1564            let id = o.split.id.as_str().to_string();
1565            if let Some(i) = self.fail_open.iter().position(|s| *s == id) {
1566                self.fail_open.remove(i);
1567                return Err(SourceError::Client {
1568                    class: ErrorClass::Retryable,
1569                    reason: format!("open_split failed for {id}"),
1570                });
1571            }
1572            self.opened.push((
1573                o.split.id.as_str().to_string(),
1574                o.resume.map(|p| p.watermark),
1575                o.lane,
1576                o.partition,
1577                o.epoch.0,
1578            ));
1579            Ok(StubLane {
1580                lane: o.lane,
1581                partition: o.partition,
1582            })
1583        }
1584
1585        fn validate_resume(
1586            &self,
1587            split: &SplitSpec,
1588            _progress: &SplitProgress,
1589        ) -> Result<(), SourceError> {
1590            if let Some(&class) = self.reject_resume.get(split.id.as_str()) {
1591                return Err(SourceError::Client {
1592                    class,
1593                    reason: format!("resume drift on {}", split.id),
1594                });
1595            }
1596            Ok(())
1597        }
1598
1599        fn encode_commit(
1600            &mut self,
1601            split: &SplitId,
1602            watermark: i64,
1603        ) -> Result<SplitProgress, SourceError> {
1604            self.encoded.push((split.as_str().to_string(), watermark));
1605            let completed = self.complete_at.get(split.as_str()) == Some(&watermark);
1606            Ok(if completed {
1607                SplitProgress::completed(watermark, vec![])
1608            } else {
1609                SplitProgress::new(watermark, vec![])
1610            })
1611        }
1612
1613        fn sweep(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
1614            Ok(self.sweeps.borrow_mut().remove(split.as_str()))
1615        }
1616
1617        fn close_split(&mut self, split: &SplitId) {
1618            self.closed.push(split.as_str().to_string());
1619        }
1620
1621        fn take_finishing(&mut self) -> Vec<SplitId> {
1622            std::mem::take(&mut self.finishing)
1623                .into_iter()
1624                .map(|s| SplitId::new(&s).unwrap())
1625                .collect()
1626        }
1627
1628        fn begin_revoke(&mut self, split: &SplitId) -> bool {
1629            self.begin_revoke_calls.push(split.as_str().to_string());
1630            self.accept_revoke.contains(split.as_str())
1631        }
1632
1633        fn drain_ready(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
1634            self.drain_ready_calls.push(split.as_str().to_string());
1635            Ok(self.ready_progress.borrow().get(split.as_str()).cloned())
1636        }
1637    }
1638
1639    // ------------------------------------------------------------------
1640    // Helpers.
1641
1642    fn split(id: &str) -> SplitSpec {
1643        SplitSpec::new(SplitId::new(id).unwrap(), format!("desc:{id}").into_bytes())
1644    }
1645
1646    fn gained(id: &str, epoch: u64, watermark: Option<i64>) -> CoordinationEvent {
1647        CoordinationEvent::Gained {
1648            split: split(id),
1649            epoch: LeaseEpoch(epoch),
1650            progress: watermark.map(|w| SplitProgress::new(w, vec![])),
1651        }
1652    }
1653
1654    fn driver(script: &Script) -> CoordinationDriver {
1655        let mut d = CoordinationDriver::new(Box::new(ScriptedCoordinator(script.clone())));
1656        let ready: SourceEvent<StubLane> = d.start(Box::new(NoopPlanner)).unwrap();
1657        assert!(
1658            matches!(ready, SourceEvent::LanesAssigned(ref lanes) if lanes.is_empty()),
1659            "start must return the empty ready signal"
1660        );
1661        d
1662    }
1663
1664    fn poll(d: &mut CoordinationDriver, s: &mut TestSource) -> SourceEvent<StubLane> {
1665        d.poll_events(s, Duration::ZERO).unwrap()
1666    }
1667
1668    /// A source whose `validate_resume` refuses exactly these splits,
1669    /// classed `Fatal` the way a connector is told to.
1670    fn rejecting(splits: &[&str]) -> TestSource {
1671        TestSource {
1672            reject_resume: splits
1673                .iter()
1674                .map(|s| ((*s).to_string(), ErrorClass::Fatal))
1675                .collect(),
1676            ..TestSource::default()
1677        }
1678    }
1679
1680    // ------------------------------------------------------------------
1681    // Scenarios (each replays a defect class from the PR #34 review).
1682
1683    #[test]
1684    fn a_signal_cuts_the_control_plane_park_short() {
1685        // The driver owns the control-plane wait so that both producers can
1686        // end it: the backend, and a *lane* deciding end-of-input on a
1687        // pipeline thread. If a `wake()` call site is ever dropped, the
1688        // symptom is silent (completions wait out an idle timeout again), so
1689        // assert the park is interruptible rather than trusting the wiring.
1690        let script = Script::default();
1691        let mut d = driver(&script);
1692        let mut s = TestSource::default();
1693        let park = Duration::from_millis(400);
1694
1695        // Control: nothing pending and nothing signaling, so the full
1696        // timeout elapses. Without this the test would pass even if
1697        // `poll_events` never parked at all.
1698        let t0 = Instant::now();
1699        assert!(matches!(
1700            d.poll_events(&mut s, park).unwrap(),
1701            SourceEvent::Idle
1702        ));
1703        let idle = t0.elapsed();
1704        assert!(
1705            idle >= park / 2,
1706            "expected a real park, returned after {idle:?}"
1707        );
1708
1709        // A signal pushed after this call has drained the backend reaches it
1710        // only through the wake. The event itself surfaces on the following
1711        // call, so this asserts the wakeup, not the delivery.
1712        let signaller = script.clone();
1713        let drained = script.polls();
1714        let handle = std::thread::spawn(move || {
1715            wait_for(
1716                "the driver drains the backend",
1717                Duration::from_secs(5),
1718                || signaller.polls() > drained,
1719            );
1720            signaller.push(vec![CoordinationEvent::AllComplete]);
1721        });
1722        let long_park = Duration::from_secs(30);
1723        let t1 = Instant::now();
1724        assert!(matches!(
1725            d.poll_events(&mut s, long_park).unwrap(),
1726            SourceEvent::Idle
1727        ));
1728        let woken = t1.elapsed();
1729        handle.join().unwrap();
1730        assert!(
1731            woken < long_park / 2,
1732            "a signal must cut the park short, but it ran {woken:?} of {long_park:?}"
1733        );
1734        assert!(matches!(
1735            d.poll_events(&mut s, Duration::ZERO).unwrap(),
1736            SourceEvent::Drained
1737        ));
1738    }
1739
1740    #[test]
1741    fn a_failed_open_undoes_the_whole_batch_instead_of_stranding_lanes() {
1742        // Lanes already built when a later `open_split` fails never reach the
1743        // runtime, yet their tenancies keep a lane id and hold their leases,
1744        // heartbeated and unreadable: a stalled job rather than a failed one.
1745        let script = Script::default();
1746        let mut d = driver(&script);
1747        let mut s = TestSource {
1748            fail_open: vec!["b".into()],
1749            ..TestSource::default()
1750        };
1751
1752        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1753        let err = d
1754            .poll_events(&mut s, Duration::ZERO)
1755            .expect_err("the failing open must surface");
1756        assert!(err.to_string().contains("open_split failed for b"), "{err}");
1757        assert_eq!(s.opened.len(), 1, "a opened before b failed");
1758
1759        // The retry re-stages the whole batch and yields both lanes.
1760        let event = poll(&mut d, &mut s);
1761        let SourceEvent::LanesAdded(lanes) = event else {
1762            panic!("expected both lanes after the retry, got {event:?}");
1763        };
1764        assert_eq!(lanes.len(), 2);
1765        let reopened: Vec<&str> = s.opened.iter().map(|o| o.0.as_str()).collect();
1766        assert_eq!(reopened, ["a", "a", "b"], "a re-opens on the retry");
1767        // The rolled-back ids are burned, never reused.
1768        assert_eq!(lanes[0].id(), LaneId(2));
1769        assert_eq!(lanes[1].id(), LaneId(3));
1770    }
1771
1772    #[test]
1773    fn gains_coalesce_into_one_added_batch() {
1774        let script = Script::default();
1775        let mut d = driver(&script);
1776        let mut s = TestSource::default();
1777
1778        script.push(vec![gained("b", 1, Some(7)), gained("a", 1, None)]);
1779        let event = poll(&mut d, &mut s);
1780        let SourceEvent::LanesAdded(lanes) = event else {
1781            panic!("expected added lanes, got {event:?}");
1782        };
1783        assert_eq!(lanes.len(), 2);
1784        // Lane ids minted in gain order; distinct tenancy partitions.
1785        assert_eq!(s.opened[0].0, "b");
1786        assert_eq!(s.opened[0].2, LaneId(0));
1787        assert_eq!(s.opened[0].1, Some(7), "carried progress reaches open");
1788        assert_eq!(s.opened[1].0, "a");
1789        assert_eq!(s.opened[1].2, LaneId(1));
1790        assert_ne!(s.opened[0].3, s.opened[1].3);
1791        assert_eq!(d.assignments().len(), 2);
1792    }
1793
1794    #[test]
1795    fn a_mid_flow_gain_never_touches_live_lanes_and_their_commits_fold() {
1796        let script = Script::default();
1797        let mut d = driver(&script);
1798        let mut s = TestSource::default();
1799        script.push(vec![gained("a", 1, None)]);
1800        poll(&mut d, &mut s);
1801        let a_partition = s.opened[0].3;
1802
1803        // Split b arrives while a is live and flowing: strictly additive.
1804        script.push(vec![gained("b", 1, None)]);
1805        let event = poll(&mut d, &mut s);
1806        let SourceEvent::LanesAdded(lanes) = event else {
1807            panic!("expected added lanes, got {event:?}");
1808        };
1809        assert_eq!(lanes.len(), 1, "only the new split's lane");
1810        assert!(
1811            s.closed.is_empty(),
1812            "a routine gain must never detach flowing fetchers"
1813        );
1814
1815        // The narrow commit window: a's acked watermark lands right after
1816        // the gain. It must fold normally.
1817        d.commit(&mut s, &[(a_partition, 42)]).unwrap();
1818        assert_eq!(s.encoded, vec![("a".to_string(), 42)]);
1819        assert_eq!(script.commits().len(), 1);
1820        assert_eq!(script.commits()[0].0.as_str(), "a");
1821        // a's lane is the original, never re-minted by the gain.
1822        assert!(
1823            d.assignments()
1824                .contains(&(SplitId::new("a").unwrap(), LaneId(0)))
1825        );
1826    }
1827
1828    #[test]
1829    fn finishing_splits_surface_as_commit_ready_once() {
1830        let script = Script::default();
1831        let mut d = driver(&script);
1832        let mut s = TestSource::default();
1833        script.push(vec![gained("a", 1, None)]);
1834        poll(&mut d, &mut s);
1835        let a_partition = s.opened[0].3;
1836
1837        s.finishing.push("a".to_string());
1838        let event = poll(&mut d, &mut s);
1839        let SourceEvent::CommitReady { partitions } = event else {
1840            panic!("expected commit-ready, got {event:?}");
1841        };
1842        assert_eq!(partitions, vec![a_partition]);
1843        // Edge, not level: the hint is consumed.
1844        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1845    }
1846
1847    #[test]
1848    fn loss_surfaces_as_partial_revoke_and_detaches_fetchers() {
1849        let script = Script::default();
1850        let mut d = driver(&script);
1851        let mut s = TestSource::default();
1852        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1853        poll(&mut d, &mut s);
1854
1855        script.push(vec![CoordinationEvent::Lost {
1856            split: SplitId::new("a").unwrap(),
1857        }]);
1858        let event = poll(&mut d, &mut s);
1859        let SourceEvent::LanesRevoked { lanes, barrier } = event else {
1860            panic!("expected revoke, got {event:?}");
1861        };
1862        assert_eq!(lanes, vec![LaneId(0)]);
1863        assert_eq!(barrier.remaining(), 1, "one party per revoked lane");
1864        assert_eq!(s.closed, vec!["a"], "fetcher detached on loss");
1865        assert_eq!(d.assignments().len(), 1);
1866    }
1867
1868    #[test]
1869    fn late_drain_commit_after_loss_is_skipped() {
1870        let script = Script::default();
1871        let mut d = driver(&script);
1872        let mut s = TestSource::default();
1873        script.push(vec![gained("a", 1, None)]);
1874        poll(&mut d, &mut s);
1875        let partition = s.opened[0].3;
1876
1877        script.push(vec![CoordinationEvent::Lost {
1878            split: SplitId::new("a").unwrap(),
1879        }]);
1880        poll(&mut d, &mut s);
1881
1882        // The drain hands back a final watermark for the retired tenancy.
1883        d.commit(&mut s, &[(partition, 42)]).unwrap();
1884        assert!(s.encoded.is_empty(), "retired tenancy must not encode");
1885        assert!(script.commits().is_empty(), "and must not commit");
1886    }
1887
1888    #[test]
1889    fn fenced_commit_quarantines_the_tenancy_and_never_respawns_it() {
1890        let script = Script::default();
1891        let mut d = driver(&script);
1892        let mut s = TestSource::default();
1893        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1894        poll(&mut d, &mut s);
1895        let a_partition = s.opened[0].3;
1896        let b_partition = s.opened[1].3;
1897
1898        script.fail_next_commit("a", CoordinationErrorKind::Fenced);
1899        d.commit(&mut s, &[(a_partition, 10), (b_partition, 20)])
1900            .unwrap();
1901        // b committed; a wrote nothing and is retired with the fence flag.
1902        assert_eq!(script.commits().len(), 1);
1903        assert_eq!(script.commits()[0].0.as_str(), "b");
1904        assert_eq!(s.closed, vec!["a"]);
1905
1906        // The fenced lane is revoked...
1907        let event = poll(&mut d, &mut s);
1908        assert!(
1909            matches!(event, SourceEvent::LanesRevoked { ref lanes, .. } if lanes[..] == [LaneId(0)])
1910        );
1911
1912        // ...a late watermark for it is skipped...
1913        s.encoded.clear();
1914        d.commit(&mut s, &[(a_partition, 11)]).unwrap();
1915        assert!(s.encoded.is_empty());
1916
1917        // ...and the mid-cycle Lost that follows the fence is a no-op,
1918        // while a re-gain (higher epoch) starts a fresh tenancy, added
1919        // beside b's untouched live lane and never draining it.
1920        script.push(vec![
1921            CoordinationEvent::Lost {
1922                split: SplitId::new("a").unwrap(),
1923            },
1924            gained("a", 3, Some(10)),
1925        ]);
1926        let event = poll(&mut d, &mut s);
1927        let SourceEvent::LanesAdded(lanes) = event else {
1928            panic!("expected an added lane for the re-gain, got {event:?}");
1929        };
1930        assert_eq!(lanes.len(), 1, "only the fresh tenancy's lane");
1931        assert_eq!(s.closed, vec!["a"], "b's fetcher was never detached");
1932        let a_again = s.opened.last().unwrap();
1933        assert_eq!(a_again.0, "a");
1934        assert_eq!(a_again.4, 3, "fresh tenancy under the new epoch");
1935        assert_ne!(a_again.3, a_partition, "fresh partition — no reuse");
1936        assert_eq!(a_again.2, LaneId(2), "fresh lane id — never reused");
1937    }
1938
1939    #[test]
1940    fn lost_then_regained_in_one_batch_is_a_clean_tenancy_swap() {
1941        let script = Script::default();
1942        let mut d = driver(&script);
1943        let mut s = TestSource::default();
1944        script.push(vec![gained("a", 1, None)]);
1945        poll(&mut d, &mut s);
1946        let first_partition = s.opened[0].3;
1947
1948        script.push(vec![
1949            CoordinationEvent::Lost {
1950                split: SplitId::new("a").unwrap(),
1951            },
1952            gained("a", 2, Some(5)),
1953        ]);
1954        // Loss first (revoke), then the re-gain's lane is added fresh.
1955        let event = poll(&mut d, &mut s);
1956        assert!(matches!(event, SourceEvent::LanesRevoked { .. }));
1957        let event = poll(&mut d, &mut s);
1958        assert!(matches!(event, SourceEvent::LanesAdded(ref l) if l.len() == 1));
1959        let reopened = s.opened.last().unwrap();
1960        assert_eq!(reopened.4, 2);
1961        assert_eq!(reopened.1, Some(5), "resume from the carried progress");
1962        assert_ne!(reopened.3, first_partition);
1963        assert_eq!(reopened.2, LaneId(1), "lane ids are never recycled");
1964    }
1965
1966    #[test]
1967    fn retryable_commit_defers_and_recommits_idempotently() {
1968        let script = Script::default();
1969        let mut d = driver(&script);
1970        let mut s = TestSource::default();
1971        script.push(vec![gained("a", 1, None)]);
1972        poll(&mut d, &mut s);
1973        let partition = s.opened[0].3;
1974
1975        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
1976        let err = d
1977            .commit(&mut s, &[(partition, 10)])
1978            .expect_err("a deferred split fails the commit");
1979        assert!(
1980            matches!(
1981                &err,
1982                SourceError::Client {
1983                    class: ErrorClass::Retryable,
1984                    ..
1985                }
1986            ),
1987            "{err:?}"
1988        );
1989        assert!(script.commits().is_empty(), "deferred, not written");
1990        assert_eq!(
1991            d.tenancies[&partition]
1992                .progress
1993                .as_ref()
1994                .map(|p| p.watermark),
1995            Some(10),
1996            "the resume cache holds the deferred progress"
1997        );
1998
1999        // A bare tick re-issues nothing: the driver commits the watermarks
2000        // it is handed and no others.
2001        poll(&mut d, &mut s);
2002        assert!(script.commits().is_empty(), "a tick recommitted nothing");
2003
2004        // The split's next commit carries the merged progress and lands.
2005        d.commit(&mut s, &[(partition, 12)]).unwrap();
2006        assert_eq!(script.commits().len(), 1);
2007        assert_eq!(script.commits()[0].1.watermark, 12);
2008    }
2009
2010    /// A deferred split fails the commit after the later splits of the same
2011    /// commit are sent. Regression for #962.
2012    #[test]
2013    fn a_deferred_split_fails_the_commit_after_every_split_is_sent() {
2014        let script = Script::default();
2015        let mut d = driver(&script);
2016        let mut s = TestSource::default();
2017        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
2018        poll(&mut d, &mut s);
2019        let pa = s.opened[0].3;
2020        let pb = s.opened[1].3;
2021
2022        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
2023        let result = d.commit(&mut s, &[(pa, 10), (pb, 20)]);
2024
2025        assert!(
2026            matches!(
2027                &result,
2028                Err(SourceError::Client {
2029                    class: ErrorClass::Retryable,
2030                    ..
2031                })
2032            ),
2033            "{result:?}"
2034        );
2035        let sent: Vec<_> = script
2036            .commits()
2037            .into_iter()
2038            .map(|(split, _)| split)
2039            .collect();
2040        assert_eq!(sent, vec![SplitId::new("b").unwrap()]);
2041    }
2042
2043    /// A stop that begins during a commit stops the driver sending the
2044    /// remaining splits, and the commit fails retryably.
2045    #[test]
2046    fn a_stop_during_a_commit_sends_no_later_split() {
2047        let script = Script::default();
2048        let mut d = driver(&script);
2049        let mut s = TestSource::default();
2050        let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2051        d.set_stop(crate::source::StopSignal::new(Arc::clone(&flag)));
2052        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
2053        poll(&mut d, &mut s);
2054        let pa = s.opened[0].3;
2055        let pb = s.opened[1].3;
2056        script.stop_on_commit("a", &flag);
2057
2058        let result = d.commit(&mut s, &[(pa, 10), (pb, 20)]);
2059
2060        assert!(
2061            matches!(
2062                &result,
2063                Err(SourceError::Client {
2064                    class: ErrorClass::Retryable,
2065                    ..
2066                })
2067            ),
2068            "{result:?}"
2069        );
2070        let sent: Vec<_> = script
2071            .commits()
2072            .into_iter()
2073            .map(|(split, _)| split)
2074            .collect();
2075        assert_eq!(sent, vec![SplitId::new("a").unwrap()]);
2076    }
2077
2078    /// The final commit sends every split while the stop is set.
2079    #[test]
2080    fn commit_final_sends_every_split_while_the_stop_is_set() {
2081        let script = Script::default();
2082        let mut d = driver(&script);
2083        let mut s = TestSource::default();
2084        d.set_stop(crate::source::StopSignal::new(Arc::new(
2085            std::sync::atomic::AtomicBool::new(true),
2086        )));
2087        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
2088        poll(&mut d, &mut s);
2089        let pa = s.opened[0].3;
2090        let pb = s.opened[1].3;
2091
2092        let unstored = d.commit_final(&mut s, &[(pa, 10), (pb, 20)]).unwrap();
2093
2094        assert!(unstored.is_empty(), "{unstored:?}");
2095        assert_eq!(script.commits().len(), 2);
2096    }
2097
2098    /// `set_stop` hands the signal to the coordinator.
2099    #[test]
2100    fn set_stop_reaches_the_coordinator() {
2101        let script = Script::default();
2102        let mut d = driver(&script);
2103        let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2104        d.set_stop(crate::source::StopSignal::new(Arc::clone(&flag)));
2105        flag.store(true, std::sync::atomic::Ordering::SeqCst);
2106        assert!(script.stop().is_some_and(|stop| stop.is_set()));
2107    }
2108
2109    /// A poison report the coordinator refuses while the stop is set leaves
2110    /// the tenancy live and the report queued for the next poll.
2111    #[test]
2112    fn a_report_refused_at_the_stop_stays_queued() {
2113        let script = Script::default();
2114        let mut d = driver(&script);
2115        let mut s = TestSource::default();
2116        d.set_stop(crate::source::StopSignal::new(Arc::new(
2117            std::sync::atomic::AtomicBool::new(true),
2118        )));
2119        script.push(vec![gained("a", 1, None)]);
2120        poll(&mut d, &mut s);
2121        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2122
2123        d.fail(&mut s, &SplitId::new("a").unwrap(), LeaseEpoch(1), "poison")
2124            .unwrap();
2125        assert_eq!(d.assignments().len(), 1, "the tenancy stays live");
2126
2127        poll(&mut d, &mut s);
2128        assert_eq!(script.fails().len(), 2, "the report is offered again");
2129    }
2130
2131    #[test]
2132    fn completion_sweep_commits_terminal_progress_and_frees_the_lane() {
2133        let script = Script::default();
2134        let mut d = driver(&script);
2135        let mut s = TestSource::default();
2136        script.push(vec![gained("a", 1, None)]);
2137        poll(&mut d, &mut s);
2138
2139        s.sweeps
2140            .borrow_mut()
2141            .insert("a".into(), SplitProgress::completed(9, vec![]));
2142        // The sweep commits terminal progress and retires the lane; being
2143        // complete (nothing in flight by construction), it leaves
2144        // barrier-less on this same poll (staged-work fastpath).
2145        let event = poll(&mut d, &mut s);
2146        assert!(
2147            matches!(event, SourceEvent::LanesRetired { ref lanes } if lanes[..] == [LaneId(0)]),
2148            "completed lanes retire without a drain barrier, got {event:?}"
2149        );
2150        assert_eq!(script.commits().len(), 1);
2151        assert!(script.commits()[0].1.completed);
2152
2153        // A watermark-carrying commit that completes a split does the same.
2154        script.push(vec![gained("b", 1, None)]);
2155        poll(&mut d, &mut s);
2156        let b_partition = s.opened.last().unwrap().3;
2157        s.complete_at.insert("b".into(), 20);
2158        d.commit(&mut s, &[(b_partition, 20)]).unwrap();
2159        assert!(script.commits().last().unwrap().1.completed);
2160        let event = poll(&mut d, &mut s);
2161        assert!(matches!(event, SourceEvent::LanesRetired { .. }));
2162    }
2163
2164    #[test]
2165    fn standby_with_zero_splits_drains_on_all_complete() {
2166        let script = Script::default();
2167        let mut d = driver(&script);
2168        let mut s = TestSource::default();
2169
2170        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2171        script.push(vec![CoordinationEvent::AllComplete]);
2172        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Drained));
2173        // Idempotent thereafter.
2174        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Drained));
2175    }
2176
2177    #[test]
2178    fn stalled_is_fatal_by_default_and_drains_when_configured() {
2179        let script = Script::default();
2180        let mut d = driver(&script);
2181        let mut s = TestSource::default();
2182        script.push(vec![CoordinationEvent::Stalled {
2183            completed: 7,
2184            quarantined: 1,
2185        }]);
2186        // The stall surfaces on the same call that absorbed the event.
2187        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2188        assert!(err.to_string().contains("quarantined"), "{err}");
2189
2190        let script = Script::default();
2191        let mut d = CoordinationDriver::new(Box::new(ScriptedCoordinator(script.clone())))
2192            .stall_drains(true);
2193        let _: SourceEvent<StubLane> = d.start(Box::new(NoopPlanner)).unwrap();
2194        script.push(vec![CoordinationEvent::Stalled {
2195            completed: 7,
2196            quarantined: 1,
2197        }]);
2198        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Drained));
2199    }
2200
2201    #[test]
2202    fn fail_reports_poison_and_retires_the_lane() {
2203        let script = Script::default();
2204        let mut d = driver(&script);
2205        let mut s = TestSource::default();
2206        script.push(vec![gained("a", 1, None)]);
2207        poll(&mut d, &mut s);
2208
2209        d.fail(
2210            &mut s,
2211            &SplitId::new("a").unwrap(),
2212            LeaseEpoch(1),
2213            "undecodable object",
2214        )
2215        .unwrap();
2216        assert_eq!(script.fails().len(), 1);
2217        assert_eq!(s.closed, vec!["a"]);
2218        let event = poll(&mut d, &mut s);
2219        assert!(matches!(event, SourceEvent::LanesRevoked { .. }));
2220        // Failing a split we no longer hold is a quiet no-op.
2221        d.fail(&mut s, &SplitId::new("a").unwrap(), LeaseEpoch(1), "again")
2222            .unwrap();
2223        assert_eq!(script.fails().len(), 1);
2224    }
2225
2226    #[test]
2227    fn a_retryable_failure_report_is_retried_until_it_lands() {
2228        let script = Script::default();
2229        let mut d = driver(&script);
2230        let mut s = TestSource::default();
2231        script.push(vec![gained("a", 4, None)]);
2232        poll(&mut d, &mut s);
2233        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2234
2235        d.fail(
2236            &mut s,
2237            &SplitId::new("a").unwrap(),
2238            LeaseEpoch(4),
2239            "undecodable object",
2240        )
2241        .unwrap();
2242        assert_eq!(script.fails().len(), 1);
2243        assert!(
2244            s.closed.is_empty(),
2245            "the split is held until the report lands"
2246        );
2247
2248        let event = poll(&mut d, &mut s);
2249        assert_eq!(script.fails().len(), 2);
2250        assert_eq!(
2251            script.fails()[1].1,
2252            LeaseEpoch(4),
2253            "the re-offer keeps the epoch"
2254        );
2255        assert_eq!(s.closed, vec!["a"]);
2256        assert!(matches!(event, SourceEvent::LanesRevoked { .. }));
2257        poll(&mut d, &mut s);
2258        assert_eq!(script.fails().len(), 2);
2259    }
2260
2261    #[test]
2262    fn a_queued_failure_report_is_dropped_when_the_split_is_lost() {
2263        let script = Script::default();
2264        let mut d = driver(&script);
2265        let mut s = TestSource::default();
2266        script.push(vec![gained("a", 1, None)]);
2267        poll(&mut d, &mut s);
2268        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2269        d.fail(
2270            &mut s,
2271            &SplitId::new("a").unwrap(),
2272            LeaseEpoch(1),
2273            "undecodable object",
2274        )
2275        .unwrap();
2276
2277        // The loss is drained before the next re-offer.
2278        script.push(vec![CoordinationEvent::Lost {
2279            split: split("a").id,
2280        }]);
2281        script.push(vec![gained("a", 2, None)]);
2282        poll(&mut d, &mut s);
2283        poll(&mut d, &mut s);
2284        assert_eq!(script.fails().len(), 1, "a stale report is not re-offered");
2285        assert_eq!(d.assignments().len(), 1, "the regained split stays live");
2286    }
2287
2288    #[test]
2289    fn a_fatal_failure_report_is_returned() {
2290        let script = Script::default();
2291        let mut d = driver(&script);
2292        let mut s = TestSource::default();
2293        script.push(vec![gained("a", 1, None)]);
2294        poll(&mut d, &mut s);
2295        script.fail_next_report("a", CoordinationErrorKind::Fatal);
2296
2297        let err = d
2298            .fail(
2299                &mut s,
2300                &SplitId::new("a").unwrap(),
2301                LeaseEpoch(1),
2302                "undecodable object",
2303            )
2304            .unwrap_err();
2305        assert!(is_fatal(&err), "{err}");
2306    }
2307
2308    #[test]
2309    fn release_departs_with_every_live_split() {
2310        let script = Script::default();
2311        let mut d = driver(&script);
2312        let mut s = TestSource::default();
2313        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
2314        poll(&mut d, &mut s);
2315
2316        d.release();
2317        let departed = script.departed().expect("the driver departs");
2318        assert_eq!(departed.len(), 2);
2319        assert!(departed.iter().any(|s| s.as_str() == "a"));
2320        assert!(departed.iter().any(|s| s.as_str() == "b"));
2321    }
2322
2323    /// A worker holding nothing still departs, so the backend can hand back
2324    /// what it keeps beyond splits.
2325    #[test]
2326    fn release_departs_with_nothing_held() {
2327        let script = Script::default();
2328        let mut d = driver(&script);
2329        d.release();
2330        assert_eq!(script.departed(), Some(Vec::new()));
2331    }
2332
2333    #[test]
2334    fn resume_validation_rejects_drifted_progress() {
2335        let script = Script::default();
2336        let mut d = driver(&script);
2337        let mut s = rejecting(&["a"]);
2338        script.push(vec![gained("a", 1, Some(7))]);
2339        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2340        assert!(err.to_string().contains("resume drift"), "{err}");
2341
2342        // The split is never opened, so nothing else would hand it back:
2343        // the driver reports it as poison, carrying the source's reason.
2344        assert!(s.opened.is_empty());
2345        let fails = script.fails();
2346        assert_eq!(fails.len(), 1);
2347        assert_eq!(fails[0].0.as_str(), "a");
2348        assert!(fails[0].2.contains("resume drift on a"), "{}", fails[0].2);
2349    }
2350
2351    #[test]
2352    fn a_refused_resume_leaves_the_rest_of_the_batch_applied() {
2353        let script = Script::default();
2354        let mut d = driver(&script);
2355        let mut s = rejecting(&["a"]);
2356        // One batch, one drifted split: `poll` already drained the batch, so
2357        // a sound split behind the rejection is never re-offered.
2358        script.push(vec![gained("a", 1, Some(7)), gained("b", 1, Some(3))]);
2359        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2360        assert!(err.to_string().contains("resume drift on a"), "{err}");
2361        assert_eq!(script.fails().len(), 1);
2362
2363        // `b` was staged by the same batch and opens on the next poll.
2364        let event = poll(&mut d, &mut s);
2365        let SourceEvent::LanesAdded(lanes) = event else {
2366            panic!("expected the sound split to open");
2367        };
2368        assert_eq!(lanes.len(), 1);
2369        assert_eq!(s.opened.len(), 1);
2370        assert_eq!(s.opened[0].0, "b");
2371        assert_eq!(s.opened[0].1, Some(3));
2372    }
2373
2374    #[test]
2375    fn a_failed_poison_report_does_not_replace_the_rejection() {
2376        let script = Script::default();
2377        let mut d = driver(&script);
2378        let mut s = rejecting(&["a"]);
2379        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2380        script.push(vec![gained("a", 1, Some(7))]);
2381
2382        // The source's rejection is the error the pipeline classes; a
2383        // backend that would not take the report cannot downgrade it.
2384        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2385        assert!(err.to_string().contains("resume drift on a"), "{err}");
2386        assert_eq!(script.fails().len(), 1);
2387    }
2388
2389    #[test]
2390    fn a_refused_poison_report_is_retried_until_it_lands() {
2391        let script = Script::default();
2392        let mut d = driver(&script);
2393        let mut s = rejecting(&["a"]);
2394        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2395        script.push(vec![gained("a", 3, Some(7))]);
2396        d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2397        assert_eq!(script.fails().len(), 1);
2398
2399        // A refused report leaves the split held here, so it is re-offered
2400        // until the backend takes it, and then stops.
2401        poll(&mut d, &mut s);
2402        assert_eq!(script.fails().len(), 2);
2403        poll(&mut d, &mut s);
2404        let fails = script.fails();
2405        assert_eq!(fails.len(), 2);
2406        assert!(
2407            fails.iter().all(|(_, epoch, _)| *epoch == LeaseEpoch(3)),
2408            "{fails:?}"
2409        );
2410    }
2411
2412    /// A rejected gain's report that is fenced after the split was regained
2413    /// with no `Lost` leaves the regained tenancy live. Regression for #909.
2414    #[test]
2415    fn a_fenced_report_for_a_rejected_gain_keeps_the_tenancy_regained_without_a_loss() {
2416        let script = Script::default();
2417        let mut d = driver(&script);
2418        let mut s = rejecting(&["a"]);
2419        // The first report applies with its reply lost, the re-offer in the
2420        // poll that drains the regain is refused, and the next one is fenced.
2421        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2422        script.fail_next_report("a", CoordinationErrorKind::Retryable);
2423        script.fail_next_report("a", CoordinationErrorKind::Fenced);
2424        script.push(vec![gained("a", 1, Some(7))]);
2425        d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2426        s.reject_resume.clear();
2427        script.push(vec![gained("a", 2, Some(7))]);
2428        for _ in 0..4 {
2429            let _ = d.poll_events(&mut s, Duration::ZERO);
2430        }
2431        assert_eq!(script.fails().len(), 3);
2432        assert_eq!(script.fails()[2].1, LeaseEpoch(1));
2433        assert_eq!(
2434            d.assignments().len(),
2435            1,
2436            "the tenancy at epoch 2 stays live"
2437        );
2438    }
2439
2440    /// A report for an earlier tenancy of a split sends nothing and leaves
2441    /// the current tenancy assigned and its fetchers attached.
2442    #[test]
2443    fn fail_for_an_earlier_tenancy_leaves_the_current_one_live() {
2444        let script = Script::default();
2445        let mut d = driver(&script);
2446        let mut s = TestSource::default();
2447        script.push(vec![gained("a", 1, None)]);
2448        poll(&mut d, &mut s);
2449        script.push(vec![CoordinationEvent::Lost {
2450            split: split("a").id,
2451        }]);
2452        poll(&mut d, &mut s);
2453        script.push(vec![gained("a", 2, None)]);
2454        poll(&mut d, &mut s);
2455        let closed = s.closed.clone();
2456        assert_eq!(closed, vec!["a"]);
2457
2458        d.fail(
2459            &mut s,
2460            &SplitId::new("a").unwrap(),
2461            LeaseEpoch(1),
2462            "undecodable object",
2463        )
2464        .unwrap();
2465        assert!(script.fails().is_empty(), "{:?}", script.fails());
2466        assert_eq!(s.closed, closed);
2467        assert_eq!(d.assignments().len(), 1);
2468    }
2469
2470    /// A queued gain-time report is dropped when its split is lost, so it
2471    /// cannot fail the tenancy that regains the split. Regression for #904.
2472    #[test]
2473    fn stale_rejected_gain_report_does_not_fail_a_regained_split() {
2474        let script = Script::default();
2475        let mut d = driver(&script);
2476        let mut s = rejecting(&["a"]);
2477        for _ in 0..3 {
2478            script.fail_next_report("a", CoordinationErrorKind::Retryable);
2479        }
2480        script.push(vec![gained("a", 1, Some(7))]);
2481        d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2482        s.reject_resume.clear();
2483        script.push(vec![CoordinationEvent::Lost {
2484            split: split("a").id,
2485        }]);
2486        script.push(vec![gained("a", 2, Some(7))]);
2487        for _ in 0..5 {
2488            let _ = d.poll_events(&mut s, Duration::ZERO);
2489        }
2490        assert_eq!(
2491            script.fails().len(),
2492            1,
2493            "the stale report is not re-offered"
2494        );
2495        assert_eq!(d.assignments().len(), 1);
2496    }
2497
2498    /// A loss and its regain delivered in one batch drop the queued report
2499    /// before it is re-offered. Regression for #904.
2500    #[test]
2501    fn stale_rejected_gain_report_is_not_offered_when_the_loss_and_regain_share_a_batch() {
2502        let script = Script::default();
2503        let mut d = driver(&script);
2504        let mut s = rejecting(&["a"]);
2505        for _ in 0..3 {
2506            script.fail_next_report("a", CoordinationErrorKind::Retryable);
2507        }
2508        script.push(vec![gained("a", 1, Some(7))]);
2509        d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2510        s.reject_resume.clear();
2511        script.push(vec![
2512            CoordinationEvent::Lost {
2513                split: split("a").id,
2514            },
2515            gained("a", 2, Some(7)),
2516        ]);
2517        for _ in 0..4 {
2518            let _ = d.poll_events(&mut s, Duration::ZERO);
2519        }
2520        assert_eq!(
2521            script.fails().len(),
2522            1,
2523            "the stale report is not re-offered"
2524        );
2525        assert_eq!(d.assignments().len(), 1);
2526    }
2527
2528    /// A gain rejected and lost in one batch leaves no queued report to
2529    /// outlive its split. Regression for #904.
2530    #[test]
2531    fn rejected_gain_report_queued_in_the_batch_that_loses_the_split_is_dropped() {
2532        let script = Script::default();
2533        let mut d = driver(&script);
2534        let mut s = rejecting(&["a"]);
2535        for _ in 0..3 {
2536            script.fail_next_report("a", CoordinationErrorKind::Retryable);
2537        }
2538        script.push(vec![
2539            gained("a", 1, Some(7)),
2540            CoordinationEvent::Lost {
2541                split: split("a").id,
2542            },
2543            gained("a", 2, None),
2544        ]);
2545        let _ = d.poll_events(&mut s, Duration::ZERO);
2546        for _ in 0..4 {
2547            let _ = d.poll_events(&mut s, Duration::ZERO);
2548        }
2549        assert_eq!(
2550            script.fails().len(),
2551            1,
2552            "the stale report is not re-offered"
2553        );
2554        assert_eq!(d.assignments().len(), 1);
2555    }
2556
2557    /// A gain rejected and quarantined in one batch leaves no queued report to
2558    /// outlive its split.
2559    #[test]
2560    fn rejected_gain_report_queued_in_the_batch_that_quarantines_the_split_is_dropped() {
2561        let script = Script::default();
2562        let mut d = driver(&script);
2563        let mut s = rejecting(&["a"]);
2564        for _ in 0..3 {
2565            script.fail_next_report("a", CoordinationErrorKind::Retryable);
2566        }
2567        script.push(vec![
2568            gained("a", 1, Some(7)),
2569            CoordinationEvent::Quarantined {
2570                split: split("a").id,
2571                attempts: 3,
2572            },
2573        ]);
2574        let _ = d.poll_events(&mut s, Duration::ZERO);
2575        for _ in 0..4 {
2576            let _ = d.poll_events(&mut s, Duration::ZERO);
2577        }
2578        assert_eq!(
2579            script.fails().len(),
2580            1,
2581            "the stale report is not re-offered"
2582        );
2583    }
2584
2585    /// A queued gain-time report is dropped when its split is quarantined.
2586    #[test]
2587    fn rejected_gain_report_is_dropped_when_the_split_is_quarantined() {
2588        let script = Script::default();
2589        let mut d = driver(&script);
2590        let mut s = rejecting(&["a"]);
2591        for _ in 0..3 {
2592            script.fail_next_report("a", CoordinationErrorKind::Retryable);
2593        }
2594        script.push(vec![gained("a", 1, Some(7))]);
2595        d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2596        script.push(vec![CoordinationEvent::Quarantined {
2597            split: split("a").id,
2598            attempts: 3,
2599        }]);
2600        for _ in 0..4 {
2601            let _ = d.poll_events(&mut s, Duration::ZERO);
2602        }
2603        assert_eq!(
2604            script.fails().len(),
2605            1,
2606            "the stale report is not re-offered"
2607        );
2608    }
2609
2610    #[test]
2611    fn a_fatal_rejection_survives_an_earlier_retryable_one() {
2612        let script = Script::default();
2613        let mut d = driver(&script);
2614        let mut s = TestSource {
2615            reject_resume: HashMap::from([
2616                ("a".to_string(), ErrorClass::Retryable),
2617                ("b".to_string(), ErrorClass::Fatal),
2618            ]),
2619            ..TestSource::default()
2620        };
2621        script.push(vec![gained("a", 1, Some(7)), gained("b", 1, Some(3))]);
2622
2623        // Surfacing the retryable rejection would run past a stop `b`'s
2624        // source asked for. Both splits are still handed back.
2625        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
2626        assert!(is_fatal(&err), "{err}");
2627        assert!(err.to_string().contains("resume drift on b"), "{err}");
2628        assert_eq!(script.fails().len(), 2);
2629    }
2630
2631    // ------------------------------------------------------------------
2632    // Cooperative revocation: request → drain → commit → release,
2633    // barrier-less; a fence mid-drain aborts through the existing loss path.
2634
2635    #[test]
2636    fn a_drain_keeps_the_tenancy_commit_eligible_until_the_final_commit() {
2637        // The inverse of `late_drain_commit_after_loss_is_skipped`: a lane
2638        // put into `Draining` is *still* commit-eligible, so its acked
2639        // watermarks keep folding to the store right up to the final commit.
2640        let script = Script::default();
2641        let mut d = driver(&script);
2642        let mut s = TestSource {
2643            accept_revoke: HashSet::from(["a".to_string()]),
2644            ..TestSource::default()
2645        };
2646        script.push(vec![gained("a", 1, None)]);
2647        poll(&mut d, &mut s);
2648        let partition = s.opened[0].3;
2649
2650        // The request lands and the source accepts, but the drain is not
2651        // finished (`drain_ready` returns None), so the tenancy stays.
2652        script.push(vec![CoordinationEvent::RevokeRequested {
2653            split: SplitId::new("a").unwrap(),
2654        }]);
2655        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2656        assert_eq!(
2657            s.begin_revoke_calls,
2658            ["a"],
2659            "the source was asked to stop intake"
2660        );
2661
2662        // A tick commit for the draining tenancy must still fold.
2663        d.commit(&mut s, &[(partition, 42)]).unwrap();
2664        assert_eq!(s.encoded, vec![("a".to_string(), 42)]);
2665        assert_eq!(script.commits().len(), 1);
2666        assert_eq!(script.commits()[0].0.as_str(), "a");
2667        assert!(
2668            !script.commits()[0].1.completed,
2669            "a revocation never completes the split"
2670        );
2671        assert!(
2672            script.released_drained().is_empty(),
2673            "not released while the drain is still in flight"
2674        );
2675    }
2676
2677    #[test]
2678    fn a_completed_drain_releases_exactly_one_split_and_retires_barrierless() {
2679        let script = Script::default();
2680        let mut d = driver(&script);
2681        let mut s = TestSource {
2682            accept_revoke: HashSet::from(["a".to_string()]),
2683            ..TestSource::default()
2684        };
2685        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
2686        poll(&mut d, &mut s);
2687
2688        // a's drain has finished (tail acked and committed), so
2689        // `drain_ready` offers the final (non-terminal) progress.
2690        s.ready_progress
2691            .borrow_mut()
2692            .insert("a".into(), SplitProgress::new(50, vec![]));
2693        script.push(vec![CoordinationEvent::RevokeRequested {
2694            split: SplitId::new("a").unwrap(),
2695        }]);
2696
2697        // One poll carries the whole grant: accept, chase the tail, final
2698        // commit, release, and the lane leaves barrier-less.
2699        let event = poll(&mut d, &mut s);
2700        let SourceEvent::LanesRetired { lanes } = event else {
2701            panic!("a cooperative revocation must retire barrier-less, got {event:?}");
2702        };
2703        assert_eq!(lanes, vec![LaneId(0)], "only a's lane leaves");
2704
2705        assert_eq!(
2706            script.released_drained(),
2707            vec![SplitId::new("a").unwrap()],
2708            "exactly one split, released via the revocation path"
2709        );
2710        assert!(script.released().is_empty(), "not a plain hand-back");
2711        // Its final commit is not a completion.
2712        let last = script.commits().last().cloned().unwrap();
2713        assert_eq!(last.0.as_str(), "a");
2714        assert_eq!(last.1.watermark, 50);
2715        assert!(!last.1.completed, "drain commits never complete the split");
2716
2717        // b's live lane is untouched, and no revoke ever follows.
2718        assert_eq!(s.closed, vec!["a"], "b's fetcher stays attached");
2719        assert!(
2720            d.assignments().iter().any(|(id, _)| id.as_str() == "b"),
2721            "b is still owned"
2722        );
2723        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2724    }
2725
2726    #[test]
2727    fn a_fenced_final_commit_aborts_the_drain_into_a_revoke() {
2728        let script = Script::default();
2729        let mut d = driver(&script);
2730        let mut s = TestSource {
2731            accept_revoke: HashSet::from(["a".to_string()]),
2732            ..TestSource::default()
2733        };
2734        script.push(vec![gained("a", 1, None)]);
2735        poll(&mut d, &mut s);
2736        let partition = s.opened[0].3;
2737
2738        // The drain is ready, but a peer fenced this tenancy first,
2739        // so its final commit is rejected.
2740        s.ready_progress
2741            .borrow_mut()
2742            .insert("a".into(), SplitProgress::new(50, vec![]));
2743        script.fail_next_commit("a", CoordinationErrorKind::Fenced);
2744        script.push(vec![CoordinationEvent::RevokeRequested {
2745            split: SplitId::new("a").unwrap(),
2746        }]);
2747
2748        let event = poll(&mut d, &mut s);
2749        let SourceEvent::LanesRevoked { lanes, barrier } = event else {
2750            panic!("a fenced drain must abort into a revoke, got {event:?}");
2751        };
2752        assert_eq!(lanes, vec![LaneId(0)]);
2753        assert_eq!(barrier.remaining(), 1, "one party per revoked lane");
2754        assert!(
2755            script.released_drained().is_empty(),
2756            "a fenced drain never releases"
2757        );
2758        assert!(
2759            script.commits().is_empty(),
2760            "the fenced final commit wrote nothing"
2761        );
2762        assert_eq!(s.closed, vec!["a"], "the fetcher was detached on the abort");
2763
2764        // A late watermark for the now-retired-fenced tenancy is skipped.
2765        d.commit(&mut s, &[(partition, 60)]).unwrap();
2766        assert!(
2767            s.encoded.is_empty(),
2768            "a retired-fenced tenancy must not encode"
2769        );
2770    }
2771
2772    #[test]
2773    fn a_retryable_final_commit_keeps_the_drain_pending() {
2774        let script = Script::default();
2775        let mut d = driver(&script);
2776        let mut s = TestSource {
2777            accept_revoke: HashSet::from(["a".to_string()]),
2778            ..TestSource::default()
2779        };
2780        script.push(vec![gained("a", 1, None)]);
2781        poll(&mut d, &mut s);
2782
2783        // The drain is ready, but the store defers the first final commit.
2784        s.ready_progress
2785            .borrow_mut()
2786            .insert("a".into(), SplitProgress::new(50, vec![]));
2787        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
2788        script.push(vec![CoordinationEvent::RevokeRequested {
2789            split: SplitId::new("a").unwrap(),
2790        }]);
2791
2792        // Deferred: nothing written, nothing released, the split stays owned
2793        // and draining.
2794        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2795        assert!(script.commits().is_empty(), "deferred, not written");
2796        assert!(script.released_drained().is_empty());
2797        assert!(
2798            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
2799            "still owned while the final commit retries"
2800        );
2801
2802        // The next poll re-offers the same tail and the commit lands:
2803        // released and retired barrier-less.
2804        let event = poll(&mut d, &mut s);
2805        let SourceEvent::LanesRetired { lanes } = event else {
2806            panic!("the retried drain must finally retire, got {event:?}");
2807        };
2808        assert_eq!(lanes, vec![LaneId(0)]);
2809        assert_eq!(script.commits().len(), 1);
2810        assert_eq!(script.commits()[0].1.watermark, 50);
2811        assert_eq!(script.released_drained(), vec![SplitId::new("a").unwrap()]);
2812    }
2813
2814    #[test]
2815    fn a_source_that_cannot_stop_intake_declines_the_revoke() {
2816        let script = Script::default();
2817        let mut d = driver(&script);
2818        // Default `accept_revoke` is empty, so `begin_revoke` declines.
2819        let mut s = TestSource::default();
2820        script.push(vec![gained("a", 1, None)]);
2821        poll(&mut d, &mut s);
2822        let partition = s.opened[0].3;
2823
2824        script.push(vec![CoordinationEvent::RevokeRequested {
2825            split: SplitId::new("a").unwrap(),
2826        }]);
2827        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2828
2829        // Asked and declined: the tenancy never enters the drain (no
2830        // `drain_ready` poll) and stays fully live.
2831        assert_eq!(s.begin_revoke_calls, ["a"]);
2832        assert!(
2833            s.drain_ready_calls.is_empty(),
2834            "a declined split never drains"
2835        );
2836        assert!(script.released_drained().is_empty());
2837
2838        // A live tenancy keeps committing as normal.
2839        d.commit(&mut s, &[(partition, 30)]).unwrap();
2840        assert_eq!(s.encoded, vec![("a".to_string(), 30)]);
2841        assert_eq!(script.commits().len(), 1);
2842    }
2843
2844    #[test]
2845    fn a_revoke_request_for_an_unheld_split_is_ignored() {
2846        let script = Script::default();
2847        let mut d = driver(&script);
2848        let mut s = TestSource {
2849            // Even a source that *would* accept is never consulted for a
2850            // split this instance does not hold.
2851            accept_revoke: HashSet::from(["a".to_string(), "ghost".to_string()]),
2852            ..TestSource::default()
2853        };
2854        script.push(vec![gained("a", 1, None)]);
2855        poll(&mut d, &mut s);
2856
2857        script.push(vec![CoordinationEvent::RevokeRequested {
2858            split: SplitId::new("ghost").unwrap(),
2859        }]);
2860        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2861        assert!(
2862            s.begin_revoke_calls.is_empty(),
2863            "an unheld split must not consult the source"
2864        );
2865        assert!(
2866            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
2867            "the held split is untouched"
2868        );
2869    }
2870
2871    #[test]
2872    fn a_source_that_declines_feeds_the_decline_back() {
2873        // Distinct from `a_source_that_cannot_stop_intake_declines_the_revoke`:
2874        // this one proves the refusal reaches the backend, which then forces
2875        // the release.
2876        let script = Script::default();
2877        let mut d = driver(&script);
2878        // Default `accept_revoke` is empty, so `begin_revoke` declines.
2879        let mut s = TestSource::default();
2880        script.push(vec![gained("a", 1, None)]);
2881        poll(&mut d, &mut s);
2882        let partition = s.opened[0].3;
2883
2884        script.push(vec![CoordinationEvent::RevokeRequested {
2885            split: SplitId::new("a").unwrap(),
2886        }]);
2887        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2888
2889        // Asked, refused, and the refusal handed back to the backend exactly
2890        // once, naming the split, so the backend cools down that split only.
2891        assert_eq!(s.begin_revoke_calls, ["a"]);
2892        assert_eq!(
2893            script.declined(),
2894            vec![SplitId::new("a").unwrap()],
2895            "the source's refusal must reach the backend, once"
2896        );
2897
2898        // The tenancy stayed Live: commits still flow.
2899        d.commit(&mut s, &[(partition, 30)]).unwrap();
2900        assert_eq!(s.encoded, vec![("a".to_string(), 30)]);
2901        assert_eq!(script.commits().len(), 1);
2902        assert_eq!(script.commits()[0].0.as_str(), "a");
2903    }
2904
2905    #[test]
2906    fn a_repeated_revoke_request_mid_drain_is_not_declined() {
2907        // Re-emission is reachable: the backend cancels a revocation the
2908        // leader takes back, so a leader can drop a split, restore it, and
2909        // drop it again. A decline would force the release of a split
2910        // draining fine, costing the replay the cooperative path avoids.
2911        let script = Script::default();
2912        let mut d = driver(&script);
2913        let mut s = TestSource {
2914            accept_revoke: HashSet::from(["a".to_string()]),
2915            ..TestSource::default()
2916        };
2917        script.push(vec![gained("a", 1, None)]);
2918        poll(&mut d, &mut s);
2919
2920        // First request: accepted, and the drain does not finish
2921        // (`drain_ready` reports None), so the tenancy stays `Draining`.
2922        script.push(vec![CoordinationEvent::RevokeRequested {
2923            split: SplitId::new("a").unwrap(),
2924        }]);
2925        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2926        assert_eq!(s.begin_revoke_calls, ["a"]);
2927
2928        // Second request for the same, still-draining split.
2929        script.push(vec![CoordinationEvent::RevokeRequested {
2930            split: SplitId::new("a").unwrap(),
2931        }]);
2932        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2933        assert_eq!(
2934            s.begin_revoke_calls,
2935            ["a"],
2936            "the source must not be asked to stop intake it has already stopped"
2937        );
2938        assert!(
2939            script.declined().is_empty(),
2940            "a drain already in flight satisfies the request; declining it would force the release"
2941        );
2942
2943        // And the drain still completes on its own terms.
2944        s.ready_progress
2945            .borrow_mut()
2946            .insert("a".into(), SplitProgress::new(50, vec![]));
2947        let event = poll(&mut d, &mut s);
2948        let SourceEvent::LanesRetired { lanes } = event else {
2949            panic!("the drain must still retire, got {event:?}");
2950        };
2951        assert_eq!(lanes, vec![LaneId(0)]);
2952        assert_eq!(script.released_drained(), vec![SplitId::new("a").unwrap()]);
2953    }
2954
2955    #[test]
2956    fn an_unopened_tenancy_declines_without_asking_the_source() {
2957        // A gain and a revocation request for the same split arrive in one
2958        // event batch: the tenancy exists but its lane has not opened yet.
2959        // The driver declines without consulting the source, and the split
2960        // still opens normally afterwards.
2961        let script = Script::default();
2962        let mut d = driver(&script);
2963        // Even a source that WOULD accept must not be consulted before the
2964        // lane exists.
2965        let mut s = TestSource {
2966            accept_revoke: HashSet::from(["a".to_string()]),
2967            ..TestSource::default()
2968        };
2969
2970        script.push(vec![
2971            gained("a", 1, None),
2972            CoordinationEvent::RevokeRequested {
2973                split: SplitId::new("a").unwrap(),
2974            },
2975        ]);
2976        // Both events are applied (decline included) and then the lane opens,
2977        // all on this one call.
2978        let event = poll(&mut d, &mut s);
2979        let SourceEvent::LanesAdded(lanes) = event else {
2980            panic!("the split must still open after the early decline, got {event:?}");
2981        };
2982        assert_eq!(lanes.len(), 1);
2983
2984        assert!(
2985            s.begin_revoke_calls.is_empty(),
2986            "a not-yet-opened tenancy must never be asked to stop intake"
2987        );
2988        assert_eq!(
2989            script.declined(),
2990            vec![SplitId::new("a").unwrap()],
2991            "the premature request is declined back to the backend"
2992        );
2993
2994        // The split is fully live now: commits flow.
2995        let partition = s.opened[0].3;
2996        d.commit(&mut s, &[(partition, 25)]).unwrap();
2997        assert_eq!(s.encoded, vec![("a".to_string(), 25)]);
2998        assert_eq!(script.commits().len(), 1);
2999        // And it was never released as drained.
3000        assert!(script.released_drained().is_empty());
3001    }
3002
3003    #[test]
3004    fn a_completed_progress_during_a_drain_is_never_terminal() {
3005        // The central guard must strip a `completed` flag reported during a
3006        // drain while still landing the commit (the watermark is acked).
3007        let script = Script::default();
3008        let mut d = driver(&script);
3009        let mut s = TestSource {
3010            accept_revoke: HashSet::from(["a".to_string()]),
3011            ..TestSource::default()
3012        };
3013        script.push(vec![gained("a", 1, None)]);
3014        poll(&mut d, &mut s);
3015        let partition = s.opened[0].3;
3016
3017        // Drive "a" into `Draining` (the drain is not finished yet:
3018        // `drain_ready` returns None).
3019        script.push(vec![CoordinationEvent::RevokeRequested {
3020            split: SplitId::new("a").unwrap(),
3021        }]);
3022        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
3023        assert_eq!(s.begin_revoke_calls, ["a"]);
3024
3025        // A tick commit whose `encode_commit` reports the split COMPLETE at
3026        // this watermark.
3027        s.complete_at.insert("a".into(), 42);
3028        d.commit(&mut s, &[(partition, 42)]).unwrap();
3029
3030        // The commit landed (watermark advanced) but stripped of completion.
3031        let committed = script.commits().last().cloned().expect("the commit landed");
3032        assert_eq!(committed.0.as_str(), "a");
3033        assert_eq!(
3034            committed.1.watermark, 42,
3035            "the guard strips the flag, not the commit"
3036        );
3037        assert!(
3038            !committed.1.completed,
3039            "a drain cut is never terminal, whatever the source claims"
3040        );
3041
3042        // The tenancy is neither completed nor retired: still owned, still
3043        // draining, no lane has left.
3044        assert!(
3045            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
3046            "still owned"
3047        );
3048        assert!(s.closed.is_empty(), "not retired");
3049
3050        // The revocation then finishes normally once the drain completes.
3051        s.ready_progress
3052            .borrow_mut()
3053            .insert("a".into(), SplitProgress::new(50, vec![]));
3054        let event = poll(&mut d, &mut s);
3055        let SourceEvent::LanesRetired { lanes } = event else {
3056            panic!("the drained revocation must finally retire, got {event:?}");
3057        };
3058        assert_eq!(lanes, vec![LaneId(0)]);
3059        assert_eq!(script.released_drained(), vec![SplitId::new("a").unwrap()]);
3060        assert!(
3061            !script.commits().last().unwrap().1.completed,
3062            "the final revocation commit is not terminal either"
3063        );
3064    }
3065
3066    #[test]
3067    fn a_completed_progress_during_a_drain_is_never_terminal_in_the_final_commit() {
3068        let script = Script::default();
3069        let mut d = driver(&script);
3070        let mut s = TestSource {
3071            accept_revoke: HashSet::from(["a".to_string()]),
3072            ..TestSource::default()
3073        };
3074        script.push(vec![gained("a", 1, None)]);
3075        poll(&mut d, &mut s);
3076        let partition = s.opened[0].3;
3077        script.push(vec![CoordinationEvent::RevokeRequested {
3078            split: SplitId::new("a").unwrap(),
3079        }]);
3080        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
3081        assert_eq!(s.begin_revoke_calls, ["a"]);
3082
3083        s.complete_at.insert("a".into(), 42);
3084        let unstored = d.commit_final(&mut s, &[(partition, 42)]).unwrap();
3085
3086        assert!(unstored.is_empty(), "{unstored:?}");
3087        let committed = script.commits().last().cloned().expect("the commit landed");
3088        assert_eq!(committed.0.as_str(), "a");
3089        assert_eq!(committed.1.watermark, 42);
3090        assert!(!committed.1.completed, "a drain cut is never terminal");
3091        assert!(
3092            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
3093            "still owned"
3094        );
3095        assert!(s.closed.is_empty(), "not retired");
3096    }
3097
3098    #[test]
3099    fn commit_final_settles_each_split_and_returns_the_unstored_partitions() {
3100        let script = Script::default();
3101        let mut d = driver(&script);
3102        let mut s = TestSource::default();
3103        script.push(vec![
3104            gained("a", 1, None),
3105            gained("b", 1, None),
3106            gained("c", 1, None),
3107            gained("d", 1, None),
3108        ]);
3109        poll(&mut d, &mut s);
3110        let [pa, pb, pc, pd] = [0, 1, 2, 3].map(|i| s.opened[i].3);
3111        s.complete_at.insert("b".into(), 20);
3112        script.fail_next_commit("c", CoordinationErrorKind::Retryable);
3113        script.fail_next_commit("d", CoordinationErrorKind::Fenced);
3114
3115        let unstored = d
3116            .commit_final(&mut s, &[(pa, 10), (pb, 20), (pc, 30), (pd, 40)])
3117            .unwrap();
3118
3119        assert_eq!(unstored, vec![pc], "only the deferred split is unstored");
3120        let landed: Vec<(String, i64)> = script
3121            .commits()
3122            .iter()
3123            .map(|(id, p)| (id.as_str().to_string(), p.watermark))
3124            .collect();
3125        assert_eq!(landed, [("a".to_string(), 10), ("b".to_string(), 20)]);
3126        let watermark = |p: PartitionId| d.tenancies[&p].progress.as_ref().map(|p| p.watermark);
3127        assert_eq!(watermark(pa), Some(10), "a durable commit folds");
3128        assert!(d.tenancies[&pb].completed, "a completing commit completes");
3129        assert_eq!(watermark(pc), Some(30), "a deferred commit is cached");
3130        assert!(d.tenancies[&pd].fenced, "a fenced commit fences");
3131        assert_eq!(s.closed, vec!["b", "d"], "completed and fenced retire");
3132    }
3133
3134    #[test]
3135    fn a_fatal_final_answer_is_returned_after_the_later_answers_settle() {
3136        let script = Script::default();
3137        let mut d = driver(&script);
3138        let mut s = TestSource::default();
3139        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
3140        poll(&mut d, &mut s);
3141        let [pa, pb] = [0, 1].map(|i| s.opened[i].3);
3142        script.fail_next_commit("a", CoordinationErrorKind::Fatal);
3143        script.fail_next_commit("b", CoordinationErrorKind::Fenced);
3144        let result = d.commit_final(&mut s, &[(pa, 10), (pb, 20)]);
3145        assert!(result.is_err(), "the fatal answer is returned");
3146        assert!(
3147            d.tenancies[&pb].fenced,
3148            "the later fenced answer still settles"
3149        );
3150        assert_eq!(s.closed, vec!["b"]);
3151    }
3152
3153    /// A commit's error names the stop when the stop cut a split's commit
3154    /// short, and not when the store deferred it with no stop begun.
3155    #[test]
3156    fn a_commit_error_names_the_stop_only_when_the_stop_began() {
3157        let script = Script::default();
3158        let mut d = driver(&script);
3159        let mut s = TestSource::default();
3160        let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3161        d.set_stop(crate::source::StopSignal::new(Arc::clone(&flag)));
3162        script.push(vec![gained("a", 1, None)]);
3163        poll(&mut d, &mut s);
3164        let pa = s.opened[0].3;
3165
3166        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
3167        let deferred = d.commit(&mut s, &[(pa, 10)]).expect_err("deferred");
3168        assert!(!deferred.to_string().contains("stop"), "{deferred}");
3169
3170        // A store backend answers a wait the stop cut short as retryable.
3171        script.stop_on_commit("a", &flag);
3172        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
3173        let cut_short = d.commit(&mut s, &[(pa, 10)]).expect_err("cut short");
3174        assert!(
3175            cut_short.to_string().contains("the stop began"),
3176            "{cut_short}"
3177        );
3178    }
3179}