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