faucet_cli/serve/
cluster.rs1use crate::serve::config::ServeConfig;
7use crate::serve::state::ServerState;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::time::Duration;
11use tokio::sync::Notify;
12use tokio_util::sync::CancellationToken;
13
14#[derive(Debug, Clone)]
16pub struct ClusterConfig {
17 pub enabled: bool,
18 pub poll: Duration,
20 pub max_attempts: u32,
22}
23
24impl ClusterConfig {
25 pub fn disabled() -> Self {
27 Self {
28 enabled: false,
29 poll: Duration::from_secs(2),
30 max_attempts: 3,
31 }
32 }
33}
34
35#[derive(Clone)]
40pub struct ClusterHandle {
41 inner: Arc<ClusterInner>,
42}
43
44struct ClusterInner {
45 cfg: ClusterConfig,
46 listen: String,
47 max_concurrent: u32,
48 started_at: chrono::DateTime<chrono::Utc>,
49 kick: Notify,
50 members: AtomicUsize,
51}
52
53impl ClusterHandle {
54 pub fn from_config(config: &ServeConfig) -> Self {
57 Self {
58 inner: Arc::new(ClusterInner {
59 cfg: config.cluster.clone(),
60 listen: config.listen.to_string(),
61 max_concurrent: config.max_concurrent_runs as u32,
62 started_at: chrono::Utc::now(),
63 kick: Notify::new(),
64 members: AtomicUsize::new(0),
65 }),
66 }
67 }
68
69 pub fn enabled(&self) -> bool {
70 self.inner.cfg.enabled
71 }
72 pub fn poll(&self) -> Duration {
73 self.inner.cfg.poll
74 }
75 pub fn max_attempts(&self) -> u32 {
76 self.inner.cfg.max_attempts
77 }
78 pub fn listen(&self) -> &str {
79 &self.inner.listen
80 }
81 pub fn max_concurrent(&self) -> u32 {
82 self.inner.max_concurrent
83 }
84 pub fn started_at(&self) -> chrono::DateTime<chrono::Utc> {
85 self.inner.started_at
86 }
87
88 pub fn kick(&self) {
90 self.inner.kick.notify_one();
91 }
92 pub async fn kicked(&self) {
94 self.inner.kick.notified().await;
95 }
96
97 pub fn members(&self) -> usize {
99 self.inner.members.load(Ordering::Acquire)
100 }
101 pub fn set_members(&self, n: usize) {
102 self.inner.members.store(n, Ordering::Release);
103 }
104}
105
106pub async fn claim_loop(state: ServerState, shutdown: CancellationToken) {
120 let handle = state.cluster().clone();
121 let mut tick = tokio::time::interval(handle.poll());
122 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
123 loop {
124 tokio::select! {
125 biased;
126 _ = shutdown.cancelled() => break,
127 _ = tick.tick() => {}
128 _ = handle.kicked() => {}
129 }
130
131 match state.history().pending_cancellations().await {
133 Ok(ids) => {
134 for id in ids {
135 state.registry().cancel(&id);
136 }
137 }
138 Err(e) => tracing::warn!(error = %e, "cluster: pending_cancellations failed"),
139 }
140 match state.history().pending_shard_cancellations().await {
144 Ok(ids) => {
145 for id in ids {
146 let fired = state.registry().cancel_run_shards(&id);
147 if fired > 0 {
148 tracing::info!(
149 run_id = %id,
150 shards = fired,
151 "cluster: cancelling local shards of a flagged sharded run"
152 );
153 }
154 }
155 }
156 Err(e) => tracing::warn!(error = %e, "cluster: pending_shard_cancellations failed"),
157 }
158
159 let free = state.semaphore().available_permits();
161 if free == 0 {
162 continue;
163 }
164 let mut claimed_count = 0usize;
165 match state.history().claim_pending(free).await {
166 Ok(claimed) => {
167 if !claimed.is_empty() {
168 crate::serve::metrics::record_runs_claimed(claimed.len());
169 claimed_count = claimed.len();
170 for rec in claimed {
171 crate::serve::runner::resume_claimed_run(state.clone(), rec);
172 }
173 }
174 }
175 Err(e) => tracing::warn!(error = %e, "cluster: claim_pending failed"),
176 }
177
178 let shard_budget = free.saturating_sub(claimed_count);
183 if shard_budget > 0 {
184 match state.history().claim_shards(shard_budget).await {
185 Ok(shards) => {
186 if !shards.is_empty() {
187 crate::serve::metrics::record_shards_claimed(shards.len());
188 for shard in shards {
189 crate::serve::runner::resume_claimed_shard(state.clone(), shard);
190 }
191 }
192 }
193 Err(e) => tracing::warn!(error = %e, "cluster: claim_shards failed"),
194 }
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn disabled_handle_reports_disabled() {
205 let cfg = ClusterConfig::disabled();
206 assert!(!cfg.enabled);
207 assert_eq!(cfg.max_attempts, 3);
208 }
209
210 #[tokio::test]
211 async fn kick_wakes_a_waiter() {
212 use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
215 let cfg = ServeConfig {
216 listen: "127.0.0.1:0".parse().unwrap(),
217 auth: AuthMode::None,
218 max_concurrent_runs: 4,
219 max_queued_runs: 4,
220 default_config_path: None,
221 history: HistoryBackendSpec::Memory,
222 cors_origins: vec![],
223 body_limit_bytes: 1_048_576,
224 shutdown_grace: std::time::Duration::from_secs(60),
225 retain_terminal_runs: std::time::Duration::from_secs(60),
226 idempotency_retention: std::time::Duration::from_secs(60),
227 log_retention: std::time::Duration::from_secs(0),
228 log_max_lines_per_run: 100_000,
229 lease_ttl: std::time::Duration::from_secs(30),
230 probe_timeout: std::time::Duration::from_secs(10),
231 env_file: None,
232 no_env_file: false,
233 log_level: "info".into(),
234 ui_enabled: true,
235 cluster: ClusterConfig::disabled(),
236 triggers_path: None,
237 callback_allow_hosts: Vec::new(),
238 };
239 let h = ClusterHandle::from_config(&cfg);
240 h.kick();
241 tokio::time::timeout(std::time::Duration::from_secs(1), h.kicked())
242 .await
243 .expect("kick must wake the waiter");
244 h.set_members(2);
245 assert_eq!(h.members(), 2);
246 }
247}