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