1use 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
30const COMMAND_DEPTH: usize = 64;
33
34pub 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 waker: Option<ControlWaker>,
52}
53
54struct Running {
55 commands: mpsc::Sender<Command>,
56 events: std_mpsc::Receiver<TaskEvent>,
57 task: tokio::task::JoinHandle<()>,
58 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 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 #[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 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 #[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 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 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 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 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 _ => {}
249 }
250 }
251
252 fn task_alive(&self) -> bool {
255 self.running
256 .as_ref()
257 .is_some_and(|r| !r.commands.is_closed())
258 }
259
260 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 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 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 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 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 tracing::warn!(error = %e, "release deferred; leases expire if it never lands");
462 Ok(())
463 }
464 Err(e) => {
465 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 let result = self.command(|reply| Command::Release {
501 splits: splits.to_vec(),
502 departure: false,
503 reply,
504 });
505 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 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 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}