#![cfg(feature = "serve-history-sqlite")]
use chrono::{Duration as ChronoDuration, Utc};
use faucet_cli::serve::history::InstanceHeartbeat;
use faucet_cli::serve::history::sqlite::SqliteHistory;
use faucet_cli::serve::history::{
Claim, DeleteOutcome, ListFilter, RunHistory, RunRecord, RunStatus,
};
use std::collections::BTreeMap;
use std::time::Duration;
async fn store(dir: &tempfile::TempDir, file: &str) -> SqliteHistory {
store_with(dir, file, Duration::from_secs(3600), "test-instance").await
}
async fn store_with(
dir: &tempfile::TempDir,
file: &str,
lease_ttl: Duration,
instance: &str,
) -> SqliteHistory {
let path = dir.path().join(file);
SqliteHistory::connect(
&format!("sqlite:{}", path.display()),
Duration::from_secs(3600),
lease_ttl,
instance.to_string(),
)
.await
.expect("connect sqlite history")
}
fn rec(id: &str, status: RunStatus, submitted: chrono::DateTime<Utc>) -> RunRecord {
let mut r = RunRecord::queued(id.into(), None, BTreeMap::new(), None, submitted);
r.status = status;
if status.is_terminal() {
r.finished_at = Some(submitted);
}
r
}
#[tokio::test]
async fn upsert_get_and_missing() {
let dir = tempfile::tempdir().unwrap();
let h = store(&dir, "a.db").await;
let mut r = rec("run-1", RunStatus::Running, Utc::now());
r.name = Some("nightly".into());
r.records_written = 7;
h.upsert(&r).await.unwrap();
let got = h.get("run-1").await.unwrap().expect("present");
assert_eq!(got.run_id, "run-1");
assert_eq!(got.status, RunStatus::Running);
assert_eq!(got.name.as_deref(), Some("nightly"));
assert_eq!(got.records_written, 7);
assert!(h.get("missing").await.unwrap().is_none());
}
#[tokio::test]
async fn idempotency_fresh_replay_conflict_at_sql_layer() {
let dir = tempfile::tempdir().unwrap();
let h = store(&dir, "idem.db").await;
let w = Duration::from_secs(3600);
assert_eq!(
h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
Claim::Fresh
);
assert_eq!(
h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
Claim::Replay("run1".into())
);
assert_eq!(
h.claim_idempotency("k", "fp2", "run3", w).await.unwrap(),
Claim::Conflict
);
assert_eq!(
h.claim_idempotency("k2", "fpa", "r1", Duration::ZERO)
.await
.unwrap(),
Claim::Fresh
);
assert_eq!(
h.claim_idempotency("k2", "fpb", "r2", Duration::ZERO)
.await
.unwrap(),
Claim::Fresh
);
}
#[tokio::test]
async fn list_orders_desc_filters_and_paginates() {
let dir = tempfile::tempdir().unwrap();
let h = store(&dir, "list.db").await;
let t0 = Utc::now();
for (i, id) in ["a", "b", "c"].iter().enumerate() {
h.upsert(&rec(
id,
RunStatus::Completed,
t0 + ChronoDuration::seconds(i as i64),
))
.await
.unwrap();
}
let page = h
.list(&ListFilter {
limit: 2,
..Default::default()
})
.await
.unwrap();
assert_eq!(
page.runs
.iter()
.map(|r| r.run_id.clone())
.collect::<Vec<_>>(),
vec!["c", "b"]
);
assert_eq!(page.next_cursor.as_deref(), Some("b"));
let page2 = h
.list(&ListFilter {
limit: 2,
cursor: Some("b".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(
page2
.runs
.iter()
.map(|r| r.run_id.clone())
.collect::<Vec<_>>(),
vec!["a"]
);
assert!(page2.next_cursor.is_none());
h.upsert(&rec(
"x",
RunStatus::Failed,
t0 + ChronoDuration::seconds(10),
))
.await
.unwrap();
let failed = h
.list(&ListFilter {
status: Some(RunStatus::Failed),
limit: 50,
..Default::default()
})
.await
.unwrap();
assert_eq!(failed.runs.len(), 1);
assert_eq!(failed.runs[0].run_id, "x");
}
#[tokio::test]
async fn delete_respects_terminal_state() {
let dir = tempfile::tempdir().unwrap();
let h = store(&dir, "del.db").await;
h.upsert(&rec("run", RunStatus::Running, Utc::now()))
.await
.unwrap();
assert_eq!(h.delete("run").await.unwrap(), DeleteOutcome::StillRunning);
assert_eq!(h.delete("nope").await.unwrap(), DeleteOutcome::NotFound);
h.upsert(&rec("run", RunStatus::Completed, Utc::now()))
.await
.unwrap();
assert_eq!(h.delete("run").await.unwrap(), DeleteOutcome::Deleted);
assert!(h.get("run").await.unwrap().is_none());
}
#[tokio::test]
async fn recover_orphans_marks_expired_lease_non_terminal_failed() {
let dir = tempfile::tempdir().unwrap();
{
let h = store_with(&dir, "recover.db", Duration::ZERO, "inst-a").await;
h.upsert(&rec("orphan", RunStatus::Running, Utc::now()))
.await
.unwrap();
h.upsert(&rec("done", RunStatus::Completed, Utc::now()))
.await
.unwrap();
}
let h2 = store_with(&dir, "recover.db", Duration::from_secs(30), "inst-b").await;
let recovered = h2.recover_orphans().await.unwrap();
assert_eq!(
recovered, 1,
"only the non-terminal expired-lease run is recovered"
);
let orphan = h2.get("orphan").await.unwrap().unwrap();
assert_eq!(orphan.status, RunStatus::Failed);
assert!(orphan.error.as_deref().unwrap().contains("lease expired"));
assert_eq!(
h2.get("done").await.unwrap().unwrap().status,
RunStatus::Completed
);
assert_eq!(h2.recover_orphans().await.unwrap(), 0);
}
#[tokio::test]
async fn recover_orphans_skips_live_lease_of_another_instance() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(&dir, "fence.db", Duration::from_secs(3600), "inst-a").await;
a.upsert(&rec("a-run", RunStatus::Running, Utc::now()))
.await
.unwrap();
let b = store_with(&dir, "fence.db", Duration::from_secs(3600), "inst-b").await;
assert_eq!(
b.recover_orphans().await.unwrap(),
0,
"a live peer's run must not be recovered"
);
assert_eq!(
b.get("a-run").await.unwrap().unwrap().status,
RunStatus::Running,
"the peer's run must still be Running"
);
}
#[tokio::test]
async fn renew_leases_is_owner_and_status_scoped() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(&dir, "renew.db", Duration::from_secs(3600), "inst-a").await;
a.upsert(&rec("a-running", RunStatus::Running, Utc::now()))
.await
.unwrap();
a.upsert(&rec("a-done", RunStatus::Completed, Utc::now()))
.await
.unwrap();
assert_eq!(a.renew_leases().await.unwrap(), 1);
let b = store_with(&dir, "renew.db", Duration::from_secs(3600), "inst-b").await;
assert_eq!(b.renew_leases().await.unwrap(), 0);
}
#[tokio::test]
async fn renew_leases_protects_a_run_from_recovery() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(&dir, "protect.db", Duration::ZERO, "inst-a").await;
a.upsert(&rec("a-run", RunStatus::Running, Utc::now()))
.await
.unwrap();
let b = store_with(&dir, "protect.db", Duration::from_secs(3600), "inst-b").await;
let a_live = store_with(&dir, "protect.db", Duration::from_secs(3600), "inst-a").await;
assert_eq!(a_live.renew_leases().await.unwrap(), 1);
assert_eq!(
b.recover_orphans().await.unwrap(),
0,
"the heartbeat extended the lease, so the run must no longer be an orphan"
);
assert_eq!(
b.get("a-run").await.unwrap().unwrap().status,
RunStatus::Running
);
}
#[tokio::test(flavor = "multi_thread")]
async fn server_with_sqlite_history_persists_runs() {
use faucet_cli::cli::ServeArgs;
use faucet_cli::serve::ServeConfig;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("serve.db");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let args = ServeArgs {
listen: format!("127.0.0.1:{port}"),
auth_token: None,
no_auth: true,
max_concurrent_runs: Some(2),
max_queued_runs: Some(8),
default_config: None,
history: Some(format!("sqlite:{}", db.display())),
cors_origin: vec![],
body_limit_bytes: 1_048_576,
shutdown_grace_secs: 5,
retain_terminal_runs_secs: 604_800,
idempotency_retention_secs: 86_400,
lease_ttl_secs: 30,
probe_timeout_secs: 5,
env_file: None,
no_env_file: true,
no_ui: false,
cluster: false,
cluster_poll_secs: 2,
cluster_max_attempts: 3,
triggers: None,
};
let mut config = ServeConfig::from_args(args).unwrap();
config.log_level = "warn".into();
tokio::spawn(async move {
let _ = faucet_cli::serve::run_server(config).await;
});
let client = reqwest::Client::new();
let base = format!("http://127.0.0.1:{port}");
let mut up = false;
for _ in 0..200 {
if client
.get(format!("{base}/healthz"))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
{
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(up, "server did not come up");
assert_eq!(
client
.get(format!("{base}/readyz"))
.send()
.await
.unwrap()
.status(),
200,
"readyz must be 200 with a healthy sqlite backend"
);
let body = serde_json::json!({
"config": "version: 1\npipeline:\n source: { type: csv, config: { path: in.csv } }\n sink: { type: jsonl, config: { path: out.jsonl } }\n"
});
let submit: serde_json::Value = client
.post(format!("{base}/v1/runs"))
.json(&body)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let run_id = submit["run_id"].as_str().unwrap().to_string();
let mut terminal = false;
for _ in 0..400 {
let rec: serde_json::Value = client
.get(format!("{base}/v1/runs/{run_id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
if matches!(
rec["status"].as_str().unwrap_or(""),
"completed" | "failed" | "cancelled"
) {
terminal = true;
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(terminal, "run never reached a terminal state");
let listed: serde_json::Value = client
.get(format!("{base}/v1/runs"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(
listed["runs"]
.as_array()
.unwrap()
.iter()
.any(|r| r["run_id"] == run_id),
"submitted run must be listed from the sqlite-backed history"
);
let h = store(&dir, "serve.db").await;
assert!(
h.get(&run_id).await.unwrap().is_some(),
"run must be physically present in the sqlite file"
);
}
#[tokio::test]
async fn purge_drops_expired_terminal_runs() {
let dir = tempfile::tempdir().unwrap();
let h = store(&dir, "purge.db").await;
h.upsert(&rec(
"old",
RunStatus::Completed,
Utc::now() - ChronoDuration::seconds(120),
))
.await
.unwrap();
h.upsert(&rec("live", RunStatus::Running, Utc::now()))
.await
.unwrap();
let removed = h.purge_expired(Duration::ZERO).await.unwrap();
assert_eq!(removed, 1);
assert!(h.get("old").await.unwrap().is_none());
assert!(h.get("live").await.unwrap().is_some());
}
#[tokio::test]
async fn delete_also_removes_matching_idem_claim_at_sql_layer() {
let dir = tempfile::tempdir().unwrap();
let h = store(&dir, "delete_idem.db").await;
let w = Duration::from_secs(3600);
assert_eq!(
h.claim_idempotency("k", "fp", "r1", w).await.unwrap(),
Claim::Fresh
);
let mut r = RunRecord::queued(
"r1".into(),
None,
BTreeMap::new(),
Some("k".into()),
Utc::now(),
);
r.status = RunStatus::Completed;
r.finished_at = Some(Utc::now());
h.upsert(&r).await.unwrap();
assert_eq!(h.delete("r1").await.unwrap(), DeleteOutcome::Deleted);
assert_eq!(
h.claim_idempotency("k", "fp", "r2", w).await.unwrap(),
Claim::Fresh
);
}
#[tokio::test]
async fn claim_pending_is_exclusive_across_instances() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(
&dir,
"claim.db",
std::time::Duration::from_secs(30),
"inst-a",
)
.await;
let b = store_with(
&dir,
"claim.db",
std::time::Duration::from_secs(30),
"inst-b",
)
.await;
let mut p = rec("p1", RunStatus::Pending, Utc::now());
p.config_body = Some("version: 1".into());
a.upsert(&p).await.unwrap();
let (ra, rb) = tokio::join!(a.claim_pending(8), b.claim_pending(8));
let got_a = ra.unwrap();
let got_b = rb.unwrap();
assert_eq!(
got_a.len() + got_b.len(),
1,
"exactly one instance claims it"
);
let stored = a.get("p1").await.unwrap().unwrap();
assert_eq!(stored.status, RunStatus::Running);
let claimed = got_a.into_iter().chain(got_b).next().unwrap();
assert_eq!(claimed.config_body.as_deref(), Some("version: 1"));
}
#[tokio::test]
async fn reclaim_requeues_then_poisons_at_cap() {
let dir = tempfile::tempdir().unwrap();
let h = store_with(&dir, "reclaim.db", std::time::Duration::ZERO, "inst-a").await;
let mut r = rec("o1", RunStatus::Running, Utc::now());
r.config_body = Some("version: 1".into());
h.upsert(&r).await.unwrap();
let rep = h.reclaim_orphans(2).await.unwrap();
assert_eq!((rep.requeued, rep.failed), (1, 0));
let after = h.get("o1").await.unwrap().unwrap();
assert_eq!(after.status, RunStatus::Pending);
assert_eq!(after.attempt, 1);
let mut again = after;
again.status = RunStatus::Running;
h.upsert(&again).await.unwrap();
let rep2 = h.reclaim_orphans(2).await.unwrap();
assert_eq!((rep2.requeued, rep2.failed), (1, 0));
let after2 = h.get("o1").await.unwrap().unwrap();
assert_eq!(after2.attempt, 2);
let mut again2 = after2;
again2.status = RunStatus::Running;
h.upsert(&again2).await.unwrap();
let rep3 = h.reclaim_orphans(2).await.unwrap();
assert_eq!((rep3.requeued, rep3.failed), (0, 1));
let dead = h.get("o1").await.unwrap().unwrap();
assert_eq!(dead.status, RunStatus::Failed);
assert!(dead.error.unwrap().contains("reclaimed"));
}
#[tokio::test]
async fn membership_heartbeat_and_liveness() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(
&dir,
"members.db",
std::time::Duration::from_secs(30),
"inst-a",
)
.await;
let b = store_with(
&dir,
"members.db",
std::time::Duration::from_secs(30),
"inst-b",
)
.await;
let beat = |n: u32| InstanceHeartbeat {
started_at: Utc::now(),
listen: Some("127.0.0.1:8080".into()),
max_concurrent: 4,
in_flight: n,
};
a.heartbeat_instance(&beat(1)).await.unwrap();
b.heartbeat_instance(&beat(0)).await.unwrap();
let live = a
.live_instances(std::time::Duration::from_secs(60))
.await
.unwrap();
assert_eq!(live.len(), 2);
let none = a.live_instances(std::time::Duration::ZERO).await.unwrap();
assert_eq!(none.len(), 0);
}
#[tokio::test]
async fn finalize_owned_is_owner_fenced() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(
&dir,
"fence.db",
std::time::Duration::from_secs(30),
"inst-a",
)
.await;
let b = store_with(
&dir,
"fence.db",
std::time::Duration::from_secs(30),
"inst-b",
)
.await;
let r = rec("f1", RunStatus::Running, Utc::now());
a.upsert(&r).await.unwrap();
let mut term = a.get("f1").await.unwrap().unwrap();
term.status = RunStatus::Completed;
assert!(
!b.finalize_owned(&term).await.unwrap(),
"non-owner is fenced"
);
assert_eq!(
a.get("f1").await.unwrap().unwrap().status,
RunStatus::Running
);
assert!(a.finalize_owned(&term).await.unwrap());
assert_eq!(
a.get("f1").await.unwrap().unwrap().status,
RunStatus::Completed
);
}
#[tokio::test]
async fn cross_instance_cancel_flag_and_pickup() {
let dir = tempfile::tempdir().unwrap();
let a = store_with(
&dir,
"cancel.db",
std::time::Duration::from_secs(30),
"inst-a",
)
.await;
let b = store_with(
&dir,
"cancel.db",
std::time::Duration::from_secs(30),
"inst-b",
)
.await;
a.upsert(&rec("r1", RunStatus::Running, Utc::now()))
.await
.unwrap();
b.request_cancel("r1").await.unwrap();
assert_eq!(
a.pending_cancellations().await.unwrap(),
vec!["r1".to_string()]
);
assert!(
b.pending_cancellations().await.unwrap().is_empty(),
"b owns nothing"
);
a.upsert(&rec("p2", RunStatus::Pending, Utc::now()))
.await
.unwrap();
assert!(a.cancel_pending("p2").await.unwrap());
assert_eq!(
a.get("p2").await.unwrap().unwrap().status,
RunStatus::Cancelled
);
assert!(!a.cancel_pending("r1").await.unwrap());
}