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    log_retain: Duration,
185    period: Duration,
186    shutdown: CancellationToken,
187) {
188    let mut tick = tokio::time::interval(period);
189    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
190    tick.tick().await; // consume the immediate first tick so we don't purge at t=0
191    loop {
192        tokio::select! {
193            _ = shutdown.cancelled() => break,
194            _ = tick.tick() => {
195                match history.purge_expired(retain).await {
196                    Ok(n) if n > 0 => {
197                        tracing::info!(purged = n, "purged expired run records / idempotency claims")
198                    }
199                    Ok(_) => {}
200                    Err(e) => tracing::warn!(error = %e, "history purge_expired failed"),
201                }
202                // Persistent run logs (#529) have their own retention window.
203                if !log_retain.is_zero() {
204                    match history.purge_run_logs(log_retain).await {
205                        Ok(n) if n > 0 => {
206                            crate::serve::metrics::inc_run_logs_purged(n);
207                            tracing::info!(purged = n, "purged expired run logs")
208                        }
209                        Ok(_) => {}
210                        Err(e) => tracing::warn!(error = %e, "history purge_run_logs failed"),
211                    }
212                }
213            },
214        }
215    }
216}
217
218/// The lease heartbeat / orphan-recovery cadence: one third of the lease TTL
219/// (so a run sees ≥2 renewals before its lease could expire), floored at 1s.
220fn lease_interval(lease_ttl: Duration) -> Duration {
221    (lease_ttl / 3).max(Duration::from_secs(1))
222}
223
224/// Background lease-maintenance loop (#146 H7). Every `period`:
225///
226/// 1. **Heartbeat** — renew this instance's own non-terminal runs' leases, so a
227///    peer never reclaims a run we are still executing.
228/// 2. **Recover** — fail any non-terminal run whose owning instance's lease has
229///    expired (a crashed/gone peer), so a survivor eventually cleans up orphans
230///    rather than waiting for the next process restart.
231///
232/// Renew runs *before* recover so this instance's leases are fresh when the
233/// expiry scan runs. For the in-memory backend both calls are no-ops.
234///
235/// In cluster mode (#197) the recover step is replaced by: a membership
236/// heartbeat, a live-member refresh (`member_ttl = period * 3`), and a
237/// failover `reclaim_orphans` that re-queues an expired-lease peer's runs
238/// (capped at `max_attempts`) rather than failing them outright.
239pub(crate) async fn lease_loop(state: ServerState, period: Duration, shutdown: CancellationToken) {
240    let cluster = state.cluster().clone();
241    // Member-liveness window ≈ the real lease TTL (period == lease_ttl/3), so a
242    // member must miss ~3 heartbeats before peers treat it as gone.
243    let member_ttl = period.saturating_mul(3);
244    let mut tick = tokio::time::interval(period);
245    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
246    tick.tick().await; // consume the immediate first tick
247    loop {
248        tokio::select! {
249            _ = shutdown.cancelled() => break,
250            _ = tick.tick() => {
251                if let Err(e) = state.history().renew_leases().await {
252                    tracing::warn!(error = %e, "lease heartbeat (renew_leases) failed");
253                }
254                if cluster.enabled() {
255                    // Membership heartbeat.
256                    let beat = crate::serve::history::InstanceHeartbeat {
257                        started_at: cluster.started_at(),
258                        listen: Some(cluster.listen().to_string()),
259                        max_concurrent: cluster.max_concurrent(),
260                        in_flight: state.registry().in_flight() as u32,
261                    };
262                    if let Err(e) = state.history().heartbeat_instance(&beat).await {
263                        tracing::warn!(error = %e, "cluster: heartbeat_instance failed");
264                    }
265                    match state.history().live_instances(member_ttl).await {
266                        Ok(members) => {
267                            cluster.set_members(members.len());
268                            crate::serve::metrics::set_cluster_instances(members.len());
269                        }
270                        Err(e) => tracing::warn!(error = %e, "cluster: live_instances failed"),
271                    }
272                    // Failover reclaim (re-run orphans).
273                    match state.history().reclaim_orphans(cluster.max_attempts()).await {
274                        Ok(r) if r.requeued > 0 || r.failed > 0 => {
275                            crate::serve::metrics::record_runs_reclaimed(r.requeued, r.failed);
276                            tracing::warn!(
277                                requeued = r.requeued, failed = r.failed,
278                                "cluster: reclaimed orphaned runs from an expired-lease instance"
279                            );
280                        }
281                        Ok(_) => {}
282                        Err(e) => tracing::warn!(error = %e, "cluster: reclaim_orphans failed"),
283                    }
284                    // Mode B (#230): heartbeat this instance's running shards, and
285                    // rebalance shards whose owner's lease expired (requeue →
286                    // another worker, or poison past max_attempts).
287                    if let Err(e) = state.history().renew_shard_leases().await {
288                        tracing::warn!(error = %e, "cluster: renew_shard_leases failed");
289                    }
290                    match state.history().reclaim_shards(cluster.max_attempts()).await {
291                        Ok(r) if r.requeued > 0 || r.failed > 0 => {
292                            crate::serve::metrics::record_shards_reclaimed(r.requeued, r.failed);
293                            tracing::warn!(
294                                requeued = r.requeued, failed = r.failed,
295                                "cluster: reclaimed orphaned shards from an expired-lease instance"
296                            );
297                        }
298                        Ok(_) => {}
299                        Err(e) => tracing::warn!(error = %e, "cluster: reclaim_shards failed"),
300                    }
301                    // Mode B (#230 / F11): finalize any `sharded` parent whose
302                    // shards are all terminal but which no shard task finalized
303                    // inline (e.g. the coordinator crashed after the last shard
304                    // completed on another instance). Status-fenced + metric
305                    // recorded inside the backend, so a parent already finalized by
306                    // `maybe_finalize_parent` is not re-finalized or double-counted.
307                    match state.history().finalize_completed_sharded_parents().await {
308                        Ok(n) if n > 0 => tracing::info!(
309                            finalized = n,
310                            "cluster: finalized completed sharded parent run(s) via sweep"
311                        ),
312                        Ok(_) => {}
313                        Err(e) => {
314                            tracing::warn!(error = %e, "cluster: finalize_completed_sharded_parents failed")
315                        }
316                    }
317                } else {
318                    // Single-instance: mark orphans failed (today's behavior).
319                    match state.history().recover_orphans().await {
320                        Ok(n) if n > 0 => tracing::warn!(
321                            recovered = n,
322                            "recovered orphaned runs from an expired-lease instance"
323                        ),
324                        Ok(_) => {}
325                        Err(e) => tracing::warn!(error = %e, "orphan recovery failed"),
326                    }
327                }
328            }
329        }
330    }
331}
332
333/// Boot the server: install observability, build state + router, bind, serve
334/// until SIGTERM/SIGINT, then drain in-flight runs up to the grace window.
335pub async fn serve(config: ServeConfig, mcp: crate::serve::McpServeSettings) -> CliResult<()> {
336    let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
337    crate::serve::metrics::set_cluster_enabled(config.cluster.enabled);
338
339    // This process's identity for run-ownership leases (#146 H7). A fresh id per
340    // process, so a restarted instance recovers its prior incarnation's runs only
341    // once their lease expires — never another live instance's heartbeated runs.
342    let instance_id = uuid::Uuid::new_v4().to_string();
343    tracing::info!(
344        instance_id = %instance_id,
345        lease_ttl_secs = config.lease_ttl.as_secs(),
346        "faucet serve instance id"
347    );
348
349    let history = crate::serve::history::connect(
350        &config.history,
351        config.idempotency_retention,
352        config.lease_ttl,
353        &instance_id,
354    )
355    .await?;
356    if config.cluster.enabled {
357        // Cluster mode: a restarting instance re-queues its prior incarnation's
358        // in-flight runs (capped) rather than failing them.
359        let report = history
360            .reclaim_orphans(config.cluster.max_attempts)
361            .await
362            .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
363        if report.requeued > 0 || report.failed > 0 {
364            tracing::warn!(
365                requeued = report.requeued,
366                failed = report.failed,
367                "startup reclaim of orphaned runs from an expired-lease instance"
368            );
369        }
370    } else {
371        let recovered = history
372            .recover_orphans()
373            .await
374            .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
375        if recovered > 0 {
376            tracing::warn!(
377                recovered,
378                "marked orphaned non-terminal runs (expired owner lease) as failed"
379            );
380        }
381    }
382    // Persistent run logs (#529): route captured (already-redacted) logs into the
383    // durable backend so they survive past the ephemeral SSE drain window. The
384    // in-memory backend stays ephemeral by default (persist only to a real DB).
385    if !config.log_retention.is_zero()
386        && !matches!(
387            config.history,
388            crate::serve::config::HistoryBackendSpec::Memory
389        )
390    {
391        log_hub.enable_persistence(history.clone(), config.log_max_lines_per_run);
392        tracing::info!(
393            retention_secs = config.log_retention.as_secs(),
394            max_lines_per_run = config.log_max_lines_per_run,
395            "persistent run logs enabled"
396        );
397    }
398
399    let default_base = load_default_base(&config).await?;
400
401    // Event-driven triggers (#196): load + validate the file (fail-fast), then
402    // build the shared handle (webhook table + health rows).
403    #[cfg(feature = "triggers")]
404    let triggers = match &config.triggers_path {
405        Some(path) => {
406            // Register HELP text for the trigger metric family once at startup so
407            // the series carry descriptions in `/metrics` (mirrors schedule).
408            crate::serve::triggers::metrics::describe();
409            Some(crate::serve::triggers::load_triggers(path).await?)
410        }
411        None => None,
412    };
413    #[cfg(feature = "triggers")]
414    let triggers_handle = match &triggers {
415        Some(c) => crate::serve::triggers::health::TriggersHandle::from_compiled(&c.triggers),
416        None => crate::serve::triggers::health::TriggersHandle::empty(),
417    };
418    // A `--triggers` path in a build without the feature is a clear error.
419    #[cfg(not(feature = "triggers"))]
420    if config.triggers_path.is_some() {
421        return Err(CliError::Serve(
422            "--triggers requires a build with the `triggers` feature".into(),
423        ));
424    }
425
426    let shutdown = CancellationToken::new();
427    let state = ServerState::new(
428        &config,
429        prom,
430        shutdown.clone(),
431        history,
432        log_hub,
433        default_base,
434        #[cfg(feature = "triggers")]
435        triggers_handle,
436    );
437    let app = build_router(state.clone(), &config, &mcp);
438
439    let listener = tokio::net::TcpListener::bind(config.listen)
440        .await
441        .map_err(|e| CliError::Serve(format!("failed to bind {}: {e}", config.listen)))?;
442    let local = listener
443        .local_addr()
444        .map_err(|e| CliError::Serve(e.to_string()))?;
445    tracing::info!(listen = %local, "faucet serve listening");
446
447    // Background history maintenance: bounds run-record / idempotency-claim
448    // growth and makes the retention knobs effective (audit #146 C4).
449    let purge_period = purge_interval(config.retain_terminal_runs, config.idempotency_retention);
450    tracing::info!(
451        interval_secs = purge_period.as_secs(),
452        retain_secs = config.retain_terminal_runs.as_secs(),
453        "history maintenance task started"
454    );
455    let maintenance = tokio::spawn(maintenance_loop(
456        state.history(),
457        config.retain_terminal_runs,
458        config.log_retention,
459        purge_period,
460        shutdown.clone(),
461    ));
462
463    // Lease heartbeat + cross-instance orphan recovery (#146 H7). Renews this
464    // instance's run leases and reclaims runs whose owning instance's lease has
465    // expired. A no-op for the in-memory backend.
466    let lease_period = lease_interval(config.lease_ttl);
467    let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
468
469    // Cluster claim loop: pulls Pending runs from the shared DB (cluster only).
470    let claim = if config.cluster.enabled {
471        tracing::info!(
472            poll_secs = config.cluster.poll.as_secs(),
473            max_attempts = config.cluster.max_attempts,
474            "cluster mode enabled; starting claim loop"
475        );
476        Some(tokio::spawn(crate::serve::cluster::claim_loop(
477            state.clone(),
478            shutdown.clone(),
479        )))
480    } else {
481        None
482    };
483
484    // Event-driven trigger watchers (#196): spawn one supervised task per enabled
485    // polling trigger (object_arrival / queue_depth). Webhook triggers are
486    // handled by the router — no watcher task needed for them.
487    #[cfg(feature = "triggers")]
488    let trigger_handles = match &triggers {
489        Some(c) => {
490            tracing::info!(count = c.triggers.len(), "spawning trigger watchers");
491            crate::serve::triggers::spawn_watchers(state.clone(), c, shutdown.clone())
492        }
493        None => Vec::new(),
494    };
495
496    // The HTTP graceful-shutdown future resolves on signal, then drives the run
497    // drain *inside itself* — this is load-bearing: `axum::serve(...).await` does
498    // not return until every open connection closes, and an open SSE
499    // `/v1/runs/{id}/logs` stream stays open until its run ends. If we deferred
500    // `shutdown.cancel()` to after the `.await` (as before), a long run with an
501    // open SSE stream would deadlock shutdown forever (audit #321 M9): axum waits
502    // on the SSE, the SSE waits on the run, the run waits on a cancel that never
503    // fires. Draining + cancelling from within the signal handler breaks that
504    // cycle — cancelled runs end, their SSE streams close, and axum can return.
505    // `into_make_service_with_connect_info` exposes the peer address so the auth
506    // layer can record a `source_ip` on audit records (#205).
507    let drain_state = state.clone();
508    let drain_shutdown = shutdown.clone();
509    let drain_grace = config.shutdown_grace;
510    axum::serve(
511        listener,
512        app.into_make_service_with_connect_info::<SocketAddr>(),
513    )
514    .with_graceful_shutdown(async move {
515        wait_for_signal().await;
516        tracing::info!("shutdown signal received; draining in-flight runs");
517        // Stop pulling NEW work the moment we begin draining.
518        if let Some(claim) = claim {
519            claim.abort();
520        }
521        // Grace for in-flight runs to finish naturally, then cooperatively
522        // cancel any still running so their sinks flush at the next page
523        // boundary AND their SSE streams close.
524        let drained =
525            tokio::time::timeout(drain_grace, drain_state.registry().wait_drained()).await;
526        if drained.is_err() {
527            let remaining = drain_state.registry().in_flight();
528            tracing::warn!(remaining, "grace window expired; cancelling in-flight runs");
529            drain_shutdown.cancel();
530        }
531    })
532    .await
533    .map_err(|e| CliError::Serve(format!("server error: {e}")))?;
534
535    // Serve has returned (connections closed). Give any just-cancelled runs the
536    // full cooperative-flush grace to write their terminal status / complete an
537    // S3 multipart upload — matching the pipeline's own `RUN_FLUSH_GRACE`, not
538    // the old hardcoded 5s that cut buffered sinks off early (audit #321 M8).
539    let _ = tokio::time::timeout(
540        crate::serve::runner::RUN_FLUSH_GRACE,
541        state.registry().wait_drained(),
542    )
543    .await;
544    maintenance.abort();
545    leases.abort();
546    #[cfg(feature = "triggers")]
547    for h in trigger_handles {
548        h.abort();
549    }
550    // Flush any buffered OTLP telemetry after in-flight runs drain (no-op without
551    // the `otel` feature).
552    faucet_core::shutdown_otel();
553    tracing::info!("faucet serve stopped");
554    Ok(())
555}
556
557/// Resolve on SIGTERM (Unix) or Ctrl-C (any platform).
558async fn wait_for_signal() {
559    #[cfg(unix)]
560    {
561        use tokio::signal::unix::{SignalKind, signal};
562        let mut term = match signal(SignalKind::terminate()) {
563            Ok(s) => s,
564            Err(_) => {
565                let _ = tokio::signal::ctrl_c().await;
566                return;
567            }
568        };
569        tokio::select! {
570            _ = tokio::signal::ctrl_c() => {}
571            _ = term.recv() => {}
572        }
573    }
574    #[cfg(not(unix))]
575    {
576        let _ = tokio::signal::ctrl_c().await;
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::serve::history::memory::MemoryHistory;
584    use crate::serve::history::{RunRecord, RunStatus};
585    use chrono::Utc;
586    use std::collections::BTreeMap;
587
588    #[test]
589    fn lease_interval_is_third_of_ttl_floored_at_one_sec() {
590        assert_eq!(
591            lease_interval(Duration::from_secs(30)),
592            Duration::from_secs(10)
593        );
594        assert_eq!(
595            lease_interval(Duration::from_secs(90)),
596            Duration::from_secs(30)
597        );
598        // Floor: a tiny TTL still heartbeats at least once per second.
599        assert_eq!(
600            lease_interval(Duration::from_secs(1)),
601            Duration::from_secs(1)
602        );
603        assert_eq!(
604            lease_interval(Duration::from_secs(2)),
605            Duration::from_secs(1)
606        );
607    }
608
609    #[test]
610    fn purge_interval_is_quarter_of_shorter_window_clamped() {
611        // Defaults (retain 7d, idem 1d) → min 1d, /4 = 6h → clamped to the 1h cap.
612        assert_eq!(
613            purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
614            Duration::from_secs(3600)
615        );
616        // A short idempotency window drives a faster cadence (but never below 60s).
617        assert_eq!(
618            purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
619            Duration::from_secs(60)
620        );
621        // Both tiny → the 60s floor.
622        assert_eq!(
623            purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
624            Duration::from_secs(60)
625        );
626        // A 40-minute window lands inside the range: 2400/4 = 600s.
627        assert_eq!(
628            purge_interval(Duration::from_secs(2400), Duration::from_secs(2400)),
629            Duration::from_secs(600)
630        );
631    }
632
633    #[tokio::test]
634    async fn maintenance_loop_purges_expired_terminal_runs() {
635        let history: Arc<dyn RunHistory> = Arc::new(MemoryHistory::new(Duration::from_secs(60)));
636
637        // An old terminal record (eligible for purge with retain=0) and a
638        // non-terminal one (must be kept).
639        let mut old = RunRecord::queued(
640            "old".into(),
641            None,
642            BTreeMap::new(),
643            None,
644            Utc::now() - chrono::Duration::seconds(10),
645        );
646        old.status = RunStatus::Completed;
647        old.finished_at = Some(Utc::now() - chrono::Duration::seconds(10));
648        history.upsert(&old).await.unwrap();
649        let live = RunRecord::queued("live".into(), None, BTreeMap::new(), None, Utc::now());
650        history.upsert(&live).await.unwrap();
651
652        let shutdown = CancellationToken::new();
653        let handle = tokio::spawn(maintenance_loop(
654            history.clone(),
655            Duration::ZERO,            // retain=0 → every terminal record is expired
656            Duration::ZERO,            // log_retain=0 → not exercised by this test
657            Duration::from_millis(10), // fast tick for the test
658            shutdown.clone(),
659        ));
660
661        // Allow several ticks (the first is consumed at t=0).
662        tokio::time::sleep(Duration::from_millis(80)).await;
663        shutdown.cancel();
664        let _ = handle.await;
665
666        assert!(
667            history.get("old").await.unwrap().is_none(),
668            "expired terminal run should have been purged by the maintenance loop"
669        );
670        assert!(
671            history.get("live").await.unwrap().is_some(),
672            "non-terminal run must be kept"
673        );
674    }
675}