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