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}