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(
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 #[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 #[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
147async fn load_default_base(config: &ServeConfig) -> CliResult<Option<Value>> {
150 match &config.default_config_path {
151 None => Ok(None),
152 Some(path) => {
153 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
164fn 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
175pub(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; 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 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
218fn lease_interval(lease_ttl: Duration) -> Duration {
221 (lease_ttl / 3).max(Duration::from_secs(1))
222}
223
224pub(crate) async fn lease_loop(state: ServerState, period: Duration, shutdown: CancellationToken) {
240 let cluster = state.cluster().clone();
241 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; 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 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 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 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 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 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
333pub 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 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 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 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 #[cfg(feature = "triggers")]
404 let triggers = match &config.triggers_path {
405 Some(path) => {
406 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 #[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 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 let lease_period = lease_interval(config.lease_ttl);
467 let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
468
469 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 #[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 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 if let Some(claim) = claim {
519 claim.abort();
520 }
521 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 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 faucet_core::shutdown_otel();
553 tracing::info!("faucet serve stopped");
554 Ok(())
555}
556
557async 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 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 assert_eq!(
613 purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
614 Duration::from_secs(3600)
615 );
616 assert_eq!(
618 purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
619 Duration::from_secs(60)
620 );
621 assert_eq!(
623 purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
624 Duration::from_secs(60)
625 );
626 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 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, Duration::ZERO, Duration::from_millis(10), shutdown.clone(),
659 ));
660
661 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}