Skip to main content

spate_core/coordination/
mod.rs

1//! Split coordination: the seam that lets several pipeline instances divide
2//! one broker-less source's work without duplicates.
3//!
4//! Brokered sources bring their own coordination (a Kafka consumer group
5//! assigns partitions across processes). Broker-less sources — object-store
6//! backfills, database range scans, file tails — have no group protocol:
7//! two processes pointed at the same input each see all of it. This module
8//! closes that gap with a leader-assigned model:
9//!
10//! - A source-provided [`SplitPlanner`] enumerates the work as weighted
11//!   **splits** (an object-store backfill: object lists bin-packed by
12//!   bytes; a database source: balanced id ranges). The planner runs only
13//!   on the fleet's elected leader — workers receive split descriptors and
14//!   never re-enumerate.
15//! - The leader also computes a **desired assignment** and publishes it
16//!   per instance. Every worker holds a [`SplitCoordinator`] that leases
17//!   the splits it was assigned, heartbeats them in the background, and
18//!   cooperatively drains any it is no longer assigned. A dead owner's
19//!   leases expire and its work is reassigned.
20//! - Progress commits are **fenced**: a commit from an instance that no
21//!   longer owns the split is rejected and writes nothing, so committed
22//!   progress can only replay, never regress.
23//!
24//! `spate-core` owns only the seam — the two traits and their handshake
25//! types — plus the reusable source-side driver
26//! ([`CoordinationDriver`](driver::CoordinationDriver)). Concrete backends
27//! live in backend crates (the NATS JetStream KV backend in
28//! `spate-coordination`); a source receives its coordinator at
29//! pipeline-assembly time, mirroring the framing seam
30//! ([`RecordFramer`](crate::framing::RecordFramer)).
31//!
32//! # Delivery contract
33//!
34//! Coordination preserves at-least-once, nothing stronger. Ownership
35//! revocations and takeovers may briefly overlap — a taken-over split's uncommitted tail is
36//! replayed by the new owner, and a zombie may emit records after its
37//! lease was seized. Both produce **duplicates, never loss**: the
38//! correctness boundary is [`SplitCoordinator::commit`], a fenced durable
39//! write that a backend must reject once the caller no longer owns the
40//! split ([`CoordinationErrorKind::Fenced`] — and a fenced write must
41//! write nothing).
42//!
43//! A split that repeatedly kills its owners (or is explicitly
44//! [`fail`](SplitCoordinator::fail)ed) is **quarantined** after a bounded
45//! number of attempts rather than crashing workers forever. Quarantined
46//! splits stay visible and block [`CoordinationEvent::AllComplete`]: a
47//! bounded job whose planned data went unprocessed finishes as
48//! [`CoordinationEvent::Stalled`], never as a false success.
49//!
50//! # Threading
51//!
52//! Like [`Source`](crate::source::Source), a coordinator is driven
53//! synchronously from the pipeline's controller thread. Implementations
54//! own their I/O (typically a background task on the runtime handle they
55//! were built with), must bound every call, and must never rely on being
56//! polled to keep a lease alive — renewal runs in the background so
57//! backpressure cannot cost ownership.
58
59use crate::error::ErrorClass;
60use std::fmt;
61
62pub mod driver;
63
64/// Maximum length of a [`SplitId`] in bytes.
65pub const SPLIT_ID_MAX_LEN: usize = 128;
66
67/// Deterministic identity of one split, minted by the planner.
68///
69/// Stable across replans of unchanged work — the property that makes
70/// replanning idempotent (create-if-absent in the store) and progress
71/// resumable across owners. Validated at construction so key-encoding
72/// problems surface as [`Fatal`](CoordinationErrorKind::Fatal) here, not
73/// as opaque backend write failures: 1–128 bytes of `[A-Za-z0-9_-]`
74/// (`.` is reserved as the store's key-hierarchy separator).
75#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
76pub struct SplitId(String);
77
78impl SplitId {
79    /// Validate and wrap a split id.
80    ///
81    /// # Errors
82    ///
83    /// [`Fatal`](CoordinationErrorKind::Fatal) when empty, longer than
84    /// [`SPLIT_ID_MAX_LEN`], or containing anything outside
85    /// `[A-Za-z0-9_-]`.
86    pub fn new(id: impl Into<String>) -> Result<SplitId, CoordinationError> {
87        let id = id.into();
88        if id.is_empty() || id.len() > SPLIT_ID_MAX_LEN {
89            return Err(CoordinationError::new(
90                CoordinationErrorKind::Fatal,
91                format!(
92                    "split id must be 1..={SPLIT_ID_MAX_LEN} bytes, got {} ({id:?})",
93                    id.len()
94                ),
95            ));
96        }
97        if let Some(bad) = id
98            .chars()
99            .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
100        {
101            return Err(CoordinationError::new(
102                CoordinationErrorKind::Fatal,
103                format!("split id may only contain [A-Za-z0-9_-], got {bad:?} in {id:?}"),
104            ));
105        }
106        Ok(SplitId(id))
107    }
108
109    /// The id as a string slice.
110    #[must_use]
111    pub fn as_str(&self) -> &str {
112        &self.0
113    }
114}
115
116impl fmt::Display for SplitId {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(&self.0)
119    }
120}
121
122/// Monotonic fencing token for one split's lease, minted by the backend.
123/// Strictly increases across ownership changes of that split; a write
124/// presented under a superseded epoch is rejected.
125///
126/// Distinct from the checkpointer's *assignment* epoch (a per-process
127/// counter over [`SourceEvent::LanesAssigned`](crate::source::SourceEvent)
128/// cycles) and from the planner's *generation* (which orders leaders): a
129/// `LeaseEpoch` orders owners of one split **across processes**.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
131pub struct LeaseEpoch(pub u64);
132
133/// One unit of leasable work, as the planner enumerated it.
134///
135/// The descriptor is carried verbatim to whichever worker gains the split,
136/// so workers never re-enumerate the input (one LIST/scan per plan, on the
137/// leader). It is opaque to the framework and to backends; keep it small —
138/// backends enforce a size cap (the NATS backend: a fixed 512 KiB per
139/// stored value). Descriptors are written once at planning and never
140/// rewritten by commits, so their size never taxes the commit path.
141#[derive(Clone, Debug, PartialEq, Eq)]
142#[non_exhaustive]
143pub struct SplitSpec {
144    /// Deterministic identity, stable across replans of unchanged work.
145    pub id: SplitId,
146    /// Source-defined payload: everything a worker needs to process the
147    /// split (an object-store source: member keys with etags and sizes; a
148    /// database source: an id range).
149    pub descriptor: Vec<u8>,
150    /// Relative cost hint (bytes, rows) — **the balance objective**.
151    ///
152    /// This is load, not sort order. Under the work-stealing balancer this
153    /// field only ordered claims and balance was on split *count*; a
154    /// planner that populated it as a ranking key rather than a cost will
155    /// now skew the leader's distribution.
156    ///
157    /// The
158    /// leader distributes summed weight, not split count, so a planner
159    /// that emits wildly uneven splits (an object-store planner gives any
160    /// object at or above its packing target a split to itself) still
161    /// balances correctly. A planner that leaves every weight at the
162    /// default degrades to count-balancing, which is the right behaviour
163    /// when splits really are uniform. `0` is treated as `1`.
164    pub weight: u64,
165}
166
167impl SplitSpec {
168    /// A split with the default weight of 1.
169    #[must_use]
170    pub fn new(id: SplitId, descriptor: Vec<u8>) -> SplitSpec {
171        SplitSpec {
172            id,
173            descriptor,
174            weight: 1,
175        }
176    }
177
178    /// Set the relative cost hint.
179    #[must_use]
180    pub fn with_weight(mut self, weight: u64) -> SplitSpec {
181        self.weight = weight;
182        self
183    }
184}
185
186/// A split as the planner submits it: the spec plus optional seed
187/// progress. Seeds are first-writer-wins — a seed never overwrites an
188/// existing record — which is the migration path for work that already has
189/// a pre-coordination checkpoint. A brand-new source plans without seeds.
190#[derive(Clone, Debug, PartialEq, Eq)]
191#[non_exhaustive]
192pub struct PlannedSplit {
193    /// The split itself.
194    pub spec: SplitSpec,
195    /// Durable progress to seed if (and only if) the split has none.
196    pub seed: Option<SplitProgress>,
197}
198
199impl PlannedSplit {
200    /// A split with no seed progress.
201    #[must_use]
202    pub fn new(spec: SplitSpec) -> PlannedSplit {
203        PlannedSplit { spec, seed: None }
204    }
205
206    /// Attach seed progress (first writer wins).
207    #[must_use]
208    pub fn with_seed(mut self, seed: SplitProgress) -> PlannedSplit {
209        self.seed = Some(seed);
210        self
211    }
212}
213
214/// Whether a plan's enumeration can still grow.
215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub enum PlanFinality {
217    /// More work may appear; the leader re-runs the planner on its replan
218    /// interval and [`CoordinationEvent::AllComplete`] never fires.
219    Open,
220    /// The enumeration is complete and final. Once every split is
221    /// completed the job reports [`CoordinationEvent::AllComplete`] (or
222    /// [`CoordinationEvent::Stalled`] if any were quarantined).
223    Final,
224}
225
226/// One planner run's output.
227#[derive(Clone, Debug, PartialEq, Eq)]
228#[non_exhaustive]
229pub struct SplitPlan {
230    /// The enumerated work. On a replan, splits already planned are
231    /// deduplicated by id (create-if-absent) — emitting them again is a
232    /// cheap no-op, which is what makes replanning idempotent.
233    pub splits: Vec<PlannedSplit>,
234    /// Whether this enumeration is complete.
235    pub finality: PlanFinality,
236    /// Opaque planner cursor persisted in the plan record and handed back
237    /// on the next run via [`PlanContext::planner_state`] (e.g. an
238    /// object-store listing's start-after key). `None` keeps the previous
239    /// cursor.
240    pub planner_state: Option<Vec<u8>>,
241}
242
243impl SplitPlan {
244    /// A plan with no cursor update.
245    #[must_use]
246    pub fn new(splits: Vec<PlannedSplit>, finality: PlanFinality) -> SplitPlan {
247        SplitPlan {
248            splits,
249            finality,
250            planner_state: None,
251        }
252    }
253
254    /// Persist a planner cursor for the next run.
255    #[must_use]
256    pub fn with_planner_state(mut self, state: Vec<u8>) -> SplitPlan {
257        self.planner_state = Some(state);
258        self
259    }
260}
261
262/// What the backend hands the planner on each run.
263#[derive(Debug)]
264#[non_exhaustive]
265pub struct PlanContext<'a> {
266    /// The cursor persisted by the previous run's
267    /// [`SplitPlan::planner_state`]; `None` on the first ever plan.
268    pub planner_state: Option<&'a [u8]>,
269    /// The current plan generation: bumped on every leader failover, so a
270    /// planner can tell a fresh leadership from a continuation.
271    pub generation: u64,
272}
273
274impl<'a> PlanContext<'a> {
275    /// Build a context (backends construct this; sources only read it).
276    #[must_use]
277    pub fn new(planner_state: Option<&'a [u8]>, generation: u64) -> PlanContext<'a> {
278        PlanContext {
279            planner_state,
280            generation,
281        }
282    }
283}
284
285/// One split's durable progress, written through the fenced
286/// [`commit`](SplitCoordinator::commit) and handed to the next owner in
287/// [`CoordinationEvent::Gained`].
288///
289/// The `state` payload is opaque to the framework **and** to backends —
290/// arbitrary bytes, no encoding constraint. The source owns its schema;
291/// keeping it opaque is what keeps connector types out of `spate-core`'s
292/// public API.
293#[derive(Clone, Debug, PartialEq, Eq)]
294#[non_exhaustive]
295pub struct SplitProgress {
296    /// Committable watermark: one past the last acknowledged record, in
297    /// the source's own offset encoding. Backends enforce that it never
298    /// decreases across a split's committed history.
299    pub watermark: i64,
300    /// Source-defined opaque resume state.
301    pub state: Vec<u8>,
302    /// Bounded jobs: this split is fully delivered and committed.
303    /// Terminal — a backend never re-offers a completed split.
304    pub completed: bool,
305}
306
307impl SplitProgress {
308    /// In-progress split state (`completed: false`).
309    #[must_use]
310    pub fn new(watermark: i64, state: Vec<u8>) -> SplitProgress {
311        SplitProgress {
312            watermark,
313            state,
314            completed: false,
315        }
316    }
317
318    /// Terminal split state: fully delivered and committed.
319    #[must_use]
320    pub fn completed(watermark: i64, state: Vec<u8>) -> SplitProgress {
321        SplitProgress {
322            watermark,
323            state,
324            completed: true,
325        }
326    }
327}
328
329/// Ownership or job-state change surfaced by [`SplitCoordinator::poll`].
330///
331/// Events for one split are ordered: a split is `Gained` before it can be
332/// `Lost`, and a re-`Gained` split always carries a higher [`LeaseEpoch`]
333/// than the tenancy it replaces.
334#[derive(Debug)]
335#[non_exhaustive]
336pub enum CoordinationEvent {
337    /// This instance now holds the lease for the split and must start (or
338    /// resume) processing it.
339    Gained {
340        /// The split now owned, descriptor included — the gaining worker
341        /// never saw the planner run.
342        split: SplitSpec,
343        /// Fencing token for this tenancy.
344        epoch: LeaseEpoch,
345        /// The last fenced-committed progress to resume from. `None` for
346        /// a split that has never committed.
347        progress: Option<SplitProgress>,
348    },
349    /// The lease was lost — seized by a peer after expiry, stolen for
350    /// balance, or self-fenced after renewals could not reach the backend.
351    /// The source must stop the split promptly and must not commit it
352    /// again (a late commit is rejected as
353    /// [`CoordinationErrorKind::Fenced`] regardless — this event is the
354    /// cooperative fast path).
355    Lost {
356        /// The split no longer owned.
357        split: SplitId,
358    },
359    /// The leader has stopped assigning this split to this instance, and
360    /// wants it back. The owner should stop intake at a safe boundary,
361    /// chase the split's tail to a final fenced commit, and release it —
362    /// the next owner then resumes from a point covering everything this
363    /// one emitted, so the transfer replays nothing.
364    ///
365    /// **The split is leaving either way.** Unlike the peer request this
366    /// replaced, a revocation is a decision rather than a proposal: a
367    /// source that declines — through the driver's
368    /// [`SplitSource::begin_revoke`](driver::SplitSource::begin_revoke),
369    /// which defaults to declining — or that does not finish inside
370    /// `drain_deadline` has the release forced instead, and its
371    /// uncommitted tail replays under the next owner. Declining is
372    /// therefore still *safe*; it is just the expensive way to comply.
373    ///
374    /// The one exception is not the source's to take: a backend may cancel a
375    /// revocation its leader took back — the split is named for this instance
376    /// again while it still holds it — and then nothing is forced. A source
377    /// cannot observe that and must not wait for it. It also changes little
378    /// for a source that already accepted: intake stays stopped, the drain
379    /// still ends by handing the split back, and this instance is simply the
380    /// one that gains it again, through a fresh
381    /// [`Gained`](CoordinationEvent::Gained) with a new lane. Only a source
382    /// that *declined* keeps the split without interruption.
383    ///
384    /// Idempotent: the event may be re-emitted for a split already
385    /// draining, and a revocation for a split this instance does not hold
386    /// is a silent no-op.
387    RevokeRequested {
388        /// The split to give up.
389        split: SplitId,
390    },
391    /// The split exhausted its delivery attempts (repeated owner deaths or
392    /// explicit [`fail`](SplitCoordinator::fail) reports) and was parked.
393    /// It will not be re-offered; it stays visible in the store and in the
394    /// `spate_coordination_splits_quarantined` gauge, and it blocks
395    /// [`AllComplete`](CoordinationEvent::AllComplete).
396    Quarantined {
397        /// The parked split.
398        split: SplitId,
399        /// Delivery attempts consumed.
400        attempts: u32,
401    },
402    /// Final plan and every split committed `completed`. Bounded sources
403    /// translate this into
404    /// [`SourceEvent::Drained`](crate::source::SourceEvent::Drained). An
405    /// instance that owns no splits must keep polling until this arrives —
406    /// it is the standby that covers an owner dying at the finish line.
407    AllComplete,
408    /// Final plan, nothing left runnable or running, but quarantined
409    /// splits remain: the job cannot finish cleanly. Surfaced to every
410    /// instance exactly where `AllComplete` would have been. The source
411    /// decides whether this is fatal (the default in the driver) or a
412    /// drain-with-warning.
413    Stalled {
414        /// Splits that completed.
415        completed: u64,
416        /// Splits parked in quarantine.
417        quarantined: u64,
418    },
419}
420
421/// Why a coordination operation failed.
422#[derive(Clone, Copy, Debug, PartialEq, Eq)]
423#[non_exhaustive]
424pub enum CoordinationErrorKind {
425    /// The fenced write lost: this instance no longer owns the split and
426    /// **nothing was written**. Not a pipeline error — callers intercept
427    /// it and treat the split as lost (the matching
428    /// [`CoordinationEvent::Lost`] follows from
429    /// [`poll`](SplitCoordinator::poll)).
430    Fenced,
431    /// Transient backend failure; the operation may succeed if retried on
432    /// the caller's next tick. Backends keep renewing owned leases through
433    /// caller-visible retryable failures.
434    Retryable,
435    /// Unrecoverable: backend misconfiguration, a store without the
436    /// required atomic primitives, a corrupt or incompatible record, a
437    /// diverging job fingerprint. The pipeline must stop.
438    Fatal,
439}
440
441/// Error from a coordination backend.
442#[derive(Debug, thiserror::Error)]
443#[error("coordination error ({kind:?}): {reason}")]
444#[non_exhaustive]
445pub struct CoordinationError {
446    /// How the caller must react; see [`CoordinationErrorKind`].
447    pub kind: CoordinationErrorKind,
448    /// Human-readable cause.
449    pub reason: String,
450}
451
452impl CoordinationError {
453    /// Build an error. Backends live outside this crate, so construction
454    /// goes through this constructor (the struct is `#[non_exhaustive]`).
455    pub fn new(kind: CoordinationErrorKind, reason: impl Into<String>) -> CoordinationError {
456        CoordinationError {
457            kind,
458            reason: reason.into(),
459        }
460    }
461
462    /// Map to the framework error taxonomy.
463    /// [`Fenced`](CoordinationErrorKind::Fenced) maps to [`Fatal`](ErrorClass::Fatal)
464    /// only as a backstop — callers are expected to intercept it before
465    /// classification and handle the split loss instead of failing.
466    #[must_use]
467    pub fn class(&self) -> ErrorClass {
468        match self.kind {
469            CoordinationErrorKind::Retryable => ErrorClass::Retryable,
470            CoordinationErrorKind::Fenced | CoordinationErrorKind::Fatal => ErrorClass::Fatal,
471        }
472    }
473}
474
475/// Source-provided work enumerator, run only on the fleet's elected
476/// leader — at job start and again on the replan interval while the plan
477/// is [`Open`](PlanFinality::Open).
478///
479/// Every worker presents an equivalent planner at
480/// [`SplitCoordinator::start`]; whichever instance holds leadership uses
481/// its own copy. `plan` may block on real I/O (an object-store LIST, an
482/// index query) — backends call it off their async loop.
483pub trait SplitPlanner: Send {
484    /// Cheap, deterministic job identity, derived from *configuration*,
485    /// never from the enumeration itself. Every worker joining the job
486    /// must present a byte-equal fingerprint or be rejected as
487    /// [`Fatal`](CoordinationErrorKind::Fatal) — divergent configurations
488    /// can never interpret the same split table two ways.
489    fn fingerprint(&self) -> String;
490
491    /// Enumerate the current work. Idempotency contract: unchanged work
492    /// yields the same [`SplitId`]s, so re-emitting already-planned splits
493    /// is a cheap store-side no-op.
494    fn plan(&mut self, ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError>;
495}
496
497/// Per-worker coordination handle: leases splits toward a bounded working
498/// set, surfaces ownership changes, and owns the fenced progress commit.
499///
500/// Dyn-compatible; sources hold a `Box<dyn SplitCoordinator>` injected at
501/// assembly time (typically via the
502/// [`CoordinationDriver`](driver::CoordinationDriver) rather than
503/// directly). Driven from the controller thread — implementations do their
504/// I/O elsewhere and bound every call.
505///
506/// ```
507/// use spate_core::coordination::{
508///     ControlWaker, CoordinationError, CoordinationEvent, PlanContext, PlanFinality,
509///     PlannedSplit, SplitCoordinator, SplitId, SplitPlan, SplitPlanner, SplitProgress,
510///     SplitSpec, LeaseEpoch,
511/// };
512/// use std::collections::BTreeMap;
513/// use std::time::Duration;
514///
515/// /// A trivial single-instance backend: plans immediately, grants every
516/// /// split to the one worker, keeps progress in memory. Real backends
517/// /// persist through fenced CAS writes; this shape is only the seam's
518/// /// contract in miniature.
519/// #[derive(Default)]
520/// struct LocalCoordinator {
521///     pending: Vec<CoordinationEvent>,
522///     committed: BTreeMap<SplitId, SplitProgress>,
523///     total: usize,
524/// }
525///
526/// impl SplitCoordinator for LocalCoordinator {
527///     fn start(&mut self, mut planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
528///         let plan = planner.plan(PlanContext::new(None, 1))?;
529///         assert!(matches!(plan.finality, PlanFinality::Final));
530///         self.total = plan.splits.len();
531///         for planned in plan.splits {
532///             let progress = planned.seed.clone();
533///             self.pending.push(CoordinationEvent::Gained {
534///                 split: planned.spec,
535///                 epoch: LeaseEpoch(1),
536///                 progress,
537///             });
538///         }
539///         Ok(())
540///     }
541///
542///     fn set_waker(&mut self, _w: ControlWaker) {}
543///
544///     fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
545///         let mut events = std::mem::take(&mut self.pending);
546///         if self.total > 0
547///             && self.committed.len() == self.total
548///             && self.committed.values().all(|p| p.completed)
549///         {
550///             self.total = 0; // fire AllComplete once
551///             events.push(CoordinationEvent::AllComplete);
552///         }
553///         Ok(events)
554///     }
555///
556///     fn commit(&mut self, s: &SplitId, p: &SplitProgress) -> Result<(), CoordinationError> {
557///         self.committed.insert(s.clone(), p.clone());
558///         Ok(())
559///     }
560///
561///     fn fail(&mut self, _s: &SplitId, _r: &str) -> Result<(), CoordinationError> {
562///         Ok(())
563///     }
564///
565///     fn release(&mut self, _s: &[SplitId]) -> Result<(), CoordinationError> {
566///         Ok(())
567///     }
568/// }
569///
570/// struct TwoSplits;
571/// impl SplitPlanner for TwoSplits {
572///     fn fingerprint(&self) -> String {
573///         "example:v1".into()
574///     }
575///     fn plan(&mut self, _ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError> {
576///         let splits = ["a", "b"]
577///             .into_iter()
578///             .map(|id| {
579///                 Ok(PlannedSplit::new(SplitSpec::new(
580///                     SplitId::new(id)?,
581///                     format!("range:{id}").into_bytes(),
582///                 )))
583///             })
584///             .collect::<Result<_, CoordinationError>>()?;
585///         Ok(SplitPlan::new(splits, PlanFinality::Final))
586///     }
587/// }
588///
589/// let mut c: Box<dyn SplitCoordinator> = Box::new(LocalCoordinator::default());
590/// c.start(Box::new(TwoSplits)).unwrap();
591/// let gained = c.poll().unwrap();
592/// assert_eq!(gained.len(), 2);
593/// for id in ["a", "b"] {
594///     let id = SplitId::new(id).unwrap();
595///     c.commit(&id, &SplitProgress::completed(10, vec![])).unwrap();
596/// }
597/// assert!(matches!(
598///     c.poll().unwrap().last(),
599///     Some(CoordinationEvent::AllComplete)
600/// ));
601/// ```
602pub trait SplitCoordinator: Send {
603    /// Join the job: verify the fingerprint, hand over this worker's
604    /// planner (used only if and while this instance is elected leader),
605    /// and start the backend's claim and renewal machinery. Called exactly
606    /// once, before any other method.
607    fn start(&mut self, planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError>;
608
609    /// Hand the backend the handle it signals when it has events to
610    /// deliver. Called once, before [`start`](SplitCoordinator::start).
611    ///
612    /// The control-plane wait lives in
613    /// [`CoordinationDriver`](driver::CoordinationDriver), not here,
614    /// because completions arrive from two directions the backend cannot
615    /// see between them: the backend's own machinery, and the *lanes*
616    /// reaching end-of-input on pipeline threads. A backend that parks
617    /// internally cannot be woken by the second, which is why
618    /// [`poll`](SplitCoordinator::poll) does not block. Signal this waker
619    /// whenever a later `poll` would return something.
620    fn set_waker(&mut self, waker: ControlWaker);
621
622    /// Ownership and job-state changes since the last call. **Must not
623    /// block** — return whatever is pending, including nothing. The driver
624    /// parks on the [`ControlWaker`] instead.
625    fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError>;
626
627    /// Fenced durable commit of one owned split's progress. `Ok` means
628    /// durable. [`Fenced`](CoordinationErrorKind::Fenced) means the split
629    /// is no longer owned and **nothing was written** — stop the split,
630    /// never retry the write. [`Retryable`](CoordinationErrorKind::Retryable)
631    /// leaves the previous committed state authoritative; re-committing
632    /// the merged progress on the next tick is idempotent.
633    ///
634    /// A commit on a split this instance no longer holds — including one
635    /// it already committed `completed` — returns `Fenced` without a
636    /// following [`Lost`](CoordinationEvent::Lost) event: `Lost` marks an
637    /// involuntary end of a live tenancy, and there is none. (The
638    /// [`CoordinationDriver`](driver::CoordinationDriver) never issues
639    /// such commits; hand-rolled callers must tolerate the error.)
640    fn commit(
641        &mut self,
642        split: &SplitId,
643        progress: &SplitProgress,
644    ) -> Result<(), CoordinationError>;
645
646    /// Report an owned split as unprocessable *by this tenancy*: consumes
647    /// one delivery attempt and releases it for another worker to retry.
648    /// At the backend's attempt cap the split is quarantined instead
649    /// ([`CoordinationEvent::Quarantined`]). Use for poison input; a
650    /// transient local problem is better handled by
651    /// [`release`](SplitCoordinator::release), which consumes nothing.
652    fn fail(&mut self, split: &SplitId, reason: &str) -> Result<(), CoordinationError>;
653
654    /// Voluntarily hand back owned splits (shutdown, scale-down) so peers
655    /// claim them without waiting out a lease. Consumes no delivery
656    /// attempts. Best-effort and idempotent; splits not released simply
657    /// expire.
658    fn release(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError>;
659
660    /// Release splits given up through a cooperative revocation — the owner
661    /// has drained each split, committed its tail, and is now handing it
662    /// back. Semantically a [`release`](SplitCoordinator::release)
663    /// (attempt-free, best-effort, idempotent), but distinguished so a
664    /// revocation-aware backend can record the drain as having completed
665    /// and never mistake a single-split revocation for a departure from the
666    /// fleet.
667    ///
668    /// Defaulted to [`release`](SplitCoordinator::release) so existing
669    /// backends keep working — the hand-back then reads as an ordinary one
670    /// — and so the trait stays dyn-compatible. A backend that implements
671    /// the revocation protocol overrides it.
672    fn release_drained(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
673        self.release(splits)
674    }
675
676    /// Decline a [`RevokeRequested`](CoordinationEvent::RevokeRequested)
677    /// the embedder cannot serve — the source refused to stop the split's
678    /// intake, or the split is not in a drainable state.
679    ///
680    /// **A decline does not keep the split.** It reports that the *clean*
681    /// path is unavailable, so the backend stops waiting and takes the
682    /// expensive one immediately instead of holding the rebalance open
683    /// until its deadline; the split still leaves, and its uncommitted tail
684    /// replays under the next owner. Best-effort and idempotent; declining
685    /// a split that was never revoked is a no-op. The one case where a
686    /// decline does keep the split is not the source's doing: the backend
687    /// had already cancelled that revocation, so there is nothing left to
688    /// comply with.
689    ///
690    /// **Backend obligation.** A backend that emits `RevokeRequested` must
691    /// bound what it started, whatever the source does: force the release
692    /// on a decline, and bound a drain that never finishes with a deadline
693    /// of its own. Without that, one uncooperative source pins the fleet's
694    /// rebalancing open forever — and, worse, a source that *did* stop
695    /// intake for a drain that then wedges is left holding a split it will
696    /// never read again, since nothing can ask it to resume. A backend that
697    /// withdraws a revocation therefore does not get to drop that second
698    /// obligation with it. (`spate-coordination` spells the deadline
699    /// `drain_deadline`, and applies it to a withdrawn revocation's drain
700    /// as a no-progress timeout rather than an absolute one.)
701    ///
702    /// Defaulted to a no-op so existing backends keep working and the
703    /// trait stays dyn-compatible.
704    fn decline_revoke(&mut self, _split: &SplitId) -> Result<(), CoordinationError> {
705        Ok(())
706    }
707}
708
709/// Wakes a coordinated source's control-plane wait.
710///
711/// Cheap to clone and safe to signal from any thread — including a
712/// pipeline thread on the data path, because [`wake`](ControlWaker::wake)
713/// never blocks. The channel behind it holds a single slot, so a burst of
714/// signals collapses into one wakeup, and a signal that lands while the
715/// driver is between its check and its park is buffered rather than lost.
716///
717/// Signal it for anything the driver would otherwise only notice between
718/// waits: a backend with events ready, a lane reaching end-of-input, a
719/// lane reporting poison.
720#[derive(Clone, Debug)]
721pub struct ControlWaker(crossbeam_channel::Sender<()>);
722
723impl ControlWaker {
724    /// A waker attached to nothing: [`wake`](ControlWaker::wake) is a
725    /// no-op. For unit tests that construct a lane without a driver, and
726    /// for sources that have no control-plane park to interrupt.
727    #[must_use]
728    pub fn inert() -> ControlWaker {
729        let (tx, rx) = crossbeam_channel::bounded(1);
730        drop(rx);
731        ControlWaker(tx)
732    }
733
734    /// Wake the driver if it is parked, or make its next park return
735    /// immediately. Never blocks.
736    pub fn wake(&self) {
737        // Full slot means a wakeup is already pending — nothing to add.
738        let _ = self.0.try_send(());
739    }
740}
741
742/// The waker and the parking half the driver owns.
743pub(crate) fn control_channel() -> (ControlWaker, crossbeam_channel::Receiver<()>) {
744    let (tx, rx) = crossbeam_channel::bounded(1);
745    (ControlWaker(tx), rx)
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    struct NoopCoordinator;
753
754    impl SplitCoordinator for NoopCoordinator {
755        fn start(&mut self, _planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
756            Ok(())
757        }
758
759        fn set_waker(&mut self, _waker: ControlWaker) {}
760
761        fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
762            Ok(vec![])
763        }
764
765        fn commit(
766            &mut self,
767            split: &SplitId,
768            _progress: &SplitProgress,
769        ) -> Result<(), CoordinationError> {
770            Err(CoordinationError::new(
771                CoordinationErrorKind::Fenced,
772                format!("split {split} is owned by a peer at epoch 2"),
773            ))
774        }
775
776        fn fail(&mut self, _split: &SplitId, _reason: &str) -> Result<(), CoordinationError> {
777            Ok(())
778        }
779
780        fn release(&mut self, _splits: &[SplitId]) -> Result<(), CoordinationError> {
781            Ok(())
782        }
783    }
784
785    struct NoopPlanner;
786
787    impl SplitPlanner for NoopPlanner {
788        fn fingerprint(&self) -> String {
789            "noop:v1".into()
790        }
791
792        fn plan(&mut self, ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError> {
793            assert!(ctx.planner_state.is_none());
794            Ok(SplitPlan::new(vec![], PlanFinality::Final))
795        }
796    }
797
798    #[test]
799    fn split_coordinator_is_object_safe() {
800        // Compiles only if both traits are dyn-compatible (the seam's contract).
801        let mut c: Box<dyn SplitCoordinator> = Box::new(NoopCoordinator);
802        c.start(Box::new(NoopPlanner)).unwrap();
803        assert!(c.poll().unwrap().is_empty());
804        c.release(&[SplitId::new("s-0").unwrap()]).unwrap();
805        // The defaulted revocation release delegates to `release`, staying
806        // dyn-compatible and callable through the trait object.
807        c.release_drained(&[SplitId::new("s-0").unwrap()]).unwrap();
808    }
809
810    #[test]
811    fn split_ids_validate_charset_and_length() {
812        assert_eq!(
813            SplitId::new("rows-000000-000125").unwrap().as_str(),
814            "rows-000000-000125"
815        );
816        assert_eq!(SplitId::new("A_z9").unwrap().to_string(), "A_z9");
817        for bad in [
818            "",
819            "a.b",
820            "a b",
821            "a/b",
822            "å",
823            &"x".repeat(SPLIT_ID_MAX_LEN + 1),
824        ] {
825            let err = SplitId::new(bad).unwrap_err();
826            assert_eq!(err.kind, CoordinationErrorKind::Fatal, "{bad:?}");
827        }
828        assert!(SplitId::new("x".repeat(SPLIT_ID_MAX_LEN)).is_ok());
829    }
830
831    #[test]
832    fn error_kinds_map_to_the_framework_taxonomy() {
833        let fenced = CoordinationError::new(CoordinationErrorKind::Fenced, "seized");
834        assert_eq!(fenced.class(), ErrorClass::Fatal, "unintercepted backstop");
835        assert_eq!(
836            CoordinationError::new(CoordinationErrorKind::Retryable, "timeout").class(),
837            ErrorClass::Retryable
838        );
839        assert_eq!(
840            CoordinationError::new(CoordinationErrorKind::Fatal, "no CAS").class(),
841            ErrorClass::Fatal
842        );
843        assert!(fenced.to_string().contains("seized"));
844    }
845
846    #[test]
847    fn builders_cover_the_non_exhaustive_structs() {
848        let spec = SplitSpec::new(SplitId::new("s").unwrap(), b"d".to_vec());
849        assert_eq!(spec.weight, 1, "default weight");
850        assert_eq!(spec.clone().with_weight(64 << 20).weight, 64 << 20);
851
852        let planned = PlannedSplit::new(spec.clone());
853        assert!(planned.seed.is_none());
854        let seeded = planned.with_seed(SplitProgress::new(7, b"state".to_vec()));
855        assert_eq!(seeded.seed.as_ref().unwrap().watermark, 7);
856
857        let plan = SplitPlan::new(vec![seeded], PlanFinality::Open);
858        assert!(plan.planner_state.is_none());
859        assert_eq!(
860            plan.with_planner_state(b"cursor".to_vec())
861                .planner_state
862                .as_deref(),
863            Some(b"cursor".as_slice())
864        );
865
866        let running = SplitProgress::new(7, vec![]);
867        assert!(!running.completed);
868        assert!(SplitProgress::completed(7, vec![]).completed);
869
870        let ctx = PlanContext::new(Some(b"cursor"), 3);
871        assert_eq!(ctx.generation, 3);
872    }
873
874    #[test]
875    fn lease_epochs_order_across_owners() {
876        assert!(LeaseEpoch(2) > LeaseEpoch(1));
877    }
878}