use crate::clock::{Clock, SystemClock};
use crate::config::CoordinationConfig;
use crate::error::fatal;
use crate::records::{self, LeaseVal, SplitProgressRecord};
use crate::store::metered::Metered;
use crate::store::{CoordinationStore, Keyspace};
use crate::task::{Command, Task, TaskEvent};
use spate_core::coordination::ControlWaker;
use spate_core::coordination::{
CoordinationError, CoordinationErrorKind, CoordinationEvent, SplitCoordinator, SplitId,
SplitPlanner, SplitProgress,
};
use spate_core::metrics::CoordinationMetrics;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::mpsc as std_mpsc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
const COMMAND_DEPTH: usize = 64;
pub struct StoreCoordinator<S: CoordinationStore + Clone> {
store: S,
config: CoordinationConfig,
clock: Arc<dyn Clock>,
io: tokio::runtime::Handle,
metrics: Option<CoordinationMetrics>,
instance: String,
nonce: String,
running: Option<Running>,
failed: Option<(CoordinationErrorKind, String)>,
waker: Option<ControlWaker>,
}
struct Running {
commands: mpsc::Sender<Command>,
events: std_mpsc::Receiver<TaskEvent>,
task: tokio::task::JoinHandle<()>,
held: BTreeMap<String, u64>,
}
impl<S: CoordinationStore + Clone> std::fmt::Debug for StoreCoordinator<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoreCoordinator")
.field("instance", &self.instance)
.field("started", &self.running.is_some())
.field("failed", &self.failed)
.finish_non_exhaustive()
}
}
impl<S: CoordinationStore + Clone> StoreCoordinator<S> {
pub fn new(
store: S,
config: CoordinationConfig,
io: tokio::runtime::Handle,
metrics: Option<CoordinationMetrics>,
) -> Result<StoreCoordinator<S>, CoordinationError> {
StoreCoordinator::with_clock(store, config, io, metrics, Arc::new(SystemClock))
}
#[doc(hidden)]
pub fn with_clock(
store: S,
config: CoordinationConfig,
io: tokio::runtime::Handle,
metrics: Option<CoordinationMetrics>,
clock: Arc<dyn Clock>,
) -> Result<StoreCoordinator<S>, CoordinationError> {
config.validate()?;
if io.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread {
return Err(fatal(
"coordination needs a multi-thread tokio runtime: the coordinator's \
background task and the controller's blocking replies cannot share one \
thread",
));
}
let store_ttl = store.lease_ttl();
if store_ttl != config.lease_duration {
return Err(fatal(format!(
"the store's lease TTL ({store_ttl:?}) does not match \
coordination.lease_duration ({:?}): both must be built from the same \
value — construct the store with the config's lease_duration",
config.lease_duration
)));
}
let instance = match &config.instance_id {
Some(id) => id.clone(),
None => format!("spate-{}", uuid::Uuid::new_v4().simple()),
};
let nonce = uuid::Uuid::new_v4().simple().to_string();
Ok(StoreCoordinator {
store,
config,
clock,
io,
metrics,
instance,
nonce,
running: None,
failed: None,
waker: None,
})
}
#[must_use]
pub fn instance_id(&self) -> &str {
&self.instance
}
fn check_failed(&self) -> Result<(), CoordinationError> {
match &self.failed {
Some((kind, reason)) => Err(CoordinationError::new(*kind, reason.clone())),
None => Ok(()),
}
}
fn fail_from(&mut self, kind: CoordinationErrorKind, reason: &str) -> CoordinationError {
self.failed = Some((kind, reason.to_string()));
CoordinationError::new(kind, reason.to_string())
}
fn command(
&mut self,
build: impl FnOnce(std_mpsc::SyncSender<Result<(), CoordinationError>>) -> Command,
) -> Result<(), CoordinationError> {
self.check_failed()?;
let Some(running) = &self.running else {
return Err(fatal("coordinator used before start"));
};
let commands = running.commands.clone();
let deadline_at = Instant::now() + self.config.op_timeout * 3;
let (reply_tx, reply_rx) = std_mpsc::sync_channel(1);
let mut command = build(reply_tx);
loop {
match commands.try_send(command) {
Ok(()) => break,
Err(mpsc::error::TrySendError::Full(returned)) => {
if Instant::now() >= deadline_at {
return Err(CoordinationError::new(
CoordinationErrorKind::Retryable,
"coordination command queue is full; the store may be slow or \
unreachable",
));
}
command = returned;
std::thread::sleep(Duration::from_millis(1));
}
Err(mpsc::error::TrySendError::Closed(_)) => {
return Err(self.drain_failure());
}
}
}
let remaining = deadline_at.saturating_duration_since(Instant::now());
match reply_rx.recv_timeout(remaining) {
Ok(result) => result,
Err(std_mpsc::RecvTimeoutError::Timeout) => Err(CoordinationError::new(
CoordinationErrorKind::Retryable,
format!(
"coordination command timed out after {:?}; the store may be slow or \
unreachable",
self.config.op_timeout * 3
),
)),
Err(std_mpsc::RecvTimeoutError::Disconnected) => Err(self.drain_failure()),
}
}
fn drain_failure(&mut self) -> CoordinationError {
if let Some(running) = &self.running {
while let Ok(event) = running.events.try_recv() {
if let TaskEvent::Failed(kind, reason) = event {
self.failed = Some((kind, reason));
}
}
}
match &self.failed {
Some((kind, reason)) => CoordinationError::new(*kind, reason.clone()),
None => self.fail_from(
CoordinationErrorKind::Fatal,
"coordination task stopped unexpectedly",
),
}
}
fn observe(held: &mut BTreeMap<String, u64>, event: &CoordinationEvent) {
match event {
CoordinationEvent::Gained { split, epoch, .. } => {
held.insert(split.id.as_str().to_string(), epoch.0);
}
CoordinationEvent::Lost { split } | CoordinationEvent::Quarantined { split, .. } => {
held.remove(split.as_str());
}
CoordinationEvent::AllComplete | CoordinationEvent::Stalled { .. } => {}
_ => {}
}
}
fn task_alive(&self) -> bool {
self.running
.as_ref()
.is_some_and(|r| !r.commands.is_closed())
}
fn release_direct(&self, splits: &[(SplitId, u64)]) {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
else {
return;
};
let deadline = self.config.op_timeout * 2;
let store = self.store.clone();
let instance = self.instance.clone();
let nonce = self.nonce.clone();
let ids: Vec<(String, u64)> = splits
.iter()
.map(|(s, epoch)| (s.as_str().to_string(), *epoch))
.collect();
let result = runtime.block_on(async move {
tokio::time::timeout(deadline, async {
for (id, epoch) in ids {
let key = records::split_key_str(&id);
if let Ok(Some(entry)) = store.get(Keyspace::Ephemeral, &key).await
&& let Ok(lease) = serde_json::from_slice::<LeaseVal>(&entry.value)
&& lease.owner == instance
&& lease.nonce == nonce
{
let _ = store
.delete(Keyspace::Ephemeral, &key, Some(entry.revision))
.await;
}
if let Ok(Some(entry)) = store.get(Keyspace::Durable, &key).await
&& let Ok(mut record) =
serde_json::from_slice::<SplitProgressRecord>(&entry.value)
&& record.owner.as_deref() == Some(instance.as_str())
&& record.epoch == epoch
{
record.owner = None;
record.written_at_ms = records::now_ms();
let _ = store
.update(Keyspace::Durable, &key, record.encode(), entry.revision)
.await;
}
}
})
.await
});
if result.is_err() {
tracing::warn!("direct release ran out of time; remaining leases will expire");
}
}
}
impl<S: CoordinationStore + Clone> SplitCoordinator for StoreCoordinator<S> {
fn start(&mut self, planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
self.check_failed()?;
if self.running.is_some() {
return Err(fatal("SplitCoordinator::start called twice"));
}
let fingerprint = planner.fingerprint();
if fingerprint.is_empty() {
return Err(fatal("the planner fingerprint must not be empty"));
}
let (command_tx, command_rx) = mpsc::channel(COMMAND_DEPTH);
let (event_tx, event_rx) = std_mpsc::channel();
let metrics = self.metrics.take();
let store = Metered::new(self.store.clone(), self.config.op_timeout, metrics.clone());
let task = Task::new(
store,
self.config.clone(),
self.clock.clone(),
fingerprint,
self.instance.clone(),
self.nonce.clone(),
planner,
metrics,
command_rx,
event_tx,
self.waker.clone(),
);
let join = self.io.spawn(task.run());
self.running = Some(Running {
commands: command_tx,
events: event_rx,
task: join,
held: BTreeMap::new(),
});
Ok(())
}
fn set_waker(&mut self, waker: ControlWaker) {
self.waker = Some(waker);
}
fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
self.check_failed()?;
let Some(running) = self.running.as_mut() else {
return Err(fatal("coordinator polled before start"));
};
let mut out = Vec::new();
let mut failure = None;
loop {
match running.events.try_recv() {
Ok(TaskEvent::Coordination(event)) => {
Self::observe(&mut running.held, &event);
out.push(event);
}
Ok(TaskEvent::Failed(kind, reason)) => {
failure = Some((kind, reason));
break;
}
Err(std_mpsc::TryRecvError::Empty) => break,
Err(std_mpsc::TryRecvError::Disconnected) => {
if out.is_empty() {
return Err(self.drain_failure());
}
break;
}
}
}
if let Some((kind, reason)) = failure {
self.failed = Some((kind, reason.clone()));
if out.is_empty() {
return Err(CoordinationError::new(kind, reason));
}
}
Ok(out)
}
fn commit(
&mut self,
split: &SplitId,
progress: &SplitProgress,
) -> Result<(), CoordinationError> {
let result = self.command(|reply| Command::Commit {
split: split.clone(),
progress: progress.clone(),
reply,
});
if let Some(running) = self.running.as_mut() {
match &result {
Ok(()) if progress.completed => {
running.held.remove(split.as_str());
}
Err(e) if e.kind == CoordinationErrorKind::Fenced => {
running.held.remove(split.as_str());
}
_ => {}
}
}
result
}
fn fail(&mut self, split: &SplitId, reason: &str) -> Result<(), CoordinationError> {
let result = self.command(|reply| Command::Fail {
split: split.clone(),
reason: reason.to_string(),
reply,
});
if result.is_ok()
&& let Some(running) = self.running.as_mut()
{
running.held.remove(split.as_str());
}
result
}
fn release(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
let result = self.command(|reply| Command::Release {
splits: splits.to_vec(),
departure: true,
reply,
});
match result {
Ok(()) => {
if let Some(running) = self.running.as_mut() {
for split in splits {
running.held.remove(split.as_str());
}
}
Ok(())
}
Err(e) if self.task_alive() => {
tracing::warn!(error = %e, "release deferred; leases expire if it never lands");
Ok(())
}
Err(e) => {
tracing::warn!(error = %e, "task-path release failed; releasing directly");
let pairs: Vec<(SplitId, u64)> = match &self.running {
Some(running) => splits
.iter()
.filter_map(|s| {
running
.held
.get(s.as_str())
.map(|epoch| (s.clone(), *epoch))
})
.collect(),
None => Vec::new(),
};
self.release_direct(&pairs);
if let Some(running) = self.running.as_mut() {
for split in splits {
running.held.remove(split.as_str());
}
}
Ok(())
}
}
}
fn release_drained(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
let result = self.command(|reply| Command::Release {
splits: splits.to_vec(),
departure: false,
reply,
});
if let Some(running) = self.running.as_mut() {
for split in splits {
running.held.remove(split.as_str());
}
}
if let Err(e) = &result {
tracing::warn!(error = %e, "revocation release deferred; the lease expires if it never lands");
}
Ok(())
}
fn decline_revoke(&mut self, split: &SplitId) -> Result<(), CoordinationError> {
let result = self.command(|reply| Command::DeclineRevoke {
split: split.clone(),
reply,
});
if let Err(e) = &result {
tracing::warn!(
split = %split,
error = %e,
"revocation decline deferred; the drain deadline forces it anyway"
);
}
Ok(())
}
}
impl<S: CoordinationStore + Clone> Drop for StoreCoordinator<S> {
fn drop(&mut self) {
if let Some(running) = self.running.take() {
let held: Vec<(SplitId, u64)> = running
.held
.iter()
.filter_map(|(id, epoch)| SplitId::new(id.clone()).ok().map(|s| (s, *epoch)))
.collect();
running.task.abort();
if !held.is_empty() {
self.release_direct(&held);
}
}
}
}