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