Skip to main content

spate_coordination/
coordinator.rs

1//! The synchronous coordinator handle sources embed.
2//!
3//! One background task per process owns all store I/O (see `task.rs`);
4//! this handle drives it over channels from the pipeline's controller
5//! thread. Commands (commit/fail/release) get bounded blocking replies;
6//! events arrive on an unbounded queue the controller drains via `poll`.
7//! Nothing here blocks on the tokio runtime — the sync side uses plain
8//! `std::sync::mpsc` receives, so a wedged runtime cannot deadlock the
9//! controller.
10
11use crate::clock::{Clock, SystemClock};
12use crate::config::CoordinationConfig;
13use crate::error::fatal;
14use crate::records::{self, LeaseVal, SplitProgressRecord};
15use crate::store::metered::Metered;
16use crate::store::{CoordinationStore, Keyspace};
17use crate::task::{Command, Task, TaskEvent};
18use spate_core::coordination::ControlWaker;
19use spate_core::coordination::{
20    CoordinationError, CoordinationErrorKind, CoordinationEvent, SplitCoordinator, SplitId,
21    SplitPlanner, SplitProgress,
22};
23use spate_core::metrics::CoordinationMetrics;
24use std::collections::BTreeMap;
25use std::sync::Arc;
26use std::sync::mpsc as std_mpsc;
27use std::time::{Duration, Instant};
28use tokio::sync::mpsc;
29
30/// Command-queue depth; the controller thread sends one command at a time,
31/// so anything above a handful only covers bursts around shutdown.
32const COMMAND_DEPTH: usize = 64;
33
34/// A [`SplitCoordinator`] over any [`CoordinationStore`].
35///
36/// Built with a multi-thread runtime handle (the background task and the
37/// store's I/O live there; a current-thread runtime would deadlock the
38/// blocking replies and is rejected at construction).
39pub struct StoreCoordinator<S: CoordinationStore + Clone> {
40    store: S,
41    config: CoordinationConfig,
42    clock: Arc<dyn Clock>,
43    io: tokio::runtime::Handle,
44    metrics: Option<CoordinationMetrics>,
45    instance: String,
46    nonce: String,
47    running: Option<Running>,
48    failed: Option<(CoordinationErrorKind, String)>,
49    /// Set by the driver before `start`; handed to the task so every
50    /// queued event also wakes the driver's park.
51    waker: Option<ControlWaker>,
52}
53
54struct Running {
55    commands: mpsc::Sender<Command>,
56    events: std_mpsc::Receiver<TaskEvent>,
57    task: tokio::task::JoinHandle<()>,
58    /// Splits observed Gained (with their tenancy epoch) minus
59    /// Lost/completed — the release set the direct teardown fallback
60    /// works from. The epoch pins the tenancy: a direct release must
61    /// never clear a record a same-named restart has since reclaimed.
62    held: BTreeMap<String, u64>,
63}
64
65impl<S: CoordinationStore + Clone> std::fmt::Debug for StoreCoordinator<S> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("StoreCoordinator")
68            .field("instance", &self.instance)
69            .field("started", &self.running.is_some())
70            .field("failed", &self.failed)
71            .finish_non_exhaustive()
72    }
73}
74
75impl<S: CoordinationStore + Clone> StoreCoordinator<S> {
76    /// Wrap a store. `io` must be a multi-thread runtime handle.
77    ///
78    /// # Errors
79    ///
80    /// Fatal on invalid configuration, a current-thread runtime, or a
81    /// store whose lease TTL diverges from `config.lease_duration`.
82    pub fn new(
83        store: S,
84        config: CoordinationConfig,
85        io: tokio::runtime::Handle,
86        metrics: Option<CoordinationMetrics>,
87    ) -> Result<StoreCoordinator<S>, CoordinationError> {
88        StoreCoordinator::with_clock(store, config, io, metrics, Arc::new(SystemClock))
89    }
90
91    /// Like [`new`](StoreCoordinator::new) but drives the starvation
92    /// self-fence from an injected [`Clock`]. A frozen clock makes fencing
93    /// deterministic under CI scheduler jitter — pass the same clock to the
94    /// store so its lease expiry stays coherent. See [`crate::clock`].
95    ///
96    /// # Errors
97    ///
98    /// Fatal on invalid configuration, a current-thread runtime, or a
99    /// store whose lease TTL diverges from `config.lease_duration`.
100    #[doc(hidden)]
101    pub fn with_clock(
102        store: S,
103        config: CoordinationConfig,
104        io: tokio::runtime::Handle,
105        metrics: Option<CoordinationMetrics>,
106        clock: Arc<dyn Clock>,
107    ) -> Result<StoreCoordinator<S>, CoordinationError> {
108        config.validate()?;
109        if io.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread {
110            return Err(fatal(
111                "coordination needs a multi-thread tokio runtime: the coordinator's \
112                 background task and the controller's blocking replies cannot share one \
113                 thread",
114            ));
115        }
116        // Renewal cadence comes from the config, expiry from the store's
117        // TTL: built from different values they silently churn leases
118        // (renewals pace against the wrong deadline). Fail fast instead.
119        let store_ttl = store.lease_ttl();
120        if store_ttl != config.lease_duration {
121            return Err(fatal(format!(
122                "the store's lease TTL ({store_ttl:?}) does not match \
123                 coordination.lease_duration ({:?}): both must be built from the same \
124                 value — construct the store with the config's lease_duration",
125                config.lease_duration
126            )));
127        }
128        let instance = match &config.instance_id {
129            Some(id) => id.clone(),
130            None => format!("spate-{}", uuid::Uuid::new_v4().simple()),
131        };
132        let nonce = uuid::Uuid::new_v4().simple().to_string();
133        Ok(StoreCoordinator {
134            store,
135            config,
136            clock,
137            io,
138            metrics,
139            instance,
140            nonce,
141            running: None,
142            failed: None,
143            waker: None,
144        })
145    }
146
147    /// This worker's (stable or generated) instance id.
148    #[must_use]
149    pub fn instance_id(&self) -> &str {
150        &self.instance
151    }
152
153    fn check_failed(&self) -> Result<(), CoordinationError> {
154        match &self.failed {
155            Some((kind, reason)) => Err(CoordinationError::new(*kind, reason.clone())),
156            None => Ok(()),
157        }
158    }
159
160    fn fail_from(&mut self, kind: CoordinationErrorKind, reason: &str) -> CoordinationError {
161        self.failed = Some((kind, reason.to_string()));
162        CoordinationError::new(kind, reason.to_string())
163    }
164
165    fn command(
166        &mut self,
167        build: impl FnOnce(std_mpsc::SyncSender<Result<(), CoordinationError>>) -> Command,
168    ) -> Result<(), CoordinationError> {
169        self.check_failed()?;
170        let Some(running) = &self.running else {
171            return Err(fatal("coordinator used before start"));
172        };
173        let commands = running.commands.clone();
174        let deadline_at = Instant::now() + self.config.op_timeout * 3;
175        let (reply_tx, reply_rx) = std_mpsc::sync_channel(1);
176        // Enqueue with the same deadline as the reply: a full queue means
177        // the task is backed up behind an unreachable store, and an
178        // unbounded send here would wedge the controller thread for good.
179        let mut command = build(reply_tx);
180        loop {
181            match commands.try_send(command) {
182                Ok(()) => break,
183                Err(mpsc::error::TrySendError::Full(returned)) => {
184                    if Instant::now() >= deadline_at {
185                        return Err(CoordinationError::new(
186                            CoordinationErrorKind::Retryable,
187                            "coordination command queue is full; the store may be slow or \
188                             unreachable",
189                        ));
190                    }
191                    command = returned;
192                    std::thread::sleep(Duration::from_millis(1));
193                }
194                Err(mpsc::error::TrySendError::Closed(_)) => {
195                    return Err(self.drain_failure());
196                }
197            }
198        }
199        // The reply is awaited synchronously with a deadline; a wedged
200        // store surfaces as Retryable, not a hung pipeline.
201        let remaining = deadline_at.saturating_duration_since(Instant::now());
202        match reply_rx.recv_timeout(remaining) {
203            Ok(result) => result,
204            Err(std_mpsc::RecvTimeoutError::Timeout) => Err(CoordinationError::new(
205                CoordinationErrorKind::Retryable,
206                format!(
207                    "coordination command timed out after {:?}; the store may be slow or \
208                     unreachable",
209                    self.config.op_timeout * 3
210                ),
211            )),
212            Err(std_mpsc::RecvTimeoutError::Disconnected) => Err(self.drain_failure()),
213        }
214    }
215
216    /// The task died: pull its parting Failed event (if any) so the
217    /// caller gets the real cause rather than a broken-channel error.
218    fn drain_failure(&mut self) -> CoordinationError {
219        if let Some(running) = &self.running {
220            while let Ok(event) = running.events.try_recv() {
221                if let TaskEvent::Failed(kind, reason) = event {
222                    self.failed = Some((kind, reason));
223                }
224            }
225        }
226        match &self.failed {
227            Some((kind, reason)) => CoordinationError::new(*kind, reason.clone()),
228            None => self.fail_from(
229                CoordinationErrorKind::Fatal,
230                "coordination task stopped unexpectedly",
231            ),
232        }
233    }
234
235    /// Track held splits (and their tenancy epochs) from the event stream
236    /// (for the teardown fallback) while passing events through.
237    fn observe(held: &mut BTreeMap<String, u64>, event: &CoordinationEvent) {
238        match event {
239            CoordinationEvent::Gained { split, epoch, .. } => {
240                held.insert(split.id.as_str().to_string(), epoch.0);
241            }
242            CoordinationEvent::Lost { split } | CoordinationEvent::Quarantined { split, .. } => {
243                held.remove(split.as_str());
244            }
245            CoordinationEvent::AllComplete | CoordinationEvent::Stalled { .. } => {}
246            // Non-exhaustive upstream enum: future events cannot affect
247            // the held-set bookkeeping this crate defined against.
248            _ => {}
249        }
250    }
251
252    /// Whether the background task can still service commands (its
253    /// command channel is open).
254    fn task_alive(&self) -> bool {
255        self.running
256            .as_ref()
257            .is_some_and(|r| !r.commands.is_closed())
258    }
259
260    /// Direct-store release for teardown paths where the background task
261    /// (or its runtime) is already gone: a private current-thread runtime
262    /// runs guarded lease deletes and owner-clears under one aggregate
263    /// deadline. Best-effort — anything it cannot reach simply expires.
264    fn release_direct(&self, splits: &[(SplitId, u64)]) {
265        let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
266            .enable_time()
267            .build()
268        else {
269            return;
270        };
271        let deadline = self.config.op_timeout * 2;
272        let store = self.store.clone();
273        let instance = self.instance.clone();
274        let nonce = self.nonce.clone();
275        let ids: Vec<(String, u64)> = splits
276            .iter()
277            .map(|(s, epoch)| (s.as_str().to_string(), *epoch))
278            .collect();
279        let result = runtime.block_on(async move {
280            tokio::time::timeout(deadline, async {
281                for (id, epoch) in ids {
282                    let key = records::split_key_str(&id);
283                    // Lease: delete only if it is really ours.
284                    if let Ok(Some(entry)) = store.get(Keyspace::Ephemeral, &key).await
285                        && let Ok(lease) = serde_json::from_slice::<LeaseVal>(&entry.value)
286                        && lease.owner == instance
287                        && lease.nonce == nonce
288                    {
289                        let _ = store
290                            .delete(Keyspace::Ephemeral, &key, Some(entry.revision))
291                            .await;
292                    }
293                    // Record: clear the owner so the next claim is
294                    // attempt-free. The owner string alone is not proof of
295                    // tenancy — a restarted worker with the same stable
296                    // instance id may have reclaimed the split under a
297                    // higher epoch, and clearing ITS owner would fence a
298                    // live peer. The epoch pins our tenancy. Parsed
299                    // leniently (no fingerprint at hand) — the CAS on the
300                    // read revision is still safe.
301                    if let Ok(Some(entry)) = store.get(Keyspace::Durable, &key).await
302                        && let Ok(mut record) =
303                            serde_json::from_slice::<SplitProgressRecord>(&entry.value)
304                        && record.owner.as_deref() == Some(instance.as_str())
305                        && record.epoch == epoch
306                    {
307                        record.owner = None;
308                        record.written_at_ms = records::now_ms();
309                        let _ = store
310                            .update(Keyspace::Durable, &key, record.encode(), entry.revision)
311                            .await;
312                    }
313                }
314            })
315            .await
316        });
317        if result.is_err() {
318            tracing::warn!("direct release ran out of time; remaining leases will expire");
319        }
320    }
321}
322
323impl<S: CoordinationStore + Clone> SplitCoordinator for StoreCoordinator<S> {
324    fn start(&mut self, planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
325        self.check_failed()?;
326        if self.running.is_some() {
327            return Err(fatal("SplitCoordinator::start called twice"));
328        }
329        let fingerprint = planner.fingerprint();
330        if fingerprint.is_empty() {
331            return Err(fatal("the planner fingerprint must not be empty"));
332        }
333        let (command_tx, command_rx) = mpsc::channel(COMMAND_DEPTH);
334        let (event_tx, event_rx) = std_mpsc::channel();
335        let metrics = self.metrics.take();
336        // The decorator applies the per-op deadline and the store-op
337        // latency histograms to every primitive in one place.
338        let store = Metered::new(self.store.clone(), self.config.op_timeout, metrics.clone());
339        let task = Task::new(
340            store,
341            self.config.clone(),
342            self.clock.clone(),
343            fingerprint,
344            self.instance.clone(),
345            self.nonce.clone(),
346            planner,
347            metrics,
348            command_rx,
349            event_tx,
350            self.waker.clone(),
351        );
352        let join = self.io.spawn(task.run());
353        self.running = Some(Running {
354            commands: command_tx,
355            events: event_rx,
356            task: join,
357            held: BTreeMap::new(),
358        });
359        Ok(())
360    }
361
362    fn set_waker(&mut self, waker: ControlWaker) {
363        self.waker = Some(waker);
364    }
365
366    fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
367        self.check_failed()?;
368        let Some(running) = self.running.as_mut() else {
369            return Err(fatal("coordinator polled before start"));
370        };
371        let mut out = Vec::new();
372        let mut failure = None;
373        loop {
374            match running.events.try_recv() {
375                Ok(TaskEvent::Coordination(event)) => {
376                    Self::observe(&mut running.held, &event);
377                    out.push(event);
378                }
379                Ok(TaskEvent::Failed(kind, reason)) => {
380                    failure = Some((kind, reason));
381                    break;
382                }
383                // Nothing queued. This call never blocks — the driver owns
384                // the wait and the task wakes it when it enqueues.
385                Err(std_mpsc::TryRecvError::Empty) => break,
386                Err(std_mpsc::TryRecvError::Disconnected) => {
387                    if out.is_empty() {
388                        return Err(self.drain_failure());
389                    }
390                    break;
391                }
392            }
393        }
394        if let Some((kind, reason)) = failure {
395            self.failed = Some((kind, reason.clone()));
396            if out.is_empty() {
397                return Err(CoordinationError::new(kind, reason));
398            }
399        }
400        Ok(out)
401    }
402
403    fn commit(
404        &mut self,
405        split: &SplitId,
406        progress: &SplitProgress,
407    ) -> Result<(), CoordinationError> {
408        let result = self.command(|reply| Command::Commit {
409            split: split.clone(),
410            progress: progress.clone(),
411            reply,
412        });
413        if let Some(running) = self.running.as_mut() {
414            match &result {
415                Ok(()) if progress.completed => {
416                    running.held.remove(split.as_str());
417                }
418                Err(e) if e.kind == CoordinationErrorKind::Fenced => {
419                    running.held.remove(split.as_str());
420                }
421                _ => {}
422            }
423        }
424        result
425    }
426
427    fn fail(&mut self, split: &SplitId, reason: &str) -> Result<(), CoordinationError> {
428        let result = self.command(|reply| Command::Fail {
429            split: split.clone(),
430            reason: reason.to_string(),
431            reply,
432        });
433        if result.is_ok()
434            && let Some(running) = self.running.as_mut()
435        {
436            running.held.remove(split.as_str());
437        }
438        result
439    }
440
441    fn release(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
442        let result = self.command(|reply| Command::Release {
443            splits: splits.to_vec(),
444            departure: true,
445            reply,
446        });
447        match result {
448            Ok(()) => {
449                if let Some(running) = self.running.as_mut() {
450                    for split in splits {
451                        running.held.remove(split.as_str());
452                    }
453                }
454                Ok(())
455            }
456            Err(e) if self.task_alive() => {
457                // The task is alive but slow (store outage): the queued
458                // release may still execute, so a direct write now would
459                // race our own task. Best-effort contract: leases the
460                // task cannot hand back expire on their own.
461                tracing::warn!(error = %e, "release deferred; leases expire if it never lands");
462                Ok(())
463            }
464            Err(e) => {
465                // The task or its runtime is gone (shutdown ordering can
466                // tear the io runtime down before the source drops).
467                // Fall back to direct guarded writes so peers claim
468                // instantly instead of waiting out the TTL.
469                tracing::warn!(error = %e, "task-path release failed; releasing directly");
470                let pairs: Vec<(SplitId, u64)> = match &self.running {
471                    Some(running) => splits
472                        .iter()
473                        .filter_map(|s| {
474                            running
475                                .held
476                                .get(s.as_str())
477                                .map(|epoch| (s.clone(), *epoch))
478                        })
479                        .collect(),
480                    None => Vec::new(),
481                };
482                self.release_direct(&pairs);
483                if let Some(running) = self.running.as_mut() {
484                    for split in splits {
485                        running.held.remove(split.as_str());
486                    }
487                }
488                Ok(())
489            }
490        }
491    }
492
493    fn release_drained(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
494        // A drained revocation, not a departure: the task keeps this
495        // worker in the fleet even when the last split is handed back.
496        // Unlike `release`, there is NO direct-store teardown fallback —
497        // the process is live, so a release the task cannot land right now
498        // simply makes the rebalance slower (the leader forces it at
499        // `drain_deadline`); an unreleased lease expires on its own.
500        let result = self.command(|reply| Command::Release {
501            splits: splits.to_vec(),
502            departure: false,
503            reply,
504        });
505        // Drop from the held set the same way `release` does: the split is
506        // no longer this worker's to hand back at teardown.
507        if let Some(running) = self.running.as_mut() {
508            for split in splits {
509                running.held.remove(split.as_str());
510            }
511        }
512        if let Err(e) = &result {
513            tracing::warn!(error = %e, "revocation release deferred; the lease expires if it never lands");
514        }
515        Ok(())
516    }
517
518    fn decline_revoke(&mut self, split: &SplitId) -> Result<(), CoordinationError> {
519        // Best-effort: a decline the task never hears costs liveness only
520        // — the revocation is still forced at `drain_deadline`, just later
521        // than it needed to be — never correctness.
522        let result = self.command(|reply| Command::DeclineRevoke {
523            split: split.clone(),
524            reply,
525        });
526        if let Err(e) = &result {
527            tracing::warn!(
528                split = %split,
529                error = %e,
530                "revocation decline deferred; the drain deadline forces it anyway"
531            );
532        }
533        Ok(())
534    }
535}
536
537impl<S: CoordinationStore + Clone> Drop for StoreCoordinator<S> {
538    fn drop(&mut self) {
539        if let Some(running) = self.running.take() {
540            // Anything still held gets a best-effort direct release; the
541            // task is then aborted (its leases would expire anyway).
542            let held: Vec<(SplitId, u64)> = running
543                .held
544                .iter()
545                .filter_map(|(id, epoch)| SplitId::new(id.clone()).ok().map(|s| (s, *epoch)))
546                .collect();
547            running.task.abort();
548            if !held.is_empty() {
549                self.release_direct(&held);
550            }
551        }
552    }
553}