#![cfg(all(feature = "serve", feature = "serve-history-sqlite"))]
use faucet_cli::serve::history::sqlite::SqliteHistory;
use faucet_cli::serve::history::{RunHistory, RunRecord, RunStatus};
use std::time::Duration;
async fn backend(
dir: &tempfile::TempDir,
file: &str,
lease: Duration,
inst: &str,
) -> SqliteHistory {
let url = format!("sqlite:{}", dir.path().join(file).display());
SqliteHistory::connect(&url, Duration::from_secs(3600), lease, inst.to_string())
.await
.unwrap()
}
fn pending(id: &str) -> RunRecord {
let mut r = RunRecord::queued(
id.into(),
None,
Default::default(),
None,
chrono::Utc::now(),
);
r.status = RunStatus::Pending;
r.config_body = Some("version: 1".into());
r
}
#[tokio::test]
async fn failover_reassigns_a_dead_instances_run_without_double_claim() {
let dir = tempfile::tempdir().unwrap();
let a = backend(&dir, "fo.db", Duration::ZERO, "inst-a").await;
let b = backend(&dir, "fo.db", Duration::from_secs(3600), "inst-b").await;
a.upsert(&pending("r1")).await.unwrap();
let claimed_a = a.claim_pending(4).await.unwrap();
assert_eq!(claimed_a.len(), 1);
assert!(
b.claim_pending(4).await.unwrap().is_empty(),
"no double-claim"
);
let report = b.reclaim_orphans(3).await.unwrap();
assert_eq!((report.requeued, report.failed), (1, 0));
assert_eq!(
a.get("r1").await.unwrap().unwrap().status,
RunStatus::Pending
);
let claimed_b = b.claim_pending(4).await.unwrap();
assert_eq!(claimed_b.len(), 1);
let mut term = b.get("r1").await.unwrap().unwrap();
term.status = RunStatus::Completed;
assert!(b.finalize_owned(&term).await.unwrap(), "owner b finalizes");
assert_eq!(
a.get("r1").await.unwrap().unwrap().status,
RunStatus::Completed
);
}
#[tokio::test]
async fn concurrent_claims_partition_the_pending_set() {
let dir = tempfile::tempdir().unwrap();
let a = backend(&dir, "part.db", Duration::from_secs(3600), "inst-a").await;
let b = backend(&dir, "part.db", Duration::from_secs(3600), "inst-b").await;
for i in 0..10 {
a.upsert(&pending(&format!("r{i}"))).await.unwrap();
}
let (ra, rb) = tokio::join!(a.claim_pending(10), b.claim_pending(10));
let ca = ra.unwrap();
let cb = rb.unwrap();
assert_eq!(ca.len() + cb.len(), 10, "every run claimed exactly once");
let mut ids: Vec<String> = ca.iter().chain(&cb).map(|r| r.run_id.clone()).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 10, "no run claimed by both instances");
}
#[cfg(unix)]
#[tokio::test]
async fn two_process_cluster_reassigns_on_kill() {
if std::env::var_os("CARGO_LLVM_COV").is_some() {
eprintln!(
"skipping two_process_cluster_reassigns_on_kill under cargo-llvm-cov \
(instrumented spawned binaries break the lease timing; the Test job runs it)"
);
return;
}
use std::process::{Child, Command};
use tokio::time::sleep;
struct Killer(Child);
impl Drop for Killer {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let bin = env!("CARGO_BIN_EXE_faucet");
let dir = tempfile::tempdir().unwrap();
let db = format!("sqlite:{}", dir.path().join("cluster.db").display());
let out = dir.path().join("out.jsonl");
let input = dir.path().join("in.csv");
std::fs::write(&input, "id\n1\n").unwrap();
let config = format!(
"version: 1\npipeline:\n source: {{ type: csv, config: {{ path: \"{}\" }} }}\n sink: {{ type: jsonl, config: {{ path: \"{}\", append: true }} }}\n",
input.display(),
out.display(),
);
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
let port_a = free_port();
let port_b = free_port();
let spawn = |port: u16| {
Killer(
Command::new(bin)
.args([
"serve",
"--no-auth",
"--cluster",
"--history",
&db,
"--listen",
&format!("127.0.0.1:{port}"),
"--lease-ttl-secs",
"10",
"--cluster-poll-secs",
"1",
])
.env("FAUCET_LOG", "warn")
.spawn()
.expect("spawn faucet serve"),
)
};
let client = reqwest::Client::new();
let wait_healthy = |port: u16| {
let client = client.clone();
async move {
for _ in 0..200 {
if client
.get(format!("http://127.0.0.1:{port}/healthz"))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
{
return true;
}
sleep(Duration::from_millis(50)).await;
}
false
}
};
let mut a = spawn(port_a);
let b = spawn(port_b);
assert!(wait_healthy(port_a).await, "instance A became healthy");
assert!(wait_healthy(port_b).await, "instance B became healthy");
let mut run_ids = Vec::new();
for _ in 0..5 {
let mut v = None;
for _ in 0..100 {
let resp = client
.post(format!("http://127.0.0.1:{port_a}/v1/runs"))
.json(&serde_json::json!({ "config": config, "config_format": "yaml" }))
.send()
.await
.unwrap();
if resp.status() == 429 || resp.status() == 503 {
sleep(Duration::from_millis(100)).await;
continue;
}
assert_eq!(resp.status(), 202, "submit accepted");
v = Some(resp.json::<serde_json::Value>().await.unwrap());
break;
}
let v = v.expect("submit accepted within retry budget");
run_ids.push(v["run_id"].as_str().unwrap().to_string());
}
let _ = a.0.kill();
let _ = a.0.wait();
let deadline = std::time::Instant::now() + Duration::from_secs(90);
loop {
let mut all_done = true;
for id in &run_ids {
let v: serde_json::Value = client
.get(format!("http://127.0.0.1:{port_b}/v1/runs/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let status = v["status"].as_str().unwrap_or("");
if !matches!(status, "completed" | "failed" | "cancelled") {
all_done = false;
break;
}
}
if all_done || std::time::Instant::now() > deadline {
break;
}
sleep(Duration::from_millis(500)).await;
}
let mut terminal = 0;
for id in &run_ids {
let v: serde_json::Value = client
.get(format!("http://127.0.0.1:{port_b}/v1/runs/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
if matches!(
v["status"].as_str().unwrap_or(""),
"completed" | "failed" | "cancelled"
) {
terminal += 1;
}
}
drop(b); assert_eq!(
terminal,
run_ids.len(),
"every run reached a terminal state on the survivor B"
);
}