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