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