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