Skip to main content

spate_core/coordination/
driver.rs

1//! Reusable source-side choreography for coordinated sources.
2//!
3//! A coordinated source owns two very different jobs: reading its data
4//! (lanes, fetchers, offsets — connector-specific) and translating
5//! [`CoordinationEvent`]s into the controller's assignment protocol while
6//! keeping fenced-tenancy bookkeeping straight (source-generic, and where
7//! every subtle interleaving bug lives). [`CoordinationDriver`] owns the
8//! second job wholesale: a source embeds one next to a [`SplitSource`]
9//! implementation and delegates `poll_events`/`commit` to it.
10//!
11//! # Tenancies, lanes, partitions
12//!
13//! Every continuous ownership span of a split — from `Gained` to whatever
14//! ends it — is one **tenancy**, and each tenancy gets a fresh, never
15//! reused [`PartitionId`] *and* a fresh, never reused [`LaneId`]: a lane
16//! materializes exactly once, when its tenancy's split is staged for
17//! opening, and lives untouched until the tenancy ends. Gains are
18//! additive ([`SourceEvent::LanesAdded`]) — flowing lanes are never
19//! drained because a peer's split arrived. Watermarks come back keyed by
20//! partition, so a late drain-commit from a lane that lost its split
21//! resolves to a retired tenancy and is skipped — a stale write cannot be
22//! folded, committed, or resurrected by construction.
23//!
24//! # Event choreography
25//!
26//! One controller event per [`poll_events`](CoordinationDriver::poll_events)
27//! call, in priority order:
28//!
29//! 1. Pending losses → partial [`SourceEvent::LanesRevoked`] (barrier
30//!    sized one party per lane, matching the runtime's drain contract).
31//!    Once delivered, the retired tenancies they belonged to have
32//!    absorbed every late watermark they can see and are pruned.
33//! 2. Staged gains → [`SourceEvent::LanesAdded`] with lanes for the
34//!    newly-gained splits only; existing lanes and their in-flight acks
35//!    are untouched.
36//! 3. Otherwise poll the coordinator (the idle wait delegates there),
37//!    fold its events into the tenancy table, sweep for completions, and
38//!    advance any in-flight cooperative revocations (a revoked split, once
39//!    its intake is stopped and its tail acked, takes a final fenced
40//!    commit and is handed back barrier-less).
41//! 4. [`CoordinationEvent::AllComplete`] → [`SourceEvent::Drained`];
42//!    [`CoordinationEvent::Stalled`] → a fatal error by default
43//!    (see [`stall_drains`](CoordinationDriver::stall_drains)).
44//! 5. Nothing staged and some lane newly at end-of-input
45//!    ([`SplitSource::take_finishing`]) → [`SourceEvent::CommitReady`],
46//!    so the runtime chases the final acks instead of waiting out its
47//!    commit tick.
48
49use super::{
50    ControlWaker, CoordinationError, CoordinationErrorKind, CoordinationEvent, LeaseEpoch,
51    SplitCoordinator, SplitId, SplitPlanner, SplitProgress, SplitSpec,
52};
53use crate::error::{ErrorClass, SourceError};
54use crate::record::PartitionId;
55use crate::source::{DrainBarrier, LaneId, SourceEvent, SourceLane};
56use std::collections::BTreeMap;
57use std::fmt;
58use std::time::Duration;
59
60/// Everything the driver hands a source when a split's lane is
61/// materialized — exactly once per tenancy, when the gain is staged into
62/// a [`SourceEvent::LanesAdded`].
63#[derive(Debug)]
64#[non_exhaustive]
65pub struct SplitOpening<'a> {
66    /// The split to read.
67    pub split: &'a SplitSpec,
68    /// Authoritative progress to resume from (already validated via
69    /// [`SplitSource::validate_resume`]); `None` for a fresh split.
70    pub resume: Option<&'a SplitProgress>,
71    /// Lane id minted for this tenancy's lifetime; never reused by this
72    /// source.
73    pub lane: LaneId,
74    /// Stable partition id for this tenancy — the key under which this
75    /// split's watermarks come back to [`CoordinationDriver::commit`].
76    pub partition: PartitionId,
77    /// Fencing token of the current tenancy.
78    pub epoch: LeaseEpoch,
79    /// Wakes the control-plane wait. Clone it into the lane and signal it
80    /// the moment the lane decides end-of-input or reports poison —
81    /// otherwise the driver only notices between waits and the split's
82    /// completion waits out an idle timeout.
83    pub waker: &'a ControlWaker,
84}
85
86/// What the driver needs from the embedding source.
87///
88/// Implement it on the source's lane-assembly context (the sub-struct that
89/// holds what lane construction needs), not on the source itself — the
90/// driver lives beside that context as a sibling field, so both can be
91/// borrowed disjointly.
92pub trait SplitSource {
93    /// The data-plane lane type produced for gained splits.
94    type Lane: SourceLane;
95
96    /// Materialize the lane for a gained (or re-assigned) split. Spawn
97    /// fetchers here; never block on data.
98    fn open_split(&mut self, opening: SplitOpening<'_>) -> Result<Self::Lane, SourceError>;
99
100    /// Drift-check carried progress against this instance's view of the
101    /// split (etag pins, schema versions) before it is trusted. Rejecting
102    /// stops the pipeline — carried progress that no longer matches the
103    /// input is unrecoverable divergence.
104    fn validate_resume(
105        &self,
106        split: &SplitSpec,
107        progress: &SplitProgress,
108    ) -> Result<(), SourceError> {
109        let _ = (split, progress);
110        Ok(())
111    }
112
113    /// Snapshot the split's committable progress at an acked watermark:
114    /// the opaque resume state plus whether that watermark completes the
115    /// split (fully delivered **and** fully acknowledged — the source owns
116    /// its eof/emitted accounting).
117    fn encode_commit(
118        &mut self,
119        split: &SplitId,
120        watermark: i64,
121    ) -> Result<SplitProgress, SourceError>;
122
123    /// Completion sweep for an owned split with no new watermark this
124    /// tick (empty splits; tails acked exactly at the previous commit):
125    /// `Some(terminal progress)` when complete, `None` while data is in
126    /// flight.
127    fn sweep(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError>;
128
129    /// The split's lane is being retired (lost, fenced, completed, or
130    /// shutdown): detach its fetcher — never abort it, the pipeline thread
131    /// may still be draining the lane. Must not block.
132    ///
133    /// This is the end of the tenancy: the driver never calls
134    /// [`SplitSource::encode_commit`] or [`SplitSource::sweep`] for the
135    /// split afterwards (its tenancy is retired first, and retired
136    /// tenancies absorb late watermarks), so the source may drop the
137    /// split's state here.
138    fn close_split(&mut self, split: &SplitId);
139
140    /// Splits whose lanes decided end-of-input since the last call (the
141    /// edge, not the level). The driver surfaces them as
142    /// [`SourceEvent::CommitReady`] so the runtime chases their final acks
143    /// instead of waiting out the commit tick — the split then completes
144    /// (and frees its working-set slot) within milliseconds of its last
145    /// record becoming sink-durable. Purely a latency hint; the default
146    /// reports none.
147    fn take_finishing(&mut self) -> Vec<SplitId> {
148        Vec::new()
149    }
150
151    /// Begin a cooperative revocation of an owned split: stop its intake at a
152    /// safe boundary while **keeping** its commit state, so the tail can
153    /// still be chased to a final fenced commit. Unlike
154    /// [`close_split`](SplitSource::close_split) (which ends the tenancy and
155    /// lets the source drop the split's state), the split stays commit- and
156    /// sweep-adjacent here — the driver keeps committing its acked
157    /// watermarks and then calls [`drain_ready`](SplitSource::drain_ready)
158    /// until the drain finishes.
159    ///
160    /// Return `true` to accept the revocation, `false` to decline it (the
161    /// default). **Contract: return `false` for any split this source has
162    /// not opened or has already closed or completed** — the driver also
163    /// guards this (it declines tenancies without an open lane and feeds
164    /// the decline back to the backend), but the source must not rely on
165    /// that alone.
166    ///
167    /// Declining is safe but not free. The split still leaves — a
168    /// revocation is the leader's decision, not a proposal — so the backend
169    /// forces the release instead and this split's uncommitted tail replays
170    /// under its next owner. (The exception is the backend's to decide, not
171    /// the source's: a revocation the leader takes back before the decline
172    /// lands is cancelled, and then the split stays and keeps being read.)
173    /// A source that *can* stop intake at a safe boundary should, because
174    /// that is the difference between a replay-free move and a
175    /// bounded-duplicate one.
176    ///
177    /// While a split is handing off, [`encode_commit`](SplitSource::encode_commit)
178    /// must never report it `completed` — a drain cut can look terminal to
179    /// the source (everything emitted is acked) but the split is
180    /// half-read; the driver strips a `completed` flag it sees here and
181    /// logs an error, so a conforming source should never trigger it.
182    fn begin_revoke(&mut self, split: &SplitId) -> bool {
183        let _ = split;
184        false
185    }
186
187    /// Poll a handing-off split for its final progress: `Some(progress)`
188    /// with `completed: false` once every record it emitted is acked **and**
189    /// that watermark is committed, so the resume point handed to the next
190    /// owner covers everything this instance produced (a replay-free
191    /// transfer); `None` while any of that tail is still in flight, to be
192    /// retried on the next poll. Never reports `completed` — a revocation gives
193    /// the split away, it does not finish it. The default reports `None`.
194    ///
195    /// **Level-triggered, unlike [`take_finishing`](SplitSource::take_finishing):**
196    /// keep returning `Some` on every poll until the driver retires the
197    /// split — the final store commit can defer on a store hiccup and is
198    /// re-attempted from a fresh `drain_ready` answer, so an
199    /// edge-triggered implementation would stall the drain until the
200    /// backend forced it.
201    ///
202    /// A source that accepts a revocation in
203    /// [`begin_revoke`](SplitSource::begin_revoke) **must** eventually
204    /// answer `Some` here. The default `None` never finishes a drain; it
205    /// pairs with the default `begin_revoke`, which declines.
206    fn drain_ready(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
207        let _ = split;
208        Ok(None)
209    }
210}
211
212#[derive(Debug, PartialEq, Eq, Clone, Copy)]
213enum TenancyState {
214    /// Owned; lane live or staged to open.
215    Live,
216    /// Owned, but intake has stopped at a safe boundary for a cooperative
217    /// revocation: the lane is draining toward one final fenced commit. Still
218    /// commit-eligible (tick commits keep folding its acked watermarks) and
219    /// never swept — a revocation gives the split away rather than completing
220    /// it. Becomes `Retired` (drained, so barrier-less) once that final
221    /// commit lands, or fenced-`Retired` (the loss path) if a peer fences
222    /// it mid-drain.
223    Draining,
224    /// Ownership over (lost, fenced, failed, completed, handed off); entry
225    /// retained only to absorb late watermarks until its revocation is
226    /// delivered.
227    Retired,
228}
229
230#[derive(Debug)]
231struct Tenancy {
232    split: SplitSpec,
233    epoch: LeaseEpoch,
234    lane: Option<LaneId>,
235    state: TenancyState,
236    /// A commit for this tenancy was fenced: fold nothing, commit nothing.
237    fenced: bool,
238    /// Resume cache: the `Gained` carry, then every acked commit fold.
239    /// Acked means sink-durable, so respawning from it can only skip data
240    /// that is already safe — at-least-once holds even when the durable
241    /// store lags a Retryable commit behind.
242    progress: Option<SplitProgress>,
243    /// Terminal progress reached the store; nothing further to commit.
244    completed: bool,
245    /// This tenancy released its split through a cooperative revocation: its
246    /// final commit is durable, so the peer resumes replay-free. Routes the
247    /// lane out through the barrier-less retired path, exactly like
248    /// `completed` (nothing is in flight behind a drained revocation).
249    handed_off: bool,
250}
251
252/// Source-side coordination choreography, embedded by a coordinated
253/// source; see the [module docs](self) for the protocol it implements.
254pub struct CoordinationDriver {
255    coordinator: Box<dyn SplitCoordinator>,
256    /// Parking half of the control-plane wakeup; the waker half is held by
257    /// the backend and by every lane this driver opened.
258    wait: crossbeam_channel::Receiver<()>,
259    waker: ControlWaker,
260    tenancies: BTreeMap<PartitionId, Tenancy>,
261    by_split: BTreeMap<SplitId, PartitionId>,
262    /// Lanes whose loss must still surface as a partial revoke.
263    pending_lost: Vec<LaneId>,
264    /// Lanes of completed tenancies: terminal progress reached the store,
265    /// so they leave without a drain barrier.
266    pending_retired: Vec<LaneId>,
267    /// Tenancies gained but not yet materialized: their lanes go out in
268    /// the next [`SourceEvent::LanesAdded`].
269    pending_open: Vec<PartitionId>,
270    all_complete: bool,
271    stalled: Option<(u64, u64)>,
272    stall_drains: bool,
273    started: bool,
274    next_partition: u32,
275    /// Lane ids are minted once per tenancy and never reused.
276    next_lane: u32,
277}
278
279impl fmt::Debug for CoordinationDriver {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        f.debug_struct("CoordinationDriver")
282            .field("tenancies", &self.tenancies.len())
283            .field("live", &self.by_split.len())
284            .field("pending_lost", &self.pending_lost.len())
285            .field("pending_retired", &self.pending_retired.len())
286            .field("pending_open", &self.pending_open.len())
287            .field("all_complete", &self.all_complete)
288            .field("stalled", &self.stalled)
289            .field("started", &self.started)
290            .finish_non_exhaustive()
291    }
292}
293
294impl CoordinationDriver {
295    /// Wrap a coordinator handle.
296    #[must_use]
297    pub fn new(mut coordinator: Box<dyn SplitCoordinator>) -> CoordinationDriver {
298        let (waker, wait) = super::control_channel();
299        coordinator.set_waker(waker.clone());
300        CoordinationDriver {
301            coordinator,
302            wait,
303            waker,
304            tenancies: BTreeMap::new(),
305            by_split: BTreeMap::new(),
306            pending_lost: Vec::new(),
307            pending_retired: Vec::new(),
308            pending_open: Vec::new(),
309            all_complete: false,
310            stalled: None,
311            stall_drains: false,
312            started: false,
313            next_partition: 0,
314            next_lane: 0,
315        }
316    }
317
318    /// Treat [`CoordinationEvent::Stalled`] as a drain-with-warning
319    /// instead of a fatal error. Default `false`: a bounded job that
320    /// cannot finish because splits are quarantined fails loudly rather
321    /// than exiting as if it were complete.
322    #[must_use]
323    pub fn stall_drains(mut self, drains: bool) -> CoordinationDriver {
324        self.stall_drains = drains;
325        self
326    }
327
328    /// Join the job. Returns the event the source must return from the
329    /// *same* `poll_events` call: the empty `LanesAssigned` ready signal
330    /// (it bumps the controller's assignment epoch and marks the pipeline
331    /// running while splits are still being claimed).
332    pub fn start<L>(
333        &mut self,
334        planner: Box<dyn SplitPlanner>,
335    ) -> Result<SourceEvent<L>, SourceError> {
336        assert!(!self.started, "CoordinationDriver::start called twice");
337        self.coordinator.start(planner).map_err(as_source_error)?;
338        self.started = true;
339        Ok(SourceEvent::LanesAssigned(Vec::new()))
340    }
341
342    /// Coordinated `poll_events` body: surfaces at most one controller
343    /// event per call, per the [module docs](self).
344    pub fn poll_events<S: SplitSource>(
345        &mut self,
346        source: &mut S,
347        timeout: Duration,
348    ) -> Result<SourceEvent<S::Lane>, SourceError> {
349        assert!(self.started, "poll_events before start");
350
351        // Staged work can be consumed without producing an event — a batch
352        // of gains every one of which was retired before it could open — and
353        // then the drain has to look again. Loop rather than recurse: the
354        // fall-through below runs a second `coordinator.poll()`, so under
355        // recursion the depth tracked how many such batches a backend
356        // produced back to back, with nothing structural bounding it.
357        let mut park = timeout;
358        loop {
359            // 1. Losses first: stop lost lanes before anything else runs.
360            if !self.pending_lost.is_empty() {
361                let lanes = std::mem::take(&mut self.pending_lost);
362                let barrier = DrainBarrier::new(lanes.len());
363                return Ok(SourceEvent::LanesRevoked { lanes, barrier });
364            }
365
366            // 1b. Completed tenancies leave barrier-less: their terminal
367            // progress is in the store and nothing is in flight behind them.
368            if !self.pending_retired.is_empty() {
369                let lanes = std::mem::take(&mut self.pending_retired);
370                return Ok(SourceEvent::LanesRetired { lanes });
371            }
372
373            // Reaching here means every queued revocation has been delivered
374            // and the controller has drained + committed those lanes (its
375            // revoke choreography is synchronous), so retired tenancies have
376            // absorbed every late watermark they can ever see. Prune them —
377            // and only them: `Live` tenancies are owned, and `Draining`
378            // ones are still draining toward their final commit and must
379            // survive to be advanced.
380            self.tenancies
381                .retain(|_, t| t.state != TenancyState::Retired);
382
383            // 2. Staged gains: additive lanes for the newly-gained splits
384            // only. Existing lanes are untouched — a routine gain must never
385            // drain flowing lanes.
386            if !self.pending_open.is_empty() {
387                let lanes = self.open_pending(source)?;
388                if !lanes.is_empty() {
389                    return Ok(SourceEvent::LanesAdded(lanes));
390                }
391            }
392
393            // 3. Terminal states, once the choreography above has quiesced.
394            if let Some((completed, quarantined)) = self.stalled {
395                if self.stall_drains {
396                    tracing::warn!(
397                        completed,
398                        quarantined,
399                        "job stalled; draining as configured"
400                    );
401                    return Ok(SourceEvent::Drained);
402                }
403                return Err(SourceError::Client {
404                    class: ErrorClass::Fatal,
405                    reason: format!(
406                        "coordinated job stalled: {completed} splits completed but {quarantined} \
407                     are quarantined and out of delivery attempts; inspect \
408                     spate_coordination_splits_quarantined and requeue or exclude them"
409                    ),
410                });
411            }
412            if self.all_complete {
413                return Ok(SourceEvent::Drained);
414            }
415
416            // 4. Drain the coordinator (never blocks — the wait is ours, at
417            // the end of this function).
418            let events = self.coordinator.poll().map_err(as_source_error)?;
419            for event in events {
420                self.apply(source, event)?;
421            }
422
423            // 5. Completion sweep over live, uncommitted-terminal tenancies.
424            self.sweep(source)?;
425
426            // 5b. Advance in-flight cooperative revocations: chase each
427            // draining split's tail and, once acked and committed, release
428            // it barrier-less to the requesting peer (or, if fenced
429            // mid-drain, abort it through the loss path).
430            self.advance_drains(source)?;
431
432            if !self.pending_lost.is_empty()
433                || !self.pending_retired.is_empty()
434                || !self.pending_open.is_empty()
435                || self.all_complete
436                || self.stalled.is_some()
437            {
438                // Something is staged: go round and surface it on this same
439                // call, without parking on the way.
440                park = Duration::ZERO;
441                continue;
442            }
443            break;
444        }
445
446        // 5. Nothing staged: surface newly-finishing splits so the runtime
447        // chases their final acks instead of waiting out its commit tick.
448        let finishing = source.take_finishing();
449        if !finishing.is_empty() {
450            let partitions: Vec<PartitionId> = finishing
451                .iter()
452                .filter_map(|split| self.by_split.get(split).copied())
453                .collect();
454            if !partitions.is_empty() {
455                return Ok(SourceEvent::CommitReady { partitions });
456            }
457        }
458
459        // 6. Nothing to report: park here, not inside the backend. Both
460        // producers signal the same waker — the backend when it has events,
461        // a lane the moment it decides end-of-input or reports poison — so
462        // a completion surfaces in microseconds instead of waiting out the
463        // remainder of this timeout. The sender half lives on `self`, so
464        // the channel can never disconnect and this can never spin.
465        if !park.is_zero() {
466            let _ = self.wait.recv_timeout(park);
467        }
468        Ok(SourceEvent::Idle)
469    }
470
471    /// Coordinated `commit` body: per-split fenced commits keyed by the
472    /// tenancy partition ids the driver minted.
473    pub fn commit<S: SplitSource>(
474        &mut self,
475        source: &mut S,
476        watermarks: &[(PartitionId, i64)],
477    ) -> Result<(), SourceError> {
478        for &(partition, watermark) in watermarks {
479            let Some(tenancy) = self.tenancies.get(&partition) else {
480                // Pruned tenancy: a drain commit that arrived after its
481                // retirement was fully delivered. Its data replays under
482                // the new owner.
483                continue;
484            };
485            if tenancy.state == TenancyState::Retired || tenancy.fenced || tenancy.completed {
486                continue;
487            }
488            let split = tenancy.split.id.clone();
489            let progress = source.encode_commit(&split, watermark)?;
490            self.commit_progress(source, partition, &split, progress)?;
491        }
492        Ok(())
493    }
494
495    /// Report an owned split as poison: consumes a delivery attempt and
496    /// hands it back for another worker (or quarantine, at the cap). The
497    /// split's lane is retired through the normal loss path.
498    pub fn fail<S: SplitSource>(
499        &mut self,
500        source: &mut S,
501        split: &SplitId,
502        reason: &str,
503    ) -> Result<(), SourceError> {
504        let Some(&partition) = self.by_split.get(split) else {
505            return Ok(()); // already lost — nothing to report
506        };
507        match self.coordinator.fail(split, reason) {
508            Ok(()) => {}
509            // Fenced: someone already took it; the retire below still applies.
510            Err(e) if e.kind == CoordinationErrorKind::Fenced => {}
511            Err(e) => return Err(as_source_error(e)),
512        }
513        self.retire(source, partition, false);
514        Ok(())
515    }
516
517    /// Best-effort graceful release of every held split, so peers claim
518    /// them without waiting out the lease. Call from the source's `Drop`.
519    pub fn release(&mut self) {
520        if !self.started {
521            return;
522        }
523        let held: Vec<SplitId> = self.by_split.keys().cloned().collect();
524        if held.is_empty() {
525            return;
526        }
527        if let Err(e) = self.coordinator.release(&held) {
528            tracing::warn!(error = %e, "graceful split release failed; leases will expire");
529        }
530    }
531
532    /// Current live split → lane view (pause/resume bookkeeping, tests).
533    #[must_use]
534    pub fn assignments(&self) -> Vec<(SplitId, LaneId)> {
535        self.by_split
536            .iter()
537            .filter_map(|(split, partition)| {
538                let lane = self.tenancies.get(partition)?.lane?;
539                Some((split.clone(), lane))
540            })
541            .collect()
542    }
543
544    fn apply<S: SplitSource>(
545        &mut self,
546        source: &mut S,
547        event: CoordinationEvent,
548    ) -> Result<(), SourceError> {
549        match event {
550            CoordinationEvent::Gained {
551                split,
552                epoch,
553                progress,
554            } => {
555                if let Some(&stale) = self.by_split.get(&split.id) {
556                    // Backend contract violation (re-gain without a loss);
557                    // retire the stale tenancy defensively and continue.
558                    tracing::warn!(split = %split.id, "gained a split already held; retiring stale tenancy");
559                    self.retire(source, stale, false);
560                }
561                if let Some(progress) = progress.as_ref() {
562                    source.validate_resume(&split, progress)?;
563                }
564                let partition = PartitionId(self.next_partition);
565                self.next_partition += 1;
566                self.by_split.insert(split.id.clone(), partition);
567                self.tenancies.insert(
568                    partition,
569                    Tenancy {
570                        split,
571                        epoch,
572                        lane: None,
573                        state: TenancyState::Live,
574                        fenced: false,
575                        progress,
576                        completed: false,
577                        handed_off: false,
578                    },
579                );
580                self.pending_open.push(partition);
581            }
582            CoordinationEvent::RevokeRequested { split } => {
583                // The leader wants this split back. Accept only a split we
584                // hold live with an OPEN lane, un-fenced and not yet
585                // completed — a tenancy gained but not yet opened has no
586                // intake to stop and no drain to finish, and accepting it
587                // would strand it in `Draining` forever — and only if the
588                // source can stop its intake at a safe boundary. A refusal
589                // is declined back to the backend so it forces the release
590                // now instead of waiting out its drain deadline; the split
591                // leaves either way.
592                //
593                // A repeat request for a tenancy already draining is
594                // satisfied by the drain in flight, so it is accepted
595                // silently — no second `begin_revoke`, no decline. Declining
596                // would tell the backend to force a handoff that is
597                // progressing fine, costing exactly the replay the
598                // cooperative path exists to avoid. Re-emission is reachable
599                // because the backend cancels a revocation the leader takes
600                // back: the leader can drop a split, restore it, drop it
601                // again.
602                if let Some(&partition) = self.by_split.get(&split)
603                    && self
604                        .tenancies
605                        .get(&partition)
606                        .is_some_and(|t| t.state == TenancyState::Draining && !t.fenced)
607                {
608                    return Ok(());
609                }
610                let accepted = match self.by_split.get(&split) {
611                    Some(&partition) => {
612                        let eligible = self.tenancies.get(&partition).is_some_and(|t| {
613                            t.state == TenancyState::Live
614                                && t.lane.is_some()
615                                && !t.fenced
616                                && !t.completed
617                        });
618                        if eligible && source.begin_revoke(&split) {
619                            // Re-borrow after `begin_revoke`; the guard
620                            // reads above kept no mutable borrow across it.
621                            if let Some(t) = self.tenancies.get_mut(&partition) {
622                                t.state = TenancyState::Draining;
623                            }
624                            true
625                        } else {
626                            false
627                        }
628                    }
629                    None => false,
630                };
631                if !accepted && let Err(e) = self.coordinator.decline_revoke(&split) {
632                    // Liveness cost only: the backend forces the release at
633                    // its own deadline regardless.
634                    tracing::warn!(split = %split, error = %e, "revocation decline failed");
635                }
636            }
637            CoordinationEvent::Lost { split } => {
638                if let Some(&partition) = self.by_split.get(&split) {
639                    self.retire(source, partition, false);
640                }
641                // Else: already retired (e.g. we fenced on commit first).
642            }
643            CoordinationEvent::Quarantined { split, attempts } => {
644                tracing::warn!(split = %split, attempts, "split quarantined");
645                if let Some(&partition) = self.by_split.get(&split) {
646                    self.retire(source, partition, false);
647                }
648            }
649            CoordinationEvent::AllComplete => {
650                // A worker can watch a whole bounded job complete without
651                // ever holding a split: the job finished before this
652                // instance's first rebalance window (short bounded job),
653                // or the fleet simply has more replicas than splits.
654                // Normal either way — but without a line here it reads as
655                // a silent no-op instance, so say what happened.
656                if self.next_partition == 0 {
657                    tracing::info!(
658                        "coordinated job completed without this instance holding any split — \
659                         the job finished before this instance's first rebalance window, or \
660                         the fleet has more replicas than splits (see the scaling-out guide)"
661                    );
662                }
663                self.all_complete = true;
664            }
665            CoordinationEvent::Stalled {
666                completed,
667                quarantined,
668            } => {
669                self.stalled = Some((completed, quarantined));
670            }
671        }
672        Ok(())
673    }
674
675    /// End a tenancy: detach its fetchers, queue its lane for revocation,
676    /// keep the entry to absorb late watermarks until the next assignment.
677    fn retire<S: SplitSource>(&mut self, source: &mut S, partition: PartitionId, fenced: bool) {
678        let Some(tenancy) = self.tenancies.get_mut(&partition) else {
679            return;
680        };
681        if tenancy.state == TenancyState::Retired {
682            if fenced {
683                tenancy.fenced = true;
684            }
685            return;
686        }
687        tenancy.state = TenancyState::Retired;
688        tenancy.fenced |= fenced;
689        self.by_split.remove(&tenancy.split.id);
690        let split = tenancy.split.id.clone();
691        if let Some(lane) = tenancy.lane.take() {
692            if tenancy.completed || tenancy.handed_off {
693                // Fully delivered, acked, and committed — or drained and
694                // handed off with its final commit durable: nothing can be
695                // in flight, so the lane leaves without a drain barrier.
696                self.pending_retired.push(lane);
697            } else {
698                self.pending_lost.push(lane);
699            }
700        }
701        source.close_split(&split);
702    }
703
704    /// Materialize lanes for the staged gains only. Each tenancy opens
705    /// exactly once, with a lane id minted for its lifetime; a staged
706    /// tenancy that ended before it could open (gained then immediately
707    /// lost or fenced) is skipped — its retirement already handled it.
708    ///
709    /// All-or-nothing: a failure part way through undoes the whole batch
710    /// and re-stages it. Anything else strands the lanes already built —
711    /// they never reach the runtime, yet their tenancies stay `Live`
712    /// holding a lane id, which the `lane.is_some()` guard then skips
713    /// forever. The splits would keep their leases, heartbeated and
714    /// unreadable, and the job would stall instead of failing.
715    fn open_pending<S: SplitSource>(
716        &mut self,
717        source: &mut S,
718    ) -> Result<Vec<S::Lane>, SourceError> {
719        let staged = std::mem::take(&mut self.pending_open);
720        let mut lanes = Vec::with_capacity(staged.len());
721        // Tenancies this call minted a lane for, so a failure can undo them.
722        let mut opened: Vec<PartitionId> = Vec::new();
723        for idx in 0..staged.len() {
724            let partition = staged[idx];
725            let Some(tenancy) = self.tenancies.get_mut(&partition) else {
726                continue; // retired and pruned before it could open
727            };
728            if tenancy.state != TenancyState::Live || tenancy.lane.is_some() {
729                continue;
730            }
731            let lane_id = LaneId(self.next_lane);
732            self.next_lane = self
733                .next_lane
734                .checked_add(1)
735                .expect("lane ids exhausted (u32)");
736            tenancy.lane = Some(lane_id);
737            let opening = SplitOpening {
738                split: &tenancy.split,
739                resume: tenancy.progress.as_ref(),
740                lane: lane_id,
741                partition,
742                epoch: tenancy.epoch,
743                waker: &self.waker,
744            };
745            match source.open_split(opening) {
746                Ok(lane) => {
747                    lanes.push(lane);
748                    opened.push(partition);
749                }
750                Err(e) => {
751                    // Dropping the lanes detaches whatever `open_split`
752                    // spawned for them; clearing `lane` lets the retry mint
753                    // a fresh id (ids are burned, never reused).
754                    drop(lanes);
755                    for p in opened.iter().chain(std::iter::once(&partition)) {
756                        if let Some(t) = self.tenancies.get_mut(p) {
757                            t.lane = None;
758                        }
759                    }
760                    self.pending_open = staged;
761                    return Err(e);
762                }
763            }
764        }
765        Ok(lanes)
766    }
767
768    fn sweep<S: SplitSource>(&mut self, source: &mut S) -> Result<(), SourceError> {
769        let candidates: Vec<PartitionId> = self
770            .tenancies
771            .iter()
772            .filter(|(_, t)| t.state == TenancyState::Live && !t.fenced && !t.completed)
773            .map(|(&p, _)| p)
774            .collect();
775        for partition in candidates {
776            let split = self.tenancies[&partition].split.id.clone();
777            if let Some(progress) = source.sweep(&split)? {
778                self.commit_progress(source, partition, &split, progress)?;
779            }
780        }
781        Ok(())
782    }
783
784    /// Advance every in-flight cooperative revocation. For each `Draining`
785    /// tenancy whose drain has finished — [`SplitSource::drain_ready`]
786    /// returns the final progress once its tail is acked and committed —
787    /// take one last fenced commit (never `completed`; a revocation gives the
788    /// split away rather than finishing it) and dispose of it:
789    ///
790    /// - durable → mark drained, hand the split back
791    ///   ([`SplitCoordinator::release_drained`]), and retire it barrier-less;
792    /// - fenced → a peer fenced this tenancy mid-drain; retire it through
793    ///   the loss path (its bounded tail replays under the new owner);
794    /// - deferred → the store lagged; stay `Draining` and re-attempt on
795    ///   the next poll.
796    fn advance_drains<S: SplitSource>(&mut self, source: &mut S) -> Result<(), SourceError> {
797        let draining: Vec<PartitionId> = self
798            .tenancies
799            .iter()
800            .filter(|(_, t)| t.state == TenancyState::Draining && !t.fenced)
801            .map(|(&p, _)| p)
802            .collect();
803        for partition in draining {
804            let split = self.tenancies[&partition].split.id.clone();
805            let Some(progress) = source.drain_ready(&split)? else {
806                continue; // tail still in flight — retry next poll
807            };
808            debug_assert!(
809                !progress.completed,
810                "a revocation commit hands the split off, it must not complete it"
811            );
812            self.commit_drained(source, partition, &split, progress)?;
813        }
814        Ok(())
815    }
816
817    /// The disposition of one fenced commit attempt. Tick, sweep, and
818    /// drain commits triage the backend's three answers identically; only
819    /// what a durable `Ok` *means* differs, so each caller owns just that
820    /// arm and shares the fence/retry handling here.
821    fn try_commit<S: SplitSource>(
822        &mut self,
823        source: &mut S,
824        partition: PartitionId,
825        split: &SplitId,
826        progress: &SplitProgress,
827    ) -> Result<CommitDisposition, SourceError> {
828        match self.coordinator.commit(split, progress) {
829            Ok(()) => Ok(CommitDisposition::Durable),
830            Err(e) if e.kind == CoordinationErrorKind::Fenced => {
831                // Nothing was written; the split belongs to a peer. Retire
832                // with the fence flag so nothing of this tenancy is ever
833                // folded or respawned (the matching Lost event may still
834                // arrive and finds the tenancy already retired).
835                tracing::warn!(split = %split, "commit fenced; split lost to a peer");
836                self.retire(source, partition, true);
837                Ok(CommitDisposition::Fenced)
838            }
839            Err(e) if e.kind == CoordinationErrorKind::Retryable => {
840                // Previous durable state stays authoritative; the caller
841                // recommits the merged progress on the next tick.
842                tracing::warn!(split = %split, error = %e, "commit deferred; will retry");
843                Ok(CommitDisposition::Deferred)
844            }
845            Err(e) => Err(as_source_error(e)),
846        }
847    }
848
849    /// Shared fenced-commit path for tick commits and sweep commits.
850    fn commit_progress<S: SplitSource>(
851        &mut self,
852        source: &mut S,
853        partition: PartitionId,
854        split: &SplitId,
855        progress: SplitProgress,
856    ) -> Result<(), SourceError> {
857        // A handing-off split's drain cut can look terminal to the source
858        // (every record it emitted is acked), but committing it
859        // `completed: true` would mark a half-read split permanently done
860        // and its next owner would never resume it — silent data loss. The
861        // spate-s3 source guards this itself; enforce it centrally so a
862        // conforming third-party source cannot fall into the trap.
863        let progress = if progress.completed
864            && self
865                .tenancies
866                .get(&partition)
867                .is_some_and(|t| t.state == TenancyState::Draining)
868        {
869            tracing::error!(
870                split = %split,
871                "source reported a handing-off split completed; forcing \
872                 completed=false — a drain cut is never terminal"
873            );
874            SplitProgress::new(progress.watermark, progress.state)
875        } else {
876            progress
877        };
878        match self.try_commit(source, partition, split, &progress)? {
879            CommitDisposition::Durable => {
880                let tenancy = self.tenancies.get_mut(&partition).expect("live tenancy");
881                let completed = progress.completed;
882                tenancy.progress = Some(progress);
883                if completed {
884                    tenancy.completed = true;
885                    // A completed split frees its lane for the working set.
886                    self.retire(source, partition, false);
887                }
888            }
889            // Fenced: already retired inside `try_commit`.
890            CommitDisposition::Fenced => {}
891            CommitDisposition::Deferred => {
892                // The resume cache still advances: the watermark is acked
893                // (sink-durable), so respawning past it cannot lose data.
894                let tenancy = self.tenancies.get_mut(&partition).expect("live tenancy");
895                tenancy.progress = Some(progress);
896            }
897        }
898        Ok(())
899    }
900
901    /// Final fenced commit that ends a cooperative revocation. Same triage as
902    /// [`commit_progress`](CoordinationDriver::commit_progress), but a
903    /// durable commit hands the split back and retires it barrier-less
904    /// instead of folding progress into a still-live tenancy.
905    fn commit_drained<S: SplitSource>(
906        &mut self,
907        source: &mut S,
908        partition: PartitionId,
909        split: &SplitId,
910        progress: SplitProgress,
911    ) -> Result<(), SourceError> {
912        // Same central guard as `commit_progress`: the handover progress is
913        // never terminal, whatever the source's `drain_ready` claims.
914        let progress = if progress.completed {
915            tracing::error!(
916                split = %split,
917                "drain_ready returned completed=true; forcing completed=false — \
918                 a drain cut is never terminal"
919            );
920            SplitProgress::new(progress.watermark, progress.state)
921        } else {
922            progress
923        };
924        match self.try_commit(source, partition, split, &progress)? {
925            CommitDisposition::Durable => {
926                // The tail is durable and the resume point now covers every
927                // record this instance emitted. Mark it handed off (so the
928                // lane retires barrier-less) and hand the split back so its
929                // next owner claims replay-free.
930                let tenancy = self
931                    .tenancies
932                    .get_mut(&partition)
933                    .expect("handing-off tenancy");
934                tenancy.progress = Some(progress);
935                tenancy.handed_off = true;
936                if let Err(e) = self
937                    .coordinator
938                    .release_drained(std::slice::from_ref(split))
939                {
940                    // Liveness cost only: the lease expires on its own and a
941                    // peer takes over — no data is at risk. Retire anyway so
942                    // the lane leaves this instance.
943                    tracing::warn!(
944                        split = %split,
945                        error = %e,
946                        "drain release failed; lease will expire and a peer will take over"
947                    );
948                }
949                self.retire(source, partition, false);
950            }
951            // Fenced mid-drain (a peer took this tenancy): already retired
952            // through the loss path inside `try_commit`. Its bounded tail
953            // replays under the new owner.
954            CommitDisposition::Fenced => {}
955            CommitDisposition::Deferred => {
956                // Store lagged: stay `Draining` and re-attempt the final
957                // commit next poll. The cached progress advances the resume
958                // point (the watermark is acked, sink-durable).
959                let tenancy = self
960                    .tenancies
961                    .get_mut(&partition)
962                    .expect("handing-off tenancy");
963                tenancy.progress = Some(progress);
964            }
965        }
966        Ok(())
967    }
968}
969
970/// How the backend answered one fenced commit; see
971/// [`CoordinationDriver::try_commit`].
972enum CommitDisposition {
973    /// Durable write. The caller advances its own state.
974    Durable,
975    /// Fenced: nothing written, the split belongs to a peer; the tenancy
976    /// has already been retired with the fence flag.
977    Fenced,
978    /// Retryable: the previous durable state stays authoritative and the
979    /// caller recommits the merged progress next tick.
980    Deferred,
981}
982
983fn as_source_error(e: CoordinationError) -> SourceError {
984    SourceError::Client {
985        class: e.class(),
986        reason: e.to_string(),
987    }
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use crate::checkpoint::AckRef;
994    use crate::coordination::{PlanContext, PlanFinality, SplitPlan};
995    use crate::record::RawPayload;
996    use crate::source::PayloadBatch;
997    use std::cell::RefCell;
998    use std::collections::{HashMap, HashSet, VecDeque};
999    use std::rc::Rc;
1000    use std::sync::{Arc, Mutex};
1001    use std::time::Instant;
1002
1003    // ------------------------------------------------------------------
1004    // Scripted coordinator double (the shape spate-test later publishes).
1005
1006    #[derive(Default)]
1007    struct ScriptState {
1008        batches: VecDeque<Vec<CoordinationEvent>>,
1009        commit_outcomes: HashMap<String, VecDeque<CoordinationErrorKind>>,
1010        commits: Vec<(SplitId, SplitProgress)>,
1011        fails: Vec<(SplitId, String)>,
1012        released: Vec<SplitId>,
1013        /// Captured separately from `released` so a test can prove the
1014        /// driver takes the revocation-release path, not a plain hand-back.
1015        released_drained: Vec<SplitId>,
1016        /// Every `decline_revoke` call, so a test can prove the driver feeds
1017        /// a refusal back to the backend, which then forces the release.
1018        declined: Vec<SplitId>,
1019        started: bool,
1020        waker: Option<ControlWaker>,
1021    }
1022
1023    #[derive(Clone, Default)]
1024    struct Script(Arc<Mutex<ScriptState>>);
1025
1026    impl Script {
1027        fn push(&self, events: Vec<CoordinationEvent>) {
1028            let mut st = self.0.lock().unwrap();
1029            st.batches.push_back(events);
1030            if let Some(w) = &st.waker {
1031                w.wake();
1032            }
1033        }
1034
1035        fn fail_next_commit(&self, split: &str, kind: CoordinationErrorKind) {
1036            self.0
1037                .lock()
1038                .unwrap()
1039                .commit_outcomes
1040                .entry(split.to_string())
1041                .or_default()
1042                .push_back(kind);
1043        }
1044
1045        fn commits(&self) -> Vec<(SplitId, SplitProgress)> {
1046            self.0.lock().unwrap().commits.clone()
1047        }
1048
1049        fn released(&self) -> Vec<SplitId> {
1050            self.0.lock().unwrap().released.clone()
1051        }
1052
1053        fn released_drained(&self) -> Vec<SplitId> {
1054            self.0.lock().unwrap().released_drained.clone()
1055        }
1056
1057        fn declined(&self) -> Vec<SplitId> {
1058            self.0.lock().unwrap().declined.clone()
1059        }
1060
1061        fn fails(&self) -> Vec<(SplitId, String)> {
1062            self.0.lock().unwrap().fails.clone()
1063        }
1064    }
1065
1066    struct ScriptedCoordinator(Script);
1067
1068    impl SplitCoordinator for ScriptedCoordinator {
1069        fn start(&mut self, _planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
1070            self.0.0.lock().unwrap().started = true;
1071            Ok(())
1072        }
1073
1074        fn set_waker(&mut self, waker: ControlWaker) {
1075            self.0.0.lock().unwrap().waker = Some(waker);
1076        }
1077
1078        fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
1079            Ok(self
1080                .0
1081                .0
1082                .lock()
1083                .unwrap()
1084                .batches
1085                .pop_front()
1086                .unwrap_or_default())
1087        }
1088
1089        fn commit(
1090            &mut self,
1091            split: &SplitId,
1092            progress: &SplitProgress,
1093        ) -> Result<(), CoordinationError> {
1094            let mut s = self.0.0.lock().unwrap();
1095            if let Some(kinds) = s.commit_outcomes.get_mut(split.as_str())
1096                && let Some(kind) = kinds.pop_front()
1097            {
1098                return Err(CoordinationError::new(kind, "scripted"));
1099            }
1100            s.commits.push((split.clone(), progress.clone()));
1101            Ok(())
1102        }
1103
1104        fn fail(&mut self, split: &SplitId, reason: &str) -> Result<(), CoordinationError> {
1105            self.0
1106                .0
1107                .lock()
1108                .unwrap()
1109                .fails
1110                .push((split.clone(), reason.to_string()));
1111            Ok(())
1112        }
1113
1114        fn release(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
1115            self.0
1116                .0
1117                .lock()
1118                .unwrap()
1119                .released
1120                .extend(splits.iter().cloned());
1121            Ok(())
1122        }
1123
1124        fn release_drained(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
1125            self.0
1126                .0
1127                .lock()
1128                .unwrap()
1129                .released_drained
1130                .extend(splits.iter().cloned());
1131            Ok(())
1132        }
1133
1134        fn decline_revoke(&mut self, split: &SplitId) -> Result<(), CoordinationError> {
1135            self.0.0.lock().unwrap().declined.push(split.clone());
1136            Ok(())
1137        }
1138    }
1139
1140    struct NoopPlanner;
1141
1142    impl SplitPlanner for NoopPlanner {
1143        fn fingerprint(&self) -> String {
1144            "test:v1".into()
1145        }
1146
1147        fn plan(&mut self, _ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError> {
1148            Ok(SplitPlan::new(vec![], PlanFinality::Final))
1149        }
1150    }
1151
1152    // ------------------------------------------------------------------
1153    // Stub data plane.
1154
1155    enum NoBatch {}
1156
1157    impl<'buf> PayloadBatch<'buf> for NoBatch {
1158        fn next_payload(&mut self) -> Option<RawPayload<'buf>> {
1159            match *self {}
1160        }
1161
1162        fn ack(&self) -> &AckRef {
1163            match *self {}
1164        }
1165    }
1166
1167    #[derive(Debug)]
1168    struct StubLane {
1169        lane: LaneId,
1170        partition: PartitionId,
1171    }
1172
1173    impl SourceLane for StubLane {
1174        type Batch<'a> = NoBatch;
1175
1176        fn id(&self) -> LaneId {
1177            self.lane
1178        }
1179
1180        fn partition(&self) -> PartitionId {
1181            self.partition
1182        }
1183
1184        fn poll(
1185            &mut self,
1186            _max: usize,
1187            _timeout: Duration,
1188        ) -> Result<Option<NoBatch>, SourceError> {
1189            Ok(None)
1190        }
1191    }
1192
1193    /// Recording SplitSource: every callback is logged; sweep and
1194    /// encode_commit outcomes are scripted per split.
1195    #[derive(Default)]
1196    struct TestSource {
1197        opened: Vec<(String, Option<i64>, LaneId, PartitionId, u64)>,
1198        closed: Vec<String>,
1199        encoded: Vec<(String, i64)>,
1200        sweeps: Rc<RefCell<HashMap<String, SplitProgress>>>,
1201        complete_at: HashMap<String, i64>,
1202        reject_resume: bool,
1203        finishing: Vec<String>,
1204        /// Split ids whose `open_split` fails. Consumed per attempt, so a
1205        /// retry of the same split succeeds.
1206        fail_open: Vec<String>,
1207        /// Split ids for which `begin_revoke` accepts (returns true). Empty
1208        /// by default, so the double declines like the trait default.
1209        accept_revoke: HashSet<String>,
1210        /// Every `begin_revoke` call, in order (accepted or declined).
1211        begin_revoke_calls: Vec<String>,
1212        /// Every `drain_ready` call, in order — proves a split did (or did
1213        /// not) transition to `Draining`.
1214        drain_ready_calls: Vec<String>,
1215        /// Scripted `drain_ready` results, sticky per split (returned on
1216        /// every poll until the tenancy retires), so a retryable final
1217        /// commit can be re-offered the same tail next poll.
1218        ready_progress: Rc<RefCell<HashMap<String, SplitProgress>>>,
1219    }
1220
1221    impl SplitSource for TestSource {
1222        type Lane = StubLane;
1223
1224        fn open_split(&mut self, o: SplitOpening<'_>) -> Result<StubLane, SourceError> {
1225            let id = o.split.id.as_str().to_string();
1226            if let Some(i) = self.fail_open.iter().position(|s| *s == id) {
1227                self.fail_open.remove(i);
1228                return Err(SourceError::Client {
1229                    class: ErrorClass::Retryable,
1230                    reason: format!("open_split failed for {id}"),
1231                });
1232            }
1233            self.opened.push((
1234                o.split.id.as_str().to_string(),
1235                o.resume.map(|p| p.watermark),
1236                o.lane,
1237                o.partition,
1238                o.epoch.0,
1239            ));
1240            Ok(StubLane {
1241                lane: o.lane,
1242                partition: o.partition,
1243            })
1244        }
1245
1246        fn validate_resume(
1247            &self,
1248            split: &SplitSpec,
1249            _progress: &SplitProgress,
1250        ) -> Result<(), SourceError> {
1251            if self.reject_resume {
1252                return Err(SourceError::Client {
1253                    class: ErrorClass::Fatal,
1254                    reason: format!("resume drift on {}", split.id),
1255                });
1256            }
1257            Ok(())
1258        }
1259
1260        fn encode_commit(
1261            &mut self,
1262            split: &SplitId,
1263            watermark: i64,
1264        ) -> Result<SplitProgress, SourceError> {
1265            self.encoded.push((split.as_str().to_string(), watermark));
1266            let completed = self.complete_at.get(split.as_str()) == Some(&watermark);
1267            Ok(if completed {
1268                SplitProgress::completed(watermark, vec![])
1269            } else {
1270                SplitProgress::new(watermark, vec![])
1271            })
1272        }
1273
1274        fn sweep(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
1275            Ok(self.sweeps.borrow_mut().remove(split.as_str()))
1276        }
1277
1278        fn close_split(&mut self, split: &SplitId) {
1279            self.closed.push(split.as_str().to_string());
1280        }
1281
1282        fn take_finishing(&mut self) -> Vec<SplitId> {
1283            std::mem::take(&mut self.finishing)
1284                .into_iter()
1285                .map(|s| SplitId::new(&s).unwrap())
1286                .collect()
1287        }
1288
1289        fn begin_revoke(&mut self, split: &SplitId) -> bool {
1290            self.begin_revoke_calls.push(split.as_str().to_string());
1291            self.accept_revoke.contains(split.as_str())
1292        }
1293
1294        fn drain_ready(&mut self, split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
1295            self.drain_ready_calls.push(split.as_str().to_string());
1296            Ok(self.ready_progress.borrow().get(split.as_str()).cloned())
1297        }
1298    }
1299
1300    // ------------------------------------------------------------------
1301    // Helpers.
1302
1303    fn split(id: &str) -> SplitSpec {
1304        SplitSpec::new(SplitId::new(id).unwrap(), format!("desc:{id}").into_bytes())
1305    }
1306
1307    fn gained(id: &str, epoch: u64, watermark: Option<i64>) -> CoordinationEvent {
1308        CoordinationEvent::Gained {
1309            split: split(id),
1310            epoch: LeaseEpoch(epoch),
1311            progress: watermark.map(|w| SplitProgress::new(w, vec![])),
1312        }
1313    }
1314
1315    fn driver(script: &Script) -> CoordinationDriver {
1316        let mut d = CoordinationDriver::new(Box::new(ScriptedCoordinator(script.clone())));
1317        let ready: SourceEvent<StubLane> = d.start(Box::new(NoopPlanner)).unwrap();
1318        assert!(
1319            matches!(ready, SourceEvent::LanesAssigned(ref lanes) if lanes.is_empty()),
1320            "start must return the empty ready signal"
1321        );
1322        d
1323    }
1324
1325    fn poll(d: &mut CoordinationDriver, s: &mut TestSource) -> SourceEvent<StubLane> {
1326        d.poll_events(s, Duration::ZERO).unwrap()
1327    }
1328
1329    // ------------------------------------------------------------------
1330    // Scenarios (each replays a defect class from the PR #34 review).
1331
1332    #[test]
1333    fn a_signal_cuts_the_control_plane_park_short() {
1334        // The driver owns the control-plane wait precisely so that both
1335        // producers can end it: the backend, and a *lane* deciding
1336        // end-of-input on a pipeline thread. If a `wake()` call site is
1337        // ever dropped, the symptom is silent — completions simply wait out
1338        // an idle timeout again — so assert the park is interruptible
1339        // rather than trusting the wiring.
1340        let script = Script::default();
1341        let mut d = driver(&script);
1342        let mut s = TestSource::default();
1343        let park = Duration::from_millis(400);
1344
1345        // Control: nothing pending and nothing signalling, so the full
1346        // timeout elapses. Without this the test would pass even if
1347        // `poll_events` never parked at all.
1348        let t0 = Instant::now();
1349        assert!(matches!(
1350            d.poll_events(&mut s, park).unwrap(),
1351            SourceEvent::Idle
1352        ));
1353        let idle = t0.elapsed();
1354        assert!(
1355            idle >= park / 2,
1356            "expected a real park, returned after {idle:?}"
1357        );
1358
1359        // A signal landing mid-park ends it. The event itself surfaces on
1360        // the following call — the drain runs at the top of `poll_events` —
1361        // so this asserts the wakeup, not the delivery.
1362        let signaller = script.clone();
1363        let handle = std::thread::spawn(move || {
1364            std::thread::sleep(Duration::from_millis(20));
1365            signaller.push(vec![CoordinationEvent::AllComplete]);
1366        });
1367        let t1 = Instant::now();
1368        let _ = d.poll_events(&mut s, park).unwrap();
1369        let woken = t1.elapsed();
1370        handle.join().unwrap();
1371        assert!(
1372            woken < park / 2,
1373            "a signal must cut the park short, but it ran {woken:?} of {park:?}"
1374        );
1375        assert!(matches!(
1376            d.poll_events(&mut s, Duration::ZERO).unwrap(),
1377            SourceEvent::Drained
1378        ));
1379    }
1380
1381    #[test]
1382    fn a_failed_open_undoes_the_whole_batch_instead_of_stranding_lanes() {
1383        // `open_split` failing part way through must not abandon the lanes
1384        // already built: they never reach the runtime, yet their tenancies
1385        // would keep a lane id, be skipped by the `lane.is_some()` guard on
1386        // every later attempt, and hold their leases — heartbeated,
1387        // unreadable, and a stalled job rather than a failed one.
1388        let script = Script::default();
1389        let mut d = driver(&script);
1390        let mut s = TestSource {
1391            fail_open: vec!["b".into()],
1392            ..TestSource::default()
1393        };
1394
1395        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1396        let err = d
1397            .poll_events(&mut s, Duration::ZERO)
1398            .expect_err("the failing open must surface");
1399        assert!(err.to_string().contains("open_split failed for b"), "{err}");
1400        assert_eq!(s.opened.len(), 1, "a opened before b failed");
1401
1402        // The retry re-stages the whole batch and yields both lanes.
1403        let event = poll(&mut d, &mut s);
1404        let SourceEvent::LanesAdded(lanes) = event else {
1405            panic!("expected both lanes after the retry, got {event:?}");
1406        };
1407        assert_eq!(lanes.len(), 2);
1408        let reopened: Vec<&str> = s.opened.iter().map(|o| o.0.as_str()).collect();
1409        assert_eq!(reopened, ["a", "a", "b"], "a re-opens on the retry");
1410        // The rolled-back ids are burned, never reused.
1411        assert_eq!(lanes[0].id(), LaneId(2));
1412        assert_eq!(lanes[1].id(), LaneId(3));
1413    }
1414
1415    #[test]
1416    fn gains_coalesce_into_one_added_batch() {
1417        let script = Script::default();
1418        let mut d = driver(&script);
1419        let mut s = TestSource::default();
1420
1421        script.push(vec![gained("b", 1, Some(7)), gained("a", 1, None)]);
1422        let event = poll(&mut d, &mut s);
1423        let SourceEvent::LanesAdded(lanes) = event else {
1424            panic!("expected added lanes, got {event:?}");
1425        };
1426        assert_eq!(lanes.len(), 2);
1427        // Lane ids minted in gain order; distinct tenancy partitions.
1428        assert_eq!(s.opened[0].0, "b");
1429        assert_eq!(s.opened[0].2, LaneId(0));
1430        assert_eq!(s.opened[0].1, Some(7), "carried progress reaches open");
1431        assert_eq!(s.opened[1].0, "a");
1432        assert_eq!(s.opened[1].2, LaneId(1));
1433        assert_ne!(s.opened[0].3, s.opened[1].3);
1434        assert_eq!(d.assignments().len(), 2);
1435    }
1436
1437    #[test]
1438    fn a_mid_flow_gain_never_touches_live_lanes_and_their_commits_fold() {
1439        let script = Script::default();
1440        let mut d = driver(&script);
1441        let mut s = TestSource::default();
1442        script.push(vec![gained("a", 1, None)]);
1443        poll(&mut d, &mut s);
1444        let a_partition = s.opened[0].3;
1445
1446        // Split b arrives while a is live and flowing: strictly additive.
1447        script.push(vec![gained("b", 1, None)]);
1448        let event = poll(&mut d, &mut s);
1449        let SourceEvent::LanesAdded(lanes) = event else {
1450            panic!("expected added lanes, got {event:?}");
1451        };
1452        assert_eq!(lanes.len(), 1, "only the new split's lane");
1453        assert!(
1454            s.closed.is_empty(),
1455            "a routine gain must never detach flowing fetchers"
1456        );
1457
1458        // The commit window that killed the pipeline pre-fix: a's acked
1459        // watermark lands right after the gain. It must fold normally.
1460        d.commit(&mut s, &[(a_partition, 42)]).unwrap();
1461        assert_eq!(s.encoded, vec![("a".to_string(), 42)]);
1462        assert_eq!(script.commits().len(), 1);
1463        assert_eq!(script.commits()[0].0.as_str(), "a");
1464        // a's lane is the original — never re-minted by the gain.
1465        assert!(
1466            d.assignments()
1467                .contains(&(SplitId::new("a").unwrap(), LaneId(0)))
1468        );
1469    }
1470
1471    #[test]
1472    fn finishing_splits_surface_as_commit_ready_once() {
1473        let script = Script::default();
1474        let mut d = driver(&script);
1475        let mut s = TestSource::default();
1476        script.push(vec![gained("a", 1, None)]);
1477        poll(&mut d, &mut s);
1478        let a_partition = s.opened[0].3;
1479
1480        s.finishing.push("a".to_string());
1481        let event = poll(&mut d, &mut s);
1482        let SourceEvent::CommitReady { partitions } = event else {
1483            panic!("expected commit-ready, got {event:?}");
1484        };
1485        assert_eq!(partitions, vec![a_partition]);
1486        // Edge, not level: the hint is consumed.
1487        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1488    }
1489
1490    #[test]
1491    fn loss_surfaces_as_partial_revoke_and_detaches_fetchers() {
1492        let script = Script::default();
1493        let mut d = driver(&script);
1494        let mut s = TestSource::default();
1495        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1496        poll(&mut d, &mut s);
1497
1498        script.push(vec![CoordinationEvent::Lost {
1499            split: SplitId::new("a").unwrap(),
1500        }]);
1501        let event = poll(&mut d, &mut s);
1502        let SourceEvent::LanesRevoked { lanes, barrier } = event else {
1503            panic!("expected revoke, got {event:?}");
1504        };
1505        assert_eq!(lanes, vec![LaneId(0)]);
1506        assert_eq!(barrier.remaining(), 1, "one party per revoked lane");
1507        assert_eq!(s.closed, vec!["a"], "fetcher detached on loss");
1508        assert_eq!(d.assignments().len(), 1);
1509    }
1510
1511    #[test]
1512    fn late_drain_commit_after_loss_is_skipped() {
1513        let script = Script::default();
1514        let mut d = driver(&script);
1515        let mut s = TestSource::default();
1516        script.push(vec![gained("a", 1, None)]);
1517        poll(&mut d, &mut s);
1518        let partition = s.opened[0].3;
1519
1520        script.push(vec![CoordinationEvent::Lost {
1521            split: SplitId::new("a").unwrap(),
1522        }]);
1523        poll(&mut d, &mut s);
1524
1525        // The drain hands back a final watermark for the retired tenancy.
1526        d.commit(&mut s, &[(partition, 42)]).unwrap();
1527        assert!(s.encoded.is_empty(), "retired tenancy must not encode");
1528        assert!(script.commits().is_empty(), "and must not commit");
1529    }
1530
1531    #[test]
1532    fn fenced_commit_quarantines_the_tenancy_and_never_respawns_it() {
1533        let script = Script::default();
1534        let mut d = driver(&script);
1535        let mut s = TestSource::default();
1536        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1537        poll(&mut d, &mut s);
1538        let a_partition = s.opened[0].3;
1539        let b_partition = s.opened[1].3;
1540
1541        script.fail_next_commit("a", CoordinationErrorKind::Fenced);
1542        d.commit(&mut s, &[(a_partition, 10), (b_partition, 20)])
1543            .unwrap();
1544        // b committed; a wrote nothing and is retired with the fence flag.
1545        assert_eq!(script.commits().len(), 1);
1546        assert_eq!(script.commits()[0].0.as_str(), "b");
1547        assert_eq!(s.closed, vec!["a"]);
1548
1549        // The fenced lane is revoked...
1550        let event = poll(&mut d, &mut s);
1551        assert!(
1552            matches!(event, SourceEvent::LanesRevoked { ref lanes, .. } if lanes[..] == [LaneId(0)])
1553        );
1554
1555        // ...a late watermark for it is skipped...
1556        s.encoded.clear();
1557        d.commit(&mut s, &[(a_partition, 11)]).unwrap();
1558        assert!(s.encoded.is_empty());
1559
1560        // ...and the mid-cycle Lost that follows the fence is a no-op,
1561        // while a re-gain (higher epoch) starts a fresh tenancy — added
1562        // beside b's untouched live lane, never draining it.
1563        script.push(vec![
1564            CoordinationEvent::Lost {
1565                split: SplitId::new("a").unwrap(),
1566            },
1567            gained("a", 3, Some(10)),
1568        ]);
1569        let event = poll(&mut d, &mut s);
1570        let SourceEvent::LanesAdded(lanes) = event else {
1571            panic!("expected an added lane for the re-gain, got {event:?}");
1572        };
1573        assert_eq!(lanes.len(), 1, "only the fresh tenancy's lane");
1574        assert_eq!(s.closed, vec!["a"], "b's fetcher was never detached");
1575        let a_again = s.opened.last().unwrap();
1576        assert_eq!(a_again.0, "a");
1577        assert_eq!(a_again.4, 3, "fresh tenancy under the new epoch");
1578        assert_ne!(a_again.3, a_partition, "fresh partition — no reuse");
1579        assert_eq!(a_again.2, LaneId(2), "fresh lane id — never reused");
1580    }
1581
1582    #[test]
1583    fn lost_then_regained_in_one_batch_is_a_clean_tenancy_swap() {
1584        let script = Script::default();
1585        let mut d = driver(&script);
1586        let mut s = TestSource::default();
1587        script.push(vec![gained("a", 1, None)]);
1588        poll(&mut d, &mut s);
1589        let first_partition = s.opened[0].3;
1590
1591        script.push(vec![
1592            CoordinationEvent::Lost {
1593                split: SplitId::new("a").unwrap(),
1594            },
1595            gained("a", 2, Some(5)),
1596        ]);
1597        // Loss first (revoke), then the re-gain's lane is added fresh.
1598        let event = poll(&mut d, &mut s);
1599        assert!(matches!(event, SourceEvent::LanesRevoked { .. }));
1600        let event = poll(&mut d, &mut s);
1601        assert!(matches!(event, SourceEvent::LanesAdded(ref l) if l.len() == 1));
1602        let reopened = s.opened.last().unwrap();
1603        assert_eq!(reopened.4, 2);
1604        assert_eq!(reopened.1, Some(5), "resume from the carried progress");
1605        assert_ne!(reopened.3, first_partition);
1606        assert_eq!(reopened.2, LaneId(1), "lane ids are never recycled");
1607    }
1608
1609    #[test]
1610    fn retryable_commit_defers_and_recommits_idempotently() {
1611        let script = Script::default();
1612        let mut d = driver(&script);
1613        let mut s = TestSource::default();
1614        script.push(vec![gained("a", 1, None)]);
1615        poll(&mut d, &mut s);
1616        let partition = s.opened[0].3;
1617
1618        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
1619        d.commit(&mut s, &[(partition, 10)]).unwrap();
1620        assert!(script.commits().is_empty(), "deferred, not written");
1621
1622        // Next tick recommits the merged progress and succeeds.
1623        d.commit(&mut s, &[(partition, 12)]).unwrap();
1624        assert_eq!(script.commits().len(), 1);
1625        assert_eq!(script.commits()[0].1.watermark, 12);
1626    }
1627
1628    #[test]
1629    fn completion_sweep_commits_terminal_progress_and_frees_the_lane() {
1630        let script = Script::default();
1631        let mut d = driver(&script);
1632        let mut s = TestSource::default();
1633        script.push(vec![gained("a", 1, None)]);
1634        poll(&mut d, &mut s);
1635
1636        s.sweeps
1637            .borrow_mut()
1638            .insert("a".into(), SplitProgress::completed(9, vec![]));
1639        // The sweep commits terminal progress and retires the lane; being
1640        // complete (nothing in flight by construction), it leaves
1641        // barrier-less on this same poll (staged-work fastpath).
1642        let event = poll(&mut d, &mut s);
1643        assert!(
1644            matches!(event, SourceEvent::LanesRetired { ref lanes } if lanes[..] == [LaneId(0)]),
1645            "completed lanes retire without a drain barrier, got {event:?}"
1646        );
1647        assert_eq!(script.commits().len(), 1);
1648        assert!(script.commits()[0].1.completed);
1649
1650        // A watermark-carrying commit that completes a split does the same.
1651        script.push(vec![gained("b", 1, None)]);
1652        poll(&mut d, &mut s);
1653        let b_partition = s.opened.last().unwrap().3;
1654        s.complete_at.insert("b".into(), 20);
1655        d.commit(&mut s, &[(b_partition, 20)]).unwrap();
1656        assert!(script.commits().last().unwrap().1.completed);
1657        let event = poll(&mut d, &mut s);
1658        assert!(matches!(event, SourceEvent::LanesRetired { .. }));
1659    }
1660
1661    #[test]
1662    fn standby_with_zero_splits_drains_on_all_complete() {
1663        let script = Script::default();
1664        let mut d = driver(&script);
1665        let mut s = TestSource::default();
1666
1667        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1668        script.push(vec![CoordinationEvent::AllComplete]);
1669        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Drained));
1670        // Idempotent thereafter.
1671        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Drained));
1672    }
1673
1674    #[test]
1675    fn stalled_is_fatal_by_default_and_drains_when_configured() {
1676        let script = Script::default();
1677        let mut d = driver(&script);
1678        let mut s = TestSource::default();
1679        script.push(vec![CoordinationEvent::Stalled {
1680            completed: 7,
1681            quarantined: 1,
1682        }]);
1683        // The stall surfaces on the same call that absorbed the event.
1684        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
1685        assert!(err.to_string().contains("quarantined"), "{err}");
1686
1687        let script = Script::default();
1688        let mut d = CoordinationDriver::new(Box::new(ScriptedCoordinator(script.clone())))
1689            .stall_drains(true);
1690        let _: SourceEvent<StubLane> = d.start(Box::new(NoopPlanner)).unwrap();
1691        script.push(vec![CoordinationEvent::Stalled {
1692            completed: 7,
1693            quarantined: 1,
1694        }]);
1695        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Drained));
1696    }
1697
1698    #[test]
1699    fn fail_reports_poison_and_retires_the_lane() {
1700        let script = Script::default();
1701        let mut d = driver(&script);
1702        let mut s = TestSource::default();
1703        script.push(vec![gained("a", 1, None)]);
1704        poll(&mut d, &mut s);
1705
1706        d.fail(&mut s, &SplitId::new("a").unwrap(), "undecodable object")
1707            .unwrap();
1708        assert_eq!(script.fails().len(), 1);
1709        assert_eq!(s.closed, vec!["a"]);
1710        let event = poll(&mut d, &mut s);
1711        assert!(matches!(event, SourceEvent::LanesRevoked { .. }));
1712        // Failing a split we no longer hold is a quiet no-op.
1713        d.fail(&mut s, &SplitId::new("a").unwrap(), "again")
1714            .unwrap();
1715        assert_eq!(script.fails().len(), 1);
1716    }
1717
1718    #[test]
1719    fn release_hands_back_every_live_split() {
1720        let script = Script::default();
1721        let mut d = driver(&script);
1722        let mut s = TestSource::default();
1723        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1724        poll(&mut d, &mut s);
1725
1726        d.release();
1727        let released = script.released();
1728        assert_eq!(released.len(), 2);
1729        assert!(released.iter().any(|s| s.as_str() == "a"));
1730        assert!(released.iter().any(|s| s.as_str() == "b"));
1731    }
1732
1733    #[test]
1734    fn resume_validation_rejects_drifted_progress() {
1735        let script = Script::default();
1736        let mut d = driver(&script);
1737        let mut s = TestSource {
1738            reject_resume: true,
1739            ..TestSource::default()
1740        };
1741        script.push(vec![gained("a", 1, Some(7))]);
1742        let err = d.poll_events(&mut s, Duration::ZERO).unwrap_err();
1743        assert!(err.to_string().contains("resume drift"), "{err}");
1744    }
1745
1746    // ------------------------------------------------------------------
1747    // Cooperative revocation: request → drain → commit → release,
1748    // barrier-less; a fence mid-drain aborts through the existing loss path.
1749
1750    #[test]
1751    fn a_drain_keeps_the_tenancy_commit_eligible_until_the_final_commit() {
1752        // The inverse of `late_drain_commit_after_loss_is_skipped`: a lane
1753        // put into `Draining` is *still* commit-eligible, so its acked
1754        // watermarks keep folding to the store right up to the final commit.
1755        let script = Script::default();
1756        let mut d = driver(&script);
1757        let mut s = TestSource {
1758            accept_revoke: HashSet::from(["a".to_string()]),
1759            ..TestSource::default()
1760        };
1761        script.push(vec![gained("a", 1, None)]);
1762        poll(&mut d, &mut s);
1763        let partition = s.opened[0].3;
1764
1765        // The request lands and the source accepts, but the drain is not
1766        // finished (`drain_ready` returns None), so the tenancy stays.
1767        script.push(vec![CoordinationEvent::RevokeRequested {
1768            split: SplitId::new("a").unwrap(),
1769        }]);
1770        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1771        assert_eq!(
1772            s.begin_revoke_calls,
1773            ["a"],
1774            "the source was asked to stop intake"
1775        );
1776
1777        // A tick commit for the handing-off tenancy must still fold.
1778        d.commit(&mut s, &[(partition, 42)]).unwrap();
1779        assert_eq!(s.encoded, vec![("a".to_string(), 42)]);
1780        assert_eq!(script.commits().len(), 1);
1781        assert_eq!(script.commits()[0].0.as_str(), "a");
1782        assert!(
1783            !script.commits()[0].1.completed,
1784            "a revocation never completes the split"
1785        );
1786        assert!(
1787            script.released_drained().is_empty(),
1788            "not released while the drain is still in flight"
1789        );
1790    }
1791
1792    #[test]
1793    fn a_completed_drain_releases_exactly_one_split_and_retires_barrierless() {
1794        let script = Script::default();
1795        let mut d = driver(&script);
1796        let mut s = TestSource {
1797            accept_revoke: HashSet::from(["a".to_string()]),
1798            ..TestSource::default()
1799        };
1800        script.push(vec![gained("a", 1, None), gained("b", 1, None)]);
1801        poll(&mut d, &mut s);
1802
1803        // a's drain has finished — tail acked and committed — so
1804        // `drain_ready` offers the final (non-terminal) progress.
1805        s.ready_progress
1806            .borrow_mut()
1807            .insert("a".into(), SplitProgress::new(50, vec![]));
1808        script.push(vec![CoordinationEvent::RevokeRequested {
1809            split: SplitId::new("a").unwrap(),
1810        }]);
1811
1812        // One poll carries the whole grant: accept, chase the tail, final
1813        // commit, release, and the lane leaves barrier-less.
1814        let event = poll(&mut d, &mut s);
1815        let SourceEvent::LanesRetired { lanes } = event else {
1816            panic!("a cooperative revocation must retire barrier-less, got {event:?}");
1817        };
1818        assert_eq!(lanes, vec![LaneId(0)], "only a's lane leaves");
1819
1820        // Exactly the handed-off split, released once, through the revocation
1821        // path (never the plain-release path).
1822        assert_eq!(
1823            script.released_drained(),
1824            vec![SplitId::new("a").unwrap()],
1825            "exactly one split, released via the revocation path"
1826        );
1827        assert!(script.released().is_empty(), "not a plain hand-back");
1828        // Its final commit is not a completion.
1829        let last = script.commits().last().cloned().unwrap();
1830        assert_eq!(last.0.as_str(), "a");
1831        assert_eq!(last.1.watermark, 50);
1832        assert!(!last.1.completed, "drain commits never complete the split");
1833
1834        // b's live lane is untouched, and no revoke ever follows.
1835        assert_eq!(s.closed, vec!["a"], "b's fetcher stays attached");
1836        assert!(
1837            d.assignments().iter().any(|(id, _)| id.as_str() == "b"),
1838            "b is still owned"
1839        );
1840        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1841    }
1842
1843    #[test]
1844    fn a_fenced_final_commit_aborts_the_drain_into_a_revoke() {
1845        let script = Script::default();
1846        let mut d = driver(&script);
1847        let mut s = TestSource {
1848            accept_revoke: HashSet::from(["a".to_string()]),
1849            ..TestSource::default()
1850        };
1851        script.push(vec![gained("a", 1, None)]);
1852        poll(&mut d, &mut s);
1853        let partition = s.opened[0].3;
1854
1855        // The drain is ready, but a peer fenced this tenancy first,
1856        // so its final commit is rejected.
1857        s.ready_progress
1858            .borrow_mut()
1859            .insert("a".into(), SplitProgress::new(50, vec![]));
1860        script.fail_next_commit("a", CoordinationErrorKind::Fenced);
1861        script.push(vec![CoordinationEvent::RevokeRequested {
1862            split: SplitId::new("a").unwrap(),
1863        }]);
1864
1865        // A fenced final commit aborts through the loss path: a *revoke*
1866        // (with a drain barrier), never a barrier-less retire, and no
1867        // release.
1868        let event = poll(&mut d, &mut s);
1869        let SourceEvent::LanesRevoked { lanes, barrier } = event else {
1870            panic!("a fenced drain must abort into a revoke, got {event:?}");
1871        };
1872        assert_eq!(lanes, vec![LaneId(0)]);
1873        assert_eq!(barrier.remaining(), 1, "one party per revoked lane");
1874        assert!(
1875            script.released_drained().is_empty(),
1876            "a fenced drain never releases"
1877        );
1878        assert!(
1879            script.commits().is_empty(),
1880            "the fenced final commit wrote nothing"
1881        );
1882        assert_eq!(s.closed, vec!["a"], "the fetcher was detached on the abort");
1883
1884        // A late watermark for the now-retired-fenced tenancy is skipped.
1885        d.commit(&mut s, &[(partition, 60)]).unwrap();
1886        assert!(
1887            s.encoded.is_empty(),
1888            "a retired-fenced tenancy must not encode"
1889        );
1890    }
1891
1892    #[test]
1893    fn a_retryable_final_commit_keeps_the_drain_pending() {
1894        let script = Script::default();
1895        let mut d = driver(&script);
1896        let mut s = TestSource {
1897            accept_revoke: HashSet::from(["a".to_string()]),
1898            ..TestSource::default()
1899        };
1900        script.push(vec![gained("a", 1, None)]);
1901        poll(&mut d, &mut s);
1902
1903        // The drain is ready, but the store defers the first final commit.
1904        s.ready_progress
1905            .borrow_mut()
1906            .insert("a".into(), SplitProgress::new(50, vec![]));
1907        script.fail_next_commit("a", CoordinationErrorKind::Retryable);
1908        script.push(vec![CoordinationEvent::RevokeRequested {
1909            split: SplitId::new("a").unwrap(),
1910        }]);
1911
1912        // Deferred: nothing written, nothing released, the split stays owned
1913        // and handing off.
1914        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1915        assert!(script.commits().is_empty(), "deferred, not written");
1916        assert!(script.released_drained().is_empty());
1917        assert!(
1918            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
1919            "still owned while the final commit retries"
1920        );
1921
1922        // The next poll re-offers the same tail and the commit lands:
1923        // released and retired barrier-less.
1924        let event = poll(&mut d, &mut s);
1925        let SourceEvent::LanesRetired { lanes } = event else {
1926            panic!("the retried drain must finally retire, got {event:?}");
1927        };
1928        assert_eq!(lanes, vec![LaneId(0)]);
1929        assert_eq!(script.commits().len(), 1);
1930        assert_eq!(script.commits()[0].1.watermark, 50);
1931        assert_eq!(script.released_drained(), vec![SplitId::new("a").unwrap()]);
1932    }
1933
1934    #[test]
1935    fn a_source_that_cannot_stop_intake_declines_the_revoke() {
1936        let script = Script::default();
1937        let mut d = driver(&script);
1938        // Default `accept_revoke` is empty, so `begin_revoke` declines.
1939        let mut s = TestSource::default();
1940        script.push(vec![gained("a", 1, None)]);
1941        poll(&mut d, &mut s);
1942        let partition = s.opened[0].3;
1943
1944        script.push(vec![CoordinationEvent::RevokeRequested {
1945            split: SplitId::new("a").unwrap(),
1946        }]);
1947        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1948
1949        // Asked and declined: the tenancy never enters the drain (no
1950        // `drain_ready` poll) and stays fully live.
1951        assert_eq!(s.begin_revoke_calls, ["a"]);
1952        assert!(
1953            s.drain_ready_calls.is_empty(),
1954            "a declined split never drains"
1955        );
1956        assert!(script.released_drained().is_empty());
1957
1958        // A live tenancy keeps committing as normal.
1959        d.commit(&mut s, &[(partition, 30)]).unwrap();
1960        assert_eq!(s.encoded, vec![("a".to_string(), 30)]);
1961        assert_eq!(script.commits().len(), 1);
1962    }
1963
1964    #[test]
1965    fn a_revoke_request_for_an_unheld_split_is_ignored() {
1966        let script = Script::default();
1967        let mut d = driver(&script);
1968        let mut s = TestSource {
1969            // Even a source that *would* accept is never consulted for a
1970            // split this instance does not hold.
1971            accept_revoke: HashSet::from(["a".to_string(), "ghost".to_string()]),
1972            ..TestSource::default()
1973        };
1974        script.push(vec![gained("a", 1, None)]);
1975        poll(&mut d, &mut s);
1976
1977        script.push(vec![CoordinationEvent::RevokeRequested {
1978            split: SplitId::new("ghost").unwrap(),
1979        }]);
1980        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
1981        assert!(
1982            s.begin_revoke_calls.is_empty(),
1983            "an unheld split must not consult the source"
1984        );
1985        assert!(
1986            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
1987            "the held split is untouched"
1988        );
1989    }
1990
1991    #[test]
1992    fn a_source_that_declines_feeds_the_decline_back() {
1993        // `a_source_that_cannot_stop_intake_declines_the_revoke` proves the
1994        // tenancy stays live; this one proves the refusal is fed BACK to the
1995        // backend, which forces the release immediately instead of waiting
1996        // out its drain deadline.
1997        let script = Script::default();
1998        let mut d = driver(&script);
1999        // Default `accept_revoke` is empty, so `begin_revoke` declines.
2000        let mut s = TestSource::default();
2001        script.push(vec![gained("a", 1, None)]);
2002        poll(&mut d, &mut s);
2003        let partition = s.opened[0].3;
2004
2005        script.push(vec![CoordinationEvent::RevokeRequested {
2006            split: SplitId::new("a").unwrap(),
2007        }]);
2008        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2009
2010        // Asked, refused, and the refusal handed back to the backend exactly
2011        // once — naming the split, so the backend cools down that split only.
2012        assert_eq!(s.begin_revoke_calls, ["a"]);
2013        assert_eq!(
2014            script.declined(),
2015            vec![SplitId::new("a").unwrap()],
2016            "the source's refusal must reach the backend, once"
2017        );
2018
2019        // The tenancy stayed Live: commits still flow.
2020        d.commit(&mut s, &[(partition, 30)]).unwrap();
2021        assert_eq!(s.encoded, vec![("a".to_string(), 30)]);
2022        assert_eq!(script.commits().len(), 1);
2023        assert_eq!(script.commits()[0].0.as_str(), "a");
2024    }
2025
2026    #[test]
2027    fn a_repeated_revoke_request_mid_drain_is_not_declined() {
2028        // `RevokeRequested` is documented idempotent, and re-emission is real:
2029        // the backend cancels a revocation the leader takes back, so a leader
2030        // can drop a split, restore it, and drop it again. Answering the
2031        // second request with a decline would tell the backend to force a
2032        // handoff that is draining fine — the replay the cooperative path
2033        // exists to avoid.
2034        let script = Script::default();
2035        let mut d = driver(&script);
2036        let mut s = TestSource {
2037            accept_revoke: HashSet::from(["a".to_string()]),
2038            ..TestSource::default()
2039        };
2040        script.push(vec![gained("a", 1, None)]);
2041        poll(&mut d, &mut s);
2042
2043        // First request: accepted, and the drain does not finish
2044        // (`drain_ready` reports None), so the tenancy stays `Draining`.
2045        script.push(vec![CoordinationEvent::RevokeRequested {
2046            split: SplitId::new("a").unwrap(),
2047        }]);
2048        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2049        assert_eq!(s.begin_revoke_calls, ["a"]);
2050
2051        // Second request for the same, still-draining split.
2052        script.push(vec![CoordinationEvent::RevokeRequested {
2053            split: SplitId::new("a").unwrap(),
2054        }]);
2055        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2056        assert_eq!(
2057            s.begin_revoke_calls,
2058            ["a"],
2059            "the source must not be asked to stop intake it has already stopped"
2060        );
2061        assert!(
2062            script.declined().is_empty(),
2063            "a drain already in flight satisfies the request; declining it would force the release"
2064        );
2065
2066        // And the drain still completes on its own terms.
2067        s.ready_progress
2068            .borrow_mut()
2069            .insert("a".into(), SplitProgress::new(50, vec![]));
2070        let event = poll(&mut d, &mut s);
2071        let SourceEvent::LanesRetired { lanes } = event else {
2072            panic!("the drain must still retire, got {event:?}");
2073        };
2074        assert_eq!(lanes, vec![LaneId(0)]);
2075        assert_eq!(script.released_drained(), vec![SplitId::new("a").unwrap()]);
2076    }
2077
2078    #[test]
2079    fn an_unopened_tenancy_declines_without_asking_the_source() {
2080        // A gain and a revocation request for the same split arrive in one event
2081        // batch: the tenancy exists but its lane has not opened yet. Accepting
2082        // it would strand it in `Draining` forever (no intake to stop, no
2083        // drain to finish), so the driver declines WITHOUT consulting the
2084        // source, and the split still opens normally afterwards.
2085        let script = Script::default();
2086        let mut d = driver(&script);
2087        // Even a source that WOULD accept must not be consulted before the
2088        // lane exists.
2089        let mut s = TestSource {
2090            accept_revoke: HashSet::from(["a".to_string()]),
2091            ..TestSource::default()
2092        };
2093
2094        script.push(vec![
2095            gained("a", 1, None),
2096            CoordinationEvent::RevokeRequested {
2097                split: SplitId::new("a").unwrap(),
2098            },
2099        ]);
2100        // Both events are applied (decline included) and then the lane opens,
2101        // all on this one call.
2102        let event = poll(&mut d, &mut s);
2103        let SourceEvent::LanesAdded(lanes) = event else {
2104            panic!("the split must still open after the early decline, got {event:?}");
2105        };
2106        assert_eq!(lanes.len(), 1);
2107
2108        assert!(
2109            s.begin_revoke_calls.is_empty(),
2110            "a not-yet-opened tenancy must never be asked to stop intake"
2111        );
2112        assert_eq!(
2113            script.declined(),
2114            vec![SplitId::new("a").unwrap()],
2115            "the premature request is declined back to the backend"
2116        );
2117
2118        // The split is fully live now: commits flow.
2119        let partition = s.opened[0].3;
2120        d.commit(&mut s, &[(partition, 25)]).unwrap();
2121        assert_eq!(s.encoded, vec![("a".to_string(), 25)]);
2122        assert_eq!(script.commits().len(), 1);
2123        // And it was never actually handed off.
2124        assert!(script.released_drained().is_empty());
2125    }
2126
2127    #[test]
2128    fn a_completed_progress_during_a_drain_is_never_terminal() {
2129        // A handing-off split's drain cut can look terminal to the source
2130        // (every record it emitted is acked), but committing it
2131        // `completed: true` would mark a half-read split permanently done and
2132        // its next owner would never resume it. The central guard must strip
2133        // the flag — while still landing the commit (the watermark is acked).
2134        let script = Script::default();
2135        let mut d = driver(&script);
2136        let mut s = TestSource {
2137            accept_revoke: HashSet::from(["a".to_string()]),
2138            ..TestSource::default()
2139        };
2140        script.push(vec![gained("a", 1, None)]);
2141        poll(&mut d, &mut s);
2142        let partition = s.opened[0].3;
2143
2144        // Drive "a" into `Draining` (the drain is not finished yet:
2145        // `drain_ready` returns None).
2146        script.push(vec![CoordinationEvent::RevokeRequested {
2147            split: SplitId::new("a").unwrap(),
2148        }]);
2149        assert!(matches!(poll(&mut d, &mut s), SourceEvent::Idle));
2150        assert_eq!(s.begin_revoke_calls, ["a"]);
2151
2152        // A tick commit whose `encode_commit` reports the split COMPLETE at
2153        // this watermark.
2154        s.complete_at.insert("a".into(), 42);
2155        d.commit(&mut s, &[(partition, 42)]).unwrap();
2156
2157        // The commit landed (watermark advanced) but stripped of completion.
2158        let committed = script.commits().last().cloned().expect("the commit landed");
2159        assert_eq!(committed.0.as_str(), "a");
2160        assert_eq!(
2161            committed.1.watermark, 42,
2162            "the guard strips the flag, not the commit"
2163        );
2164        assert!(
2165            !committed.1.completed,
2166            "a drain cut is never terminal, whatever the source claims"
2167        );
2168
2169        // The tenancy is neither completed nor retired: still owned, still
2170        // handing off, no lane has left.
2171        assert!(
2172            d.assignments().iter().any(|(id, _)| id.as_str() == "a"),
2173            "still owned"
2174        );
2175        assert!(s.closed.is_empty(), "not retired");
2176
2177        // The revocation then finishes normally once the drain completes.
2178        s.ready_progress
2179            .borrow_mut()
2180            .insert("a".into(), SplitProgress::new(50, vec![]));
2181        let event = poll(&mut d, &mut s);
2182        let SourceEvent::LanesRetired { lanes } = event else {
2183            panic!("the drained revocation must finally retire, got {event:?}");
2184        };
2185        assert_eq!(lanes, vec![LaneId(0)]);
2186        assert_eq!(script.released_drained(), vec![SplitId::new("a").unwrap()]);
2187        assert!(
2188            !script.commits().last().unwrap().1.completed,
2189            "the final revocation commit is not terminal either"
2190        );
2191    }
2192}