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