Skip to main content

faucet_cli/serve/
server.rs

1//! axum router assembly and the bind / graceful-shutdown serve loop.
2
3use crate::error::{CliError, CliResult};
4use crate::serve::config::ServeConfig;
5use crate::serve::handlers::{doctor, health, logs, runs, schemas};
6use crate::serve::history::RunHistory;
7use crate::serve::state::ServerState;
8use crate::serve::{auth, metrics};
9use axum::Router;
10use axum::routing::{get, post};
11use serde_json::Value;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio_util::sync::CancellationToken;
15use tower_http::cors::{AllowOrigin, CorsLayer};
16use tower_http::limit::RequestBodyLimitLayer;
17
18/// Build the full router: unauthenticated probes + the bearer-guarded `/v1` API.
19pub fn build_router(state: ServerState, config: &ServeConfig) -> Router {
20    let public = Router::new()
21        .route("/healthz", get(health::healthz))
22        .route("/readyz", get(health::readyz))
23        .route("/metrics", get(health::metrics));
24
25    // `/v1` routes guarded by the bearer middleware via `route_layer` (only runs
26    // for matched routes; OPTIONS preflight is allowed through inside the layer).
27    #[cfg_attr(not(feature = "triggers"), allow(unused_mut))]
28    let mut api = Router::new()
29        .route("/v1/runs", post(runs::submit_run).get(runs::list_runs))
30        .route("/v1/runs/{id}", get(runs::get_run).delete(runs::delete_run))
31        .route("/v1/runs/{id}/cancel", post(runs::cancel_run))
32        .route("/v1/runs/{id}/logs", get(logs::stream_logs))
33        .route("/v1/schemas", get(schemas::list_schemas))
34        .route("/v1/schemas/{kind}/{name}", get(schemas::get_schema))
35        .route("/v1/doctor", post(doctor::doctor));
36    #[cfg(feature = "triggers")]
37    {
38        api = api.route(
39            "/v1/triggers/{name}",
40            post(crate::serve::triggers::webhook::handle)
41                .put(crate::serve::triggers::webhook::handle),
42        );
43    }
44    let api = api.route_layer(axum::middleware::from_fn_with_state(
45        state.clone(),
46        auth::require_auth,
47    ));
48
49    let cors = if config.cors_origins.is_empty() {
50        CorsLayer::new()
51    } else {
52        let origins: Vec<axum::http::HeaderValue> = config
53            .cors_origins
54            .iter()
55            .filter_map(|o| match o.parse() {
56                Ok(v) => Some(v),
57                Err(e) => {
58                    tracing::warn!(origin = %o, error = %e, "ignoring invalid --cors-origin");
59                    None
60                }
61            })
62            .collect();
63        CorsLayer::new().allow_origin(AllowOrigin::list(origins))
64    };
65
66    #[cfg_attr(not(feature = "serve-ui"), allow(unused_mut))]
67    let mut router = public.merge(api);
68
69    #[cfg(feature = "serve-ui")]
70    if config.ui_enabled {
71        use crate::serve::ui_assets;
72        router = router
73            .route("/", axum::routing::get(ui_assets::index))
74            .route("/assets/{*path}", axum::routing::get(ui_assets::asset))
75            .fallback(ui_assets::spa_fallback);
76    }
77
78    router
79        .layer(RequestBodyLimitLayer::new(config.body_limit_bytes))
80        .layer(axum::middleware::from_fn(metrics::track_metrics))
81        .layer(cors)
82        .with_state(state)
83}
84
85/// Load the optional `--default-config` once at startup, fully resolved, as a
86/// merge base `Value`.
87async fn load_default_base(config: &ServeConfig) -> CliResult<Option<Value>> {
88    match &config.default_config_path {
89        None => Ok(None),
90        Some(path) => {
91            // `serve` has no --profile flag; honour FAUCET_PROFILE from the environment directly.
92            let profile = std::env::var("FAUCET_PROFILE").ok();
93            let cfg =
94                crate::config::PipelineConfig::from_path_async(path, profile.as_deref()).await?;
95            Ok(Some(serde_json::to_value(&cfg).map_err(|e| {
96                CliError::Serve(format!("serializing --default-config: {e}"))
97            })?))
98        }
99    }
100}
101
102/// How often the background maintenance task purges expired history.
103///
104/// A quarter of the shorter of the two retention windows, clamped to
105/// `[60s, 1h]`: frequent enough to bound store growth (and to honour a short
106/// `idempotency_retention`) without churning on the multi-day default
107/// `retain_terminal_runs`.
108fn purge_interval(retain_terminal: Duration, idem_retention: Duration) -> Duration {
109    (retain_terminal.min(idem_retention) / 4)
110        .clamp(Duration::from_secs(60), Duration::from_secs(3600))
111}
112
113/// Background history-maintenance loop: every `period`, drop terminal run
114/// records older than `retain` and expired idempotency claims, until `shutdown`
115/// fires. Without this, the history store (in-memory `DashMap`s or the SQL
116/// `faucet_serve_runs` / `faucet_serve_idem` tables) grows without bound for the
117/// life of the process and the `--retain-terminal-runs-secs` /
118/// `--idempotency-retention-secs` knobs are inert (audit #146 C4).
119pub(crate) async fn maintenance_loop(
120    history: Arc<dyn RunHistory>,
121    retain: Duration,
122    period: Duration,
123    shutdown: CancellationToken,
124) {
125    let mut tick = tokio::time::interval(period);
126    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
127    tick.tick().await; // consume the immediate first tick so we don't purge at t=0
128    loop {
129        tokio::select! {
130            _ = shutdown.cancelled() => break,
131            _ = tick.tick() => match history.purge_expired(retain).await {
132                Ok(n) if n > 0 => {
133                    tracing::info!(purged = n, "purged expired run records / idempotency claims")
134                }
135                Ok(_) => {}
136                Err(e) => tracing::warn!(error = %e, "history purge_expired failed"),
137            },
138        }
139    }
140}
141
142/// The lease heartbeat / orphan-recovery cadence: one third of the lease TTL
143/// (so a run sees ≥2 renewals before its lease could expire), floored at 1s.
144fn lease_interval(lease_ttl: Duration) -> Duration {
145    (lease_ttl / 3).max(Duration::from_secs(1))
146}
147
148/// Background lease-maintenance loop (#146 H7). Every `period`:
149///
150/// 1. **Heartbeat** — renew this instance's own non-terminal runs' leases, so a
151///    peer never reclaims a run we are still executing.
152/// 2. **Recover** — fail any non-terminal run whose owning instance's lease has
153///    expired (a crashed/gone peer), so a survivor eventually cleans up orphans
154///    rather than waiting for the next process restart.
155///
156/// Renew runs *before* recover so this instance's leases are fresh when the
157/// expiry scan runs. For the in-memory backend both calls are no-ops.
158///
159/// In cluster mode (#197) the recover step is replaced by: a membership
160/// heartbeat, a live-member refresh (`member_ttl = period * 3`), and a
161/// failover `reclaim_orphans` that re-queues an expired-lease peer's runs
162/// (capped at `max_attempts`) rather than failing them outright.
163pub(crate) async fn lease_loop(state: ServerState, period: Duration, shutdown: CancellationToken) {
164    let cluster = state.cluster().clone();
165    // Member-liveness window ≈ the real lease TTL (period == lease_ttl/3), so a
166    // member must miss ~3 heartbeats before peers treat it as gone.
167    let member_ttl = period.saturating_mul(3);
168    let mut tick = tokio::time::interval(period);
169    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
170    tick.tick().await; // consume the immediate first tick
171    loop {
172        tokio::select! {
173            _ = shutdown.cancelled() => break,
174            _ = tick.tick() => {
175                if let Err(e) = state.history().renew_leases().await {
176                    tracing::warn!(error = %e, "lease heartbeat (renew_leases) failed");
177                }
178                if cluster.enabled() {
179                    // Membership heartbeat.
180                    let beat = crate::serve::history::InstanceHeartbeat {
181                        started_at: cluster.started_at(),
182                        listen: Some(cluster.listen().to_string()),
183                        max_concurrent: cluster.max_concurrent(),
184                        in_flight: state.registry().in_flight() as u32,
185                    };
186                    if let Err(e) = state.history().heartbeat_instance(&beat).await {
187                        tracing::warn!(error = %e, "cluster: heartbeat_instance failed");
188                    }
189                    match state.history().live_instances(member_ttl).await {
190                        Ok(members) => {
191                            cluster.set_members(members.len());
192                            crate::serve::metrics::set_cluster_instances(members.len());
193                        }
194                        Err(e) => tracing::warn!(error = %e, "cluster: live_instances failed"),
195                    }
196                    // Failover reclaim (re-run orphans).
197                    match state.history().reclaim_orphans(cluster.max_attempts()).await {
198                        Ok(r) if r.requeued > 0 || r.failed > 0 => {
199                            crate::serve::metrics::record_runs_reclaimed(r.requeued, r.failed);
200                            tracing::warn!(
201                                requeued = r.requeued, failed = r.failed,
202                                "cluster: reclaimed orphaned runs from an expired-lease instance"
203                            );
204                        }
205                        Ok(_) => {}
206                        Err(e) => tracing::warn!(error = %e, "cluster: reclaim_orphans failed"),
207                    }
208                    // Mode B (#230): heartbeat this instance's running shards, and
209                    // rebalance shards whose owner's lease expired (requeue →
210                    // another worker, or poison past max_attempts).
211                    if let Err(e) = state.history().renew_shard_leases().await {
212                        tracing::warn!(error = %e, "cluster: renew_shard_leases failed");
213                    }
214                    match state.history().reclaim_shards(cluster.max_attempts()).await {
215                        Ok(r) if r.requeued > 0 || r.failed > 0 => {
216                            crate::serve::metrics::record_shards_reclaimed(r.requeued, r.failed);
217                            tracing::warn!(
218                                requeued = r.requeued, failed = r.failed,
219                                "cluster: reclaimed orphaned shards from an expired-lease instance"
220                            );
221                        }
222                        Ok(_) => {}
223                        Err(e) => tracing::warn!(error = %e, "cluster: reclaim_shards failed"),
224                    }
225                    // Mode B (#230 / F11): finalize any `sharded` parent whose
226                    // shards are all terminal but which no shard task finalized
227                    // inline (e.g. the coordinator crashed after the last shard
228                    // completed on another instance). Status-fenced + metric
229                    // recorded inside the backend, so a parent already finalized by
230                    // `maybe_finalize_parent` is not re-finalized or double-counted.
231                    match state.history().finalize_completed_sharded_parents().await {
232                        Ok(n) if n > 0 => tracing::info!(
233                            finalized = n,
234                            "cluster: finalized completed sharded parent run(s) via sweep"
235                        ),
236                        Ok(_) => {}
237                        Err(e) => {
238                            tracing::warn!(error = %e, "cluster: finalize_completed_sharded_parents failed")
239                        }
240                    }
241                } else {
242                    // Single-instance: mark orphans failed (today's behavior).
243                    match state.history().recover_orphans().await {
244                        Ok(n) if n > 0 => tracing::warn!(
245                            recovered = n,
246                            "recovered orphaned runs from an expired-lease instance"
247                        ),
248                        Ok(_) => {}
249                        Err(e) => tracing::warn!(error = %e, "orphan recovery failed"),
250                    }
251                }
252            }
253        }
254    }
255}
256
257/// Boot the server: install observability, build state + router, bind, serve
258/// until SIGTERM/SIGINT, then drain in-flight runs up to the grace window.
259pub async fn serve(config: ServeConfig) -> CliResult<()> {
260    let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
261    crate::serve::metrics::set_cluster_enabled(config.cluster.enabled);
262
263    // This process's identity for run-ownership leases (#146 H7). A fresh id per
264    // process, so a restarted instance recovers its prior incarnation's runs only
265    // once their lease expires — never another live instance's heartbeated runs.
266    let instance_id = uuid::Uuid::new_v4().to_string();
267    tracing::info!(
268        instance_id = %instance_id,
269        lease_ttl_secs = config.lease_ttl.as_secs(),
270        "faucet serve instance id"
271    );
272
273    let history = crate::serve::history::connect(
274        &config.history,
275        config.idempotency_retention,
276        config.lease_ttl,
277        &instance_id,
278    )
279    .await?;
280    if config.cluster.enabled {
281        // Cluster mode: a restarting instance re-queues its prior incarnation's
282        // in-flight runs (capped) rather than failing them.
283        let report = history
284            .reclaim_orphans(config.cluster.max_attempts)
285            .await
286            .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
287        if report.requeued > 0 || report.failed > 0 {
288            tracing::warn!(
289                requeued = report.requeued,
290                failed = report.failed,
291                "startup reclaim of orphaned runs from an expired-lease instance"
292            );
293        }
294    } else {
295        let recovered = history
296            .recover_orphans()
297            .await
298            .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
299        if recovered > 0 {
300            tracing::warn!(
301                recovered,
302                "marked orphaned non-terminal runs (expired owner lease) as failed"
303            );
304        }
305    }
306    let default_base = load_default_base(&config).await?;
307
308    // Event-driven triggers (#196): load + validate the file (fail-fast), then
309    // build the shared handle (webhook table + health rows).
310    #[cfg(feature = "triggers")]
311    let triggers = match &config.triggers_path {
312        Some(path) => {
313            // Register HELP text for the trigger metric family once at startup so
314            // the series carry descriptions in `/metrics` (mirrors schedule).
315            crate::serve::triggers::metrics::describe();
316            Some(crate::serve::triggers::load_triggers(path).await?)
317        }
318        None => None,
319    };
320    #[cfg(feature = "triggers")]
321    let triggers_handle = match &triggers {
322        Some(c) => crate::serve::triggers::health::TriggersHandle::from_compiled(&c.triggers),
323        None => crate::serve::triggers::health::TriggersHandle::empty(),
324    };
325    // A `--triggers` path in a build without the feature is a clear error.
326    #[cfg(not(feature = "triggers"))]
327    if config.triggers_path.is_some() {
328        return Err(CliError::Serve(
329            "--triggers requires a build with the `triggers` feature".into(),
330        ));
331    }
332
333    let shutdown = CancellationToken::new();
334    let state = ServerState::new(
335        &config,
336        prom,
337        shutdown.clone(),
338        history,
339        log_hub,
340        default_base,
341        #[cfg(feature = "triggers")]
342        triggers_handle,
343    );
344    let app = build_router(state.clone(), &config);
345
346    let listener = tokio::net::TcpListener::bind(config.listen)
347        .await
348        .map_err(|e| CliError::Serve(format!("failed to bind {}: {e}", config.listen)))?;
349    let local = listener
350        .local_addr()
351        .map_err(|e| CliError::Serve(e.to_string()))?;
352    tracing::info!(listen = %local, "faucet serve listening");
353
354    // Background history maintenance: bounds run-record / idempotency-claim
355    // growth and makes the retention knobs effective (audit #146 C4).
356    let purge_period = purge_interval(config.retain_terminal_runs, config.idempotency_retention);
357    tracing::info!(
358        interval_secs = purge_period.as_secs(),
359        retain_secs = config.retain_terminal_runs.as_secs(),
360        "history maintenance task started"
361    );
362    let maintenance = tokio::spawn(maintenance_loop(
363        state.history(),
364        config.retain_terminal_runs,
365        purge_period,
366        shutdown.clone(),
367    ));
368
369    // Lease heartbeat + cross-instance orphan recovery (#146 H7). Renews this
370    // instance's run leases and reclaims runs whose owning instance's lease has
371    // expired. A no-op for the in-memory backend.
372    let lease_period = lease_interval(config.lease_ttl);
373    let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
374
375    // Cluster claim loop: pulls Pending runs from the shared DB (cluster only).
376    let claim = if config.cluster.enabled {
377        tracing::info!(
378            poll_secs = config.cluster.poll.as_secs(),
379            max_attempts = config.cluster.max_attempts,
380            "cluster mode enabled; starting claim loop"
381        );
382        Some(tokio::spawn(crate::serve::cluster::claim_loop(
383            state.clone(),
384            shutdown.clone(),
385        )))
386    } else {
387        None
388    };
389
390    // Event-driven trigger watchers (#196): spawn one supervised task per enabled
391    // polling trigger (object_arrival / queue_depth). Webhook triggers are
392    // handled by the router — no watcher task needed for them.
393    #[cfg(feature = "triggers")]
394    let trigger_handles = match &triggers {
395        Some(c) => {
396            tracing::info!(count = c.triggers.len(), "spawning trigger watchers");
397            crate::serve::triggers::spawn_watchers(state.clone(), c, shutdown.clone())
398        }
399        None => Vec::new(),
400    };
401
402    // The HTTP graceful-shutdown future resolves on signal and stops accepting
403    // new connections / drains in-flight HTTP — it does NOT cancel run tasks.
404    axum::serve(listener, app)
405        .with_graceful_shutdown(async move {
406            wait_for_signal().await;
407            tracing::info!("shutdown signal received; draining in-flight runs");
408        })
409        .await
410        .map_err(|e| CliError::Serve(format!("server error: {e}")))?;
411
412    // Stop pulling NEW work the moment we begin draining: abort the claim loop so
413    // a shutting-down instance drains its in-flight runs rather than claiming more.
414    // The lease loop keeps heartbeating in-flight runs during the drain so peers
415    // don't reclaim them mid-shutdown.
416    if let Some(claim) = claim {
417        claim.abort();
418    }
419
420    // Now drain run tasks: wait up to the grace window, then cancel the rest.
421    let drained =
422        tokio::time::timeout(config.shutdown_grace, state.registry().wait_drained()).await;
423    if drained.is_err() {
424        let remaining = state.registry().in_flight();
425        tracing::warn!(remaining, "grace window expired; cancelling in-flight runs");
426        shutdown.cancel();
427        // Give cancelled tasks a brief moment to write their terminal status.
428        let _ = tokio::time::timeout(
429            std::time::Duration::from_secs(5),
430            state.registry().wait_drained(),
431        )
432        .await;
433    }
434    maintenance.abort();
435    leases.abort();
436    #[cfg(feature = "triggers")]
437    for h in trigger_handles {
438        h.abort();
439    }
440    // Flush any buffered OTLP telemetry after in-flight runs drain (no-op without
441    // the `otel` feature).
442    faucet_core::shutdown_otel();
443    tracing::info!("faucet serve stopped");
444    Ok(())
445}
446
447/// Resolve on SIGTERM (Unix) or Ctrl-C (any platform).
448async fn wait_for_signal() {
449    #[cfg(unix)]
450    {
451        use tokio::signal::unix::{SignalKind, signal};
452        let mut term = match signal(SignalKind::terminate()) {
453            Ok(s) => s,
454            Err(_) => {
455                let _ = tokio::signal::ctrl_c().await;
456                return;
457            }
458        };
459        tokio::select! {
460            _ = tokio::signal::ctrl_c() => {}
461            _ = term.recv() => {}
462        }
463    }
464    #[cfg(not(unix))]
465    {
466        let _ = tokio::signal::ctrl_c().await;
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::serve::history::memory::MemoryHistory;
474    use crate::serve::history::{RunRecord, RunStatus};
475    use chrono::Utc;
476    use std::collections::BTreeMap;
477
478    #[test]
479    fn lease_interval_is_third_of_ttl_floored_at_one_sec() {
480        assert_eq!(
481            lease_interval(Duration::from_secs(30)),
482            Duration::from_secs(10)
483        );
484        assert_eq!(
485            lease_interval(Duration::from_secs(90)),
486            Duration::from_secs(30)
487        );
488        // Floor: a tiny TTL still heartbeats at least once per second.
489        assert_eq!(
490            lease_interval(Duration::from_secs(1)),
491            Duration::from_secs(1)
492        );
493        assert_eq!(
494            lease_interval(Duration::from_secs(2)),
495            Duration::from_secs(1)
496        );
497    }
498
499    #[test]
500    fn purge_interval_is_quarter_of_shorter_window_clamped() {
501        // Defaults (retain 7d, idem 1d) → min 1d, /4 = 6h → clamped to the 1h cap.
502        assert_eq!(
503            purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
504            Duration::from_secs(3600)
505        );
506        // A short idempotency window drives a faster cadence (but never below 60s).
507        assert_eq!(
508            purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
509            Duration::from_secs(60)
510        );
511        // Both tiny → the 60s floor.
512        assert_eq!(
513            purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
514            Duration::from_secs(60)
515        );
516        // A 40-minute window lands inside the range: 2400/4 = 600s.
517        assert_eq!(
518            purge_interval(Duration::from_secs(2400), Duration::from_secs(2400)),
519            Duration::from_secs(600)
520        );
521    }
522
523    #[tokio::test]
524    async fn maintenance_loop_purges_expired_terminal_runs() {
525        let history: Arc<dyn RunHistory> = Arc::new(MemoryHistory::new(Duration::from_secs(60)));
526
527        // An old terminal record (eligible for purge with retain=0) and a
528        // non-terminal one (must be kept).
529        let mut old = RunRecord::queued(
530            "old".into(),
531            None,
532            BTreeMap::new(),
533            None,
534            Utc::now() - chrono::Duration::seconds(10),
535        );
536        old.status = RunStatus::Completed;
537        old.finished_at = Some(Utc::now() - chrono::Duration::seconds(10));
538        history.upsert(&old).await.unwrap();
539        let live = RunRecord::queued("live".into(), None, BTreeMap::new(), None, Utc::now());
540        history.upsert(&live).await.unwrap();
541
542        let shutdown = CancellationToken::new();
543        let handle = tokio::spawn(maintenance_loop(
544            history.clone(),
545            Duration::ZERO,            // retain=0 → every terminal record is expired
546            Duration::from_millis(10), // fast tick for the test
547            shutdown.clone(),
548        ));
549
550        // Allow several ticks (the first is consumed at t=0).
551        tokio::time::sleep(Duration::from_millis(80)).await;
552        shutdown.cancel();
553        let _ = handle.await;
554
555        assert!(
556            history.get("old").await.unwrap().is_none(),
557            "expired terminal run should have been purged by the maintenance loop"
558        );
559        assert!(
560            history.get("live").await.unwrap().is_some(),
561            "non-terminal run must be kept"
562        );
563    }
564}