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