1use 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
19pub 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 #[cfg_attr(not(any(feature = "triggers", feature = "catalog")), allow(unused_mut))]
33 let mut api = Router::new()
34 .route("/v1/runs", post(runs::submit_run).get(runs::list_runs))
35 .route("/v1/runs/{id}", get(runs::get_run).delete(runs::delete_run))
36 .route("/v1/runs/{id}/cancel", post(runs::cancel_run))
37 .route("/v1/runs/{id}/logs", get(logs::stream_logs))
38 .route("/v1/schemas", get(schemas::list_schemas))
39 .route("/v1/schemas/{kind}/{name}", get(schemas::get_schema))
40 .route("/v1/doctor", post(doctor::doctor))
41 .route("/v1/backfill", post(backfill::submit_backfill))
42 .route("/v1/dlq/inspect", post(dlq::inspect))
43 .route("/v1/dlq/replay", post(dlq::replay))
44 .route("/v1/dlq/discard", post(dlq::discard))
45 .route("/v1/audit", get(audit::list_audit))
46 .route("/v1/reload", post(reload::reload));
47 #[cfg(feature = "triggers")]
48 {
49 api = api.route(
50 "/v1/triggers/{name}",
51 post(crate::serve::triggers::webhook::handle)
52 .put(crate::serve::triggers::webhook::handle),
53 );
54 }
55 #[cfg(feature = "catalog")]
56 {
57 use crate::serve::handlers::catalog;
58 api = api
59 .route("/v1/catalog/datasets", get(catalog::list_datasets))
60 .route("/v1/catalog/datasets/{id}", get(catalog::get_dataset))
61 .route("/v1/catalog/lineage", get(catalog::lineage));
62 }
63 #[cfg(feature = "mcp")]
67 if mcp.enabled {
68 api = api
69 .route("/mcp", post(crate::serve::mcp_route::handle))
70 .layer(axum::Extension(crate::serve::mcp_route::McpRouteFlags {
71 allow_mutations: mcp.allow_mutations,
72 }));
73 }
74
75 let api = api.route_layer(axum::middleware::from_fn_with_state(
76 state.clone(),
77 auth::require_auth,
78 ));
79
80 let cors = if config.cors_origins.is_empty() {
81 CorsLayer::new()
82 } else {
83 let origins: Vec<axum::http::HeaderValue> = config
84 .cors_origins
85 .iter()
86 .filter_map(|o| match o.parse() {
87 Ok(v) => Some(v),
88 Err(e) => {
89 tracing::warn!(origin = %o, error = %e, "ignoring invalid --cors-origin");
90 None
91 }
92 })
93 .collect();
94 CorsLayer::new().allow_origin(AllowOrigin::list(origins))
95 };
96
97 #[cfg_attr(not(feature = "serve-ui"), allow(unused_mut))]
98 let mut router = public.merge(api);
99
100 #[cfg(feature = "serve-ui")]
101 if config.ui_enabled {
102 use crate::serve::ui_assets;
103 router = router
104 .route("/", axum::routing::get(ui_assets::index))
105 .route("/assets/{*path}", axum::routing::get(ui_assets::asset))
106 .fallback(ui_assets::spa_fallback);
107 }
108
109 router
110 .layer(RequestBodyLimitLayer::new(config.body_limit_bytes))
111 .layer(axum::middleware::from_fn(metrics::track_metrics))
112 .layer(cors)
113 .with_state(state)
114}
115
116async fn load_default_base(config: &ServeConfig) -> CliResult<Option<Value>> {
119 match &config.default_config_path {
120 None => Ok(None),
121 Some(path) => {
122 let profile = std::env::var("FAUCET_PROFILE").ok();
124 let cfg =
125 crate::config::PipelineConfig::from_path_async(path, profile.as_deref()).await?;
126 Ok(Some(serde_json::to_value(&cfg).map_err(|e| {
127 CliError::Serve(format!("serializing --default-config: {e}"))
128 })?))
129 }
130 }
131}
132
133fn purge_interval(retain_terminal: Duration, idem_retention: Duration) -> Duration {
140 (retain_terminal.min(idem_retention) / 4)
141 .clamp(Duration::from_secs(60), Duration::from_secs(3600))
142}
143
144pub(crate) async fn maintenance_loop(
151 history: Arc<dyn RunHistory>,
152 retain: Duration,
153 period: Duration,
154 shutdown: CancellationToken,
155) {
156 let mut tick = tokio::time::interval(period);
157 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
158 tick.tick().await; loop {
160 tokio::select! {
161 _ = shutdown.cancelled() => break,
162 _ = tick.tick() => match history.purge_expired(retain).await {
163 Ok(n) if n > 0 => {
164 tracing::info!(purged = n, "purged expired run records / idempotency claims")
165 }
166 Ok(_) => {}
167 Err(e) => tracing::warn!(error = %e, "history purge_expired failed"),
168 },
169 }
170 }
171}
172
173fn lease_interval(lease_ttl: Duration) -> Duration {
176 (lease_ttl / 3).max(Duration::from_secs(1))
177}
178
179pub(crate) async fn lease_loop(state: ServerState, period: Duration, shutdown: CancellationToken) {
195 let cluster = state.cluster().clone();
196 let member_ttl = period.saturating_mul(3);
199 let mut tick = tokio::time::interval(period);
200 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
201 tick.tick().await; loop {
203 tokio::select! {
204 _ = shutdown.cancelled() => break,
205 _ = tick.tick() => {
206 if let Err(e) = state.history().renew_leases().await {
207 tracing::warn!(error = %e, "lease heartbeat (renew_leases) failed");
208 }
209 if cluster.enabled() {
210 let beat = crate::serve::history::InstanceHeartbeat {
212 started_at: cluster.started_at(),
213 listen: Some(cluster.listen().to_string()),
214 max_concurrent: cluster.max_concurrent(),
215 in_flight: state.registry().in_flight() as u32,
216 };
217 if let Err(e) = state.history().heartbeat_instance(&beat).await {
218 tracing::warn!(error = %e, "cluster: heartbeat_instance failed");
219 }
220 match state.history().live_instances(member_ttl).await {
221 Ok(members) => {
222 cluster.set_members(members.len());
223 crate::serve::metrics::set_cluster_instances(members.len());
224 }
225 Err(e) => tracing::warn!(error = %e, "cluster: live_instances failed"),
226 }
227 match state.history().reclaim_orphans(cluster.max_attempts()).await {
229 Ok(r) if r.requeued > 0 || r.failed > 0 => {
230 crate::serve::metrics::record_runs_reclaimed(r.requeued, r.failed);
231 tracing::warn!(
232 requeued = r.requeued, failed = r.failed,
233 "cluster: reclaimed orphaned runs from an expired-lease instance"
234 );
235 }
236 Ok(_) => {}
237 Err(e) => tracing::warn!(error = %e, "cluster: reclaim_orphans failed"),
238 }
239 if let Err(e) = state.history().renew_shard_leases().await {
243 tracing::warn!(error = %e, "cluster: renew_shard_leases failed");
244 }
245 match state.history().reclaim_shards(cluster.max_attempts()).await {
246 Ok(r) if r.requeued > 0 || r.failed > 0 => {
247 crate::serve::metrics::record_shards_reclaimed(r.requeued, r.failed);
248 tracing::warn!(
249 requeued = r.requeued, failed = r.failed,
250 "cluster: reclaimed orphaned shards from an expired-lease instance"
251 );
252 }
253 Ok(_) => {}
254 Err(e) => tracing::warn!(error = %e, "cluster: reclaim_shards failed"),
255 }
256 match state.history().finalize_completed_sharded_parents().await {
263 Ok(n) if n > 0 => tracing::info!(
264 finalized = n,
265 "cluster: finalized completed sharded parent run(s) via sweep"
266 ),
267 Ok(_) => {}
268 Err(e) => {
269 tracing::warn!(error = %e, "cluster: finalize_completed_sharded_parents failed")
270 }
271 }
272 } else {
273 match state.history().recover_orphans().await {
275 Ok(n) if n > 0 => tracing::warn!(
276 recovered = n,
277 "recovered orphaned runs from an expired-lease instance"
278 ),
279 Ok(_) => {}
280 Err(e) => tracing::warn!(error = %e, "orphan recovery failed"),
281 }
282 }
283 }
284 }
285 }
286}
287
288pub async fn serve(config: ServeConfig, mcp: crate::serve::McpServeSettings) -> CliResult<()> {
291 let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
292 crate::serve::metrics::set_cluster_enabled(config.cluster.enabled);
293
294 let instance_id = uuid::Uuid::new_v4().to_string();
298 tracing::info!(
299 instance_id = %instance_id,
300 lease_ttl_secs = config.lease_ttl.as_secs(),
301 "faucet serve instance id"
302 );
303
304 let history = crate::serve::history::connect(
305 &config.history,
306 config.idempotency_retention,
307 config.lease_ttl,
308 &instance_id,
309 )
310 .await?;
311 if config.cluster.enabled {
312 let report = history
315 .reclaim_orphans(config.cluster.max_attempts)
316 .await
317 .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
318 if report.requeued > 0 || report.failed > 0 {
319 tracing::warn!(
320 requeued = report.requeued,
321 failed = report.failed,
322 "startup reclaim of orphaned runs from an expired-lease instance"
323 );
324 }
325 } else {
326 let recovered = history
327 .recover_orphans()
328 .await
329 .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
330 if recovered > 0 {
331 tracing::warn!(
332 recovered,
333 "marked orphaned non-terminal runs (expired owner lease) as failed"
334 );
335 }
336 }
337 let default_base = load_default_base(&config).await?;
338
339 #[cfg(feature = "triggers")]
342 let triggers = match &config.triggers_path {
343 Some(path) => {
344 crate::serve::triggers::metrics::describe();
347 Some(crate::serve::triggers::load_triggers(path).await?)
348 }
349 None => None,
350 };
351 #[cfg(feature = "triggers")]
352 let triggers_handle = match &triggers {
353 Some(c) => crate::serve::triggers::health::TriggersHandle::from_compiled(&c.triggers),
354 None => crate::serve::triggers::health::TriggersHandle::empty(),
355 };
356 #[cfg(not(feature = "triggers"))]
358 if config.triggers_path.is_some() {
359 return Err(CliError::Serve(
360 "--triggers requires a build with the `triggers` feature".into(),
361 ));
362 }
363
364 let shutdown = CancellationToken::new();
365 let state = ServerState::new(
366 &config,
367 prom,
368 shutdown.clone(),
369 history,
370 log_hub,
371 default_base,
372 #[cfg(feature = "triggers")]
373 triggers_handle,
374 );
375 let app = build_router(state.clone(), &config, &mcp);
376
377 let listener = tokio::net::TcpListener::bind(config.listen)
378 .await
379 .map_err(|e| CliError::Serve(format!("failed to bind {}: {e}", config.listen)))?;
380 let local = listener
381 .local_addr()
382 .map_err(|e| CliError::Serve(e.to_string()))?;
383 tracing::info!(listen = %local, "faucet serve listening");
384
385 let purge_period = purge_interval(config.retain_terminal_runs, config.idempotency_retention);
388 tracing::info!(
389 interval_secs = purge_period.as_secs(),
390 retain_secs = config.retain_terminal_runs.as_secs(),
391 "history maintenance task started"
392 );
393 let maintenance = tokio::spawn(maintenance_loop(
394 state.history(),
395 config.retain_terminal_runs,
396 purge_period,
397 shutdown.clone(),
398 ));
399
400 let lease_period = lease_interval(config.lease_ttl);
404 let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
405
406 let claim = if config.cluster.enabled {
408 tracing::info!(
409 poll_secs = config.cluster.poll.as_secs(),
410 max_attempts = config.cluster.max_attempts,
411 "cluster mode enabled; starting claim loop"
412 );
413 Some(tokio::spawn(crate::serve::cluster::claim_loop(
414 state.clone(),
415 shutdown.clone(),
416 )))
417 } else {
418 None
419 };
420
421 #[cfg(feature = "triggers")]
425 let trigger_handles = match &triggers {
426 Some(c) => {
427 tracing::info!(count = c.triggers.len(), "spawning trigger watchers");
428 crate::serve::triggers::spawn_watchers(state.clone(), c, shutdown.clone())
429 }
430 None => Vec::new(),
431 };
432
433 let drain_state = state.clone();
445 let drain_shutdown = shutdown.clone();
446 let drain_grace = config.shutdown_grace;
447 axum::serve(
448 listener,
449 app.into_make_service_with_connect_info::<SocketAddr>(),
450 )
451 .with_graceful_shutdown(async move {
452 wait_for_signal().await;
453 tracing::info!("shutdown signal received; draining in-flight runs");
454 if let Some(claim) = claim {
456 claim.abort();
457 }
458 let drained =
462 tokio::time::timeout(drain_grace, drain_state.registry().wait_drained()).await;
463 if drained.is_err() {
464 let remaining = drain_state.registry().in_flight();
465 tracing::warn!(remaining, "grace window expired; cancelling in-flight runs");
466 drain_shutdown.cancel();
467 }
468 })
469 .await
470 .map_err(|e| CliError::Serve(format!("server error: {e}")))?;
471
472 let _ = tokio::time::timeout(
477 crate::serve::runner::RUN_FLUSH_GRACE,
478 state.registry().wait_drained(),
479 )
480 .await;
481 maintenance.abort();
482 leases.abort();
483 #[cfg(feature = "triggers")]
484 for h in trigger_handles {
485 h.abort();
486 }
487 faucet_core::shutdown_otel();
490 tracing::info!("faucet serve stopped");
491 Ok(())
492}
493
494async fn wait_for_signal() {
496 #[cfg(unix)]
497 {
498 use tokio::signal::unix::{SignalKind, signal};
499 let mut term = match signal(SignalKind::terminate()) {
500 Ok(s) => s,
501 Err(_) => {
502 let _ = tokio::signal::ctrl_c().await;
503 return;
504 }
505 };
506 tokio::select! {
507 _ = tokio::signal::ctrl_c() => {}
508 _ = term.recv() => {}
509 }
510 }
511 #[cfg(not(unix))]
512 {
513 let _ = tokio::signal::ctrl_c().await;
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::serve::history::memory::MemoryHistory;
521 use crate::serve::history::{RunRecord, RunStatus};
522 use chrono::Utc;
523 use std::collections::BTreeMap;
524
525 #[test]
526 fn lease_interval_is_third_of_ttl_floored_at_one_sec() {
527 assert_eq!(
528 lease_interval(Duration::from_secs(30)),
529 Duration::from_secs(10)
530 );
531 assert_eq!(
532 lease_interval(Duration::from_secs(90)),
533 Duration::from_secs(30)
534 );
535 assert_eq!(
537 lease_interval(Duration::from_secs(1)),
538 Duration::from_secs(1)
539 );
540 assert_eq!(
541 lease_interval(Duration::from_secs(2)),
542 Duration::from_secs(1)
543 );
544 }
545
546 #[test]
547 fn purge_interval_is_quarter_of_shorter_window_clamped() {
548 assert_eq!(
550 purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
551 Duration::from_secs(3600)
552 );
553 assert_eq!(
555 purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
556 Duration::from_secs(60)
557 );
558 assert_eq!(
560 purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
561 Duration::from_secs(60)
562 );
563 assert_eq!(
565 purge_interval(Duration::from_secs(2400), Duration::from_secs(2400)),
566 Duration::from_secs(600)
567 );
568 }
569
570 #[tokio::test]
571 async fn maintenance_loop_purges_expired_terminal_runs() {
572 let history: Arc<dyn RunHistory> = Arc::new(MemoryHistory::new(Duration::from_secs(60)));
573
574 let mut old = RunRecord::queued(
577 "old".into(),
578 None,
579 BTreeMap::new(),
580 None,
581 Utc::now() - chrono::Duration::seconds(10),
582 );
583 old.status = RunStatus::Completed;
584 old.finished_at = Some(Utc::now() - chrono::Duration::seconds(10));
585 history.upsert(&old).await.unwrap();
586 let live = RunRecord::queued("live".into(), None, BTreeMap::new(), None, Utc::now());
587 history.upsert(&live).await.unwrap();
588
589 let shutdown = CancellationToken::new();
590 let handle = tokio::spawn(maintenance_loop(
591 history.clone(),
592 Duration::ZERO, Duration::from_millis(10), shutdown.clone(),
595 ));
596
597 tokio::time::sleep(Duration::from_millis(80)).await;
599 shutdown.cancel();
600 let _ = handle.await;
601
602 assert!(
603 history.get("old").await.unwrap().is_none(),
604 "expired terminal run should have been purged by the maintenance loop"
605 );
606 assert!(
607 history.get("live").await.unwrap().is_some(),
608 "non-terminal run must be kept"
609 );
610 }
611}