use crate::serve::config::ServeConfig;
use crate::serve::state::ServerState;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone)]
pub struct ClusterConfig {
pub enabled: bool,
pub poll: Duration,
pub max_attempts: u32,
}
impl ClusterConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
poll: Duration::from_secs(2),
max_attempts: 3,
}
}
}
#[derive(Clone)]
pub struct ClusterHandle {
inner: Arc<ClusterInner>,
}
struct ClusterInner {
cfg: ClusterConfig,
listen: String,
max_concurrent: u32,
started_at: chrono::DateTime<chrono::Utc>,
kick: Notify,
members: AtomicUsize,
}
impl ClusterHandle {
pub fn from_config(config: &ServeConfig) -> Self {
Self {
inner: Arc::new(ClusterInner {
cfg: config.cluster.clone(),
listen: config.listen.to_string(),
max_concurrent: config.max_concurrent_runs as u32,
started_at: chrono::Utc::now(),
kick: Notify::new(),
members: AtomicUsize::new(0),
}),
}
}
pub fn enabled(&self) -> bool {
self.inner.cfg.enabled
}
pub fn poll(&self) -> Duration {
self.inner.cfg.poll
}
pub fn max_attempts(&self) -> u32 {
self.inner.cfg.max_attempts
}
pub fn listen(&self) -> &str {
&self.inner.listen
}
pub fn max_concurrent(&self) -> u32 {
self.inner.max_concurrent
}
pub fn started_at(&self) -> chrono::DateTime<chrono::Utc> {
self.inner.started_at
}
pub fn kick(&self) {
self.inner.kick.notify_one();
}
pub async fn kicked(&self) {
self.inner.kick.notified().await;
}
pub fn members(&self) -> usize {
self.inner.members.load(Ordering::Acquire)
}
pub fn set_members(&self, n: usize) {
self.inner.members.store(n, Ordering::Release);
}
}
pub async fn claim_loop(state: ServerState, shutdown: CancellationToken) {
let handle = state.cluster().clone();
let mut tick = tokio::time::interval(handle.poll());
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
_ = shutdown.cancelled() => break,
_ = tick.tick() => {}
_ = handle.kicked() => {}
}
match state.history().pending_cancellations().await {
Ok(ids) => {
for id in ids {
state.registry().cancel(&id);
}
}
Err(e) => tracing::warn!(error = %e, "cluster: pending_cancellations failed"),
}
match state.history().pending_shard_cancellations().await {
Ok(ids) => {
for id in ids {
let fired = state.registry().cancel_run_shards(&id);
if fired > 0 {
tracing::info!(
run_id = %id,
shards = fired,
"cluster: cancelling local shards of a flagged sharded run"
);
}
}
}
Err(e) => tracing::warn!(error = %e, "cluster: pending_shard_cancellations failed"),
}
let free = state.semaphore().available_permits();
if free == 0 {
continue;
}
let mut claimed_count = 0usize;
match state.history().claim_pending(free).await {
Ok(claimed) => {
if !claimed.is_empty() {
crate::serve::metrics::record_runs_claimed(claimed.len());
claimed_count = claimed.len();
for rec in claimed {
crate::serve::runner::resume_claimed_run(state.clone(), rec);
}
}
}
Err(e) => tracing::warn!(error = %e, "cluster: claim_pending failed"),
}
let shard_budget = free.saturating_sub(claimed_count);
if shard_budget > 0 {
match state.history().claim_shards(shard_budget).await {
Ok(shards) => {
if !shards.is_empty() {
crate::serve::metrics::record_shards_claimed(shards.len());
for shard in shards {
crate::serve::runner::resume_claimed_shard(state.clone(), shard);
}
}
}
Err(e) => tracing::warn!(error = %e, "cluster: claim_shards failed"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_handle_reports_disabled() {
let cfg = ClusterConfig::disabled();
assert!(!cfg.enabled);
assert_eq!(cfg.max_attempts, 3);
}
#[tokio::test]
async fn kick_wakes_a_waiter() {
use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
let cfg = ServeConfig {
listen: "127.0.0.1:0".parse().unwrap(),
auth: AuthMode::None,
max_concurrent_runs: 4,
max_queued_runs: 4,
default_config_path: None,
history: HistoryBackendSpec::Memory,
cors_origins: vec![],
body_limit_bytes: 1_048_576,
shutdown_grace: std::time::Duration::from_secs(60),
retain_terminal_runs: std::time::Duration::from_secs(60),
idempotency_retention: std::time::Duration::from_secs(60),
lease_ttl: std::time::Duration::from_secs(30),
probe_timeout: std::time::Duration::from_secs(10),
env_file: None,
no_env_file: false,
log_level: "info".into(),
ui_enabled: true,
cluster: ClusterConfig::disabled(),
triggers_path: None,
};
let h = ClusterHandle::from_config(&cfg);
h.kick();
tokio::time::timeout(std::time::Duration::from_secs(1), h.kicked())
.await
.expect("kick must wake the waiter");
h.set_members(2);
assert_eq!(h.members(), 2);
}
}