Files
compliance-scanner-agent/compliance-agent/tests/werkbank_queue.rs
T
Sharang ParnerkarandClaude Fable 5 25f232774e
CI / Check (pull_request) Successful in 5m49s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
feat(werkbank): Mongo-backed job queue with lease + visibility timeout (WB-02)
The control-plane pull queue behind the Werkbank runner flow (implements
sharang/werkbank#3). A JobQueue over a `werkbank_jobs` collection:

- enqueue — idempotent by job id (unique index; duplicate is a no-op)
- lease — atomic find-and-modify of the oldest queued job the runner can run,
  matched by executor and by labels (job labels must be a subset of the runner's,
  empty/absent matches any), returns the job + a lease token, bumps attempts
- heartbeat — extends the lease, flips leased→running, surfaces a cancel request;
  None means the lease was lost (token mismatch / already terminal)
- complete — records the terminal result, token-guarded and only from an active
  state, so it's idempotent
- cancel — queued→cancelled outright, in-flight flagged for the next heartbeat
- sweep_expired — the visibility timeout: expired leases go back to queued, or to
  expired once attempts hit max, so a crashed runner's job recovers

All transitions are single atomic Mongo updates guarded by the lease token, so two
runners can never both own a job. Every op takes an explicit `now` for
deterministic tests. Adds JobRecord/LeasedJob/HeartbeatAck to the contract (BSON
datetimes so range queries compare correctly) and the werkbank_jobs indexes.

Tests: 5 integration against a real Mongo (idempotent enqueue, executor+label
matching + FIFO, heartbeat/cancel, token-guarded idempotent complete, sweep
requeue→expire; skip cleanly with no Mongo) + 2 unit. clippy + fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:07:31 +02:00

259 lines
7.8 KiB
Rust

//! Integration tests for the Werkbank job queue (WB-02).
//!
//! Exercises the atomic lease/heartbeat/complete/sweep flow against a real
//! MongoDB — the guarantees (idempotent enqueue, single-owner lease, visibility
//! timeout) are Mongo-semantics-dependent and can't be unit-tested in isolation.
//! Skips cleanly when no Mongo is reachable (set `TEST_MONGODB_URI` to point at
//! one; defaults to the local dev cluster).
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use compliance_agent::database::Database;
use compliance_agent::werkbank::JobQueue;
use compliance_core::models::werkbank::{Executor, InputRef, Job, JobResult};
/// Connect + ensure indexes on a throwaway database, or `None` if no Mongo.
async fn setup() -> Option<(JobQueue, mongodb::Database)> {
let uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let db_name = format!("wbq_{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let db = match Database::connect(&uri, &db_name).await {
Ok(d) => d,
Err(_) => {
eprintln!("SKIP werkbank_queue: no MongoDB reachable at {uri}");
return None;
}
};
db.ensure_indexes().await.expect("ensure indexes");
let queue = JobQueue::new(&db);
Some((queue, db.inner().clone()))
}
fn base_time() -> DateTime<Utc> {
Utc.timestamp_opt(1_700_000_000, 0).unwrap()
}
fn job(id: &str) -> Job {
Job::plc_provision(id, "acme", "target-1", InputRef::blob("sha256:abc"), 180)
}
fn job_with_labels(id: &str, labels: &[&str]) -> Job {
let mut j = job(id);
j.labels = labels.iter().map(|s| s.to_string()).collect();
j
}
macro_rules! skip_if_no_mongo {
() => {
match setup().await {
Some(v) => v,
None => return,
}
};
}
#[tokio::test]
async fn enqueue_is_idempotent() {
let (q, db) = skip_if_no_mongo!();
let now = base_time();
assert!(q.enqueue(job("j1"), now).await.expect("enqueue"));
// Same id again — no duplicate row, reports "already present".
assert!(!q.enqueue(job("j1"), now).await.expect("enqueue2"));
let rec = q.get("j1").await.expect("get").expect("exists");
assert_eq!(
rec.status,
compliance_core::models::werkbank::JobStatus::Queued
);
assert_eq!(rec.attempts, 0);
db.drop().await.ok();
}
#[tokio::test]
async fn lease_matches_executor_and_labels_and_is_fifo() {
let (q, db) = skip_if_no_mongo!();
let t0 = base_time();
// Two docker jobs (j_old older than j_new) + one requiring a kvm label.
q.enqueue(job("j_old"), t0).await.unwrap();
q.enqueue(job("j_new"), t0 + chrono::Duration::seconds(5))
.await
.unwrap();
q.enqueue(job_with_labels("j_kvm", &["kvm=true"]), t0)
.await
.unwrap();
// Wrong executor: a shell runner leases nothing.
assert!(q
.lease("r-shell", Executor::Shell, &[], Duration::from_secs(30), t0)
.await
.unwrap()
.is_none());
// A docker runner without the kvm label gets the oldest label-free job (FIFO).
let leased = q
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), t0)
.await
.unwrap()
.expect("leased");
assert_eq!(leased.job.id, "j_old", "oldest matching job first");
assert!(!leased.lease_token.is_empty());
// The kvm job stays unleased for that runner (missing label)...
let none = q
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), t0)
.await
.unwrap()
.expect("next");
assert_eq!(none.job.id, "j_new", "label-free job, not the kvm one");
// ...but a runner advertising kvm can take it.
let kvm = q
.lease(
"r2",
Executor::Docker,
&["kvm=true".to_string(), "arch=amd64".to_string()],
Duration::from_secs(30),
t0,
)
.await
.unwrap()
.expect("kvm leased");
assert_eq!(kvm.job.id, "j_kvm");
// A leased job increments attempts and is no longer queued.
let rec = q.get("j_old").await.unwrap().unwrap();
assert_eq!(rec.attempts, 1);
assert_eq!(rec.leased_by.as_deref(), Some("r1"));
db.drop().await.ok();
}
#[tokio::test]
async fn heartbeat_extends_lease_and_surfaces_cancel() {
let (q, db) = skip_if_no_mongo!();
let now = base_time();
q.enqueue(job("j1"), now).await.unwrap();
let leased = q
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), now)
.await
.unwrap()
.unwrap();
// A valid heartbeat moves it to running and reports not-cancelled.
let ack = q
.heartbeat("j1", &leased.lease_token, Duration::from_secs(30), now)
.await
.unwrap()
.expect("valid lease");
assert!(!ack.cancelled);
assert_eq!(
q.get("j1").await.unwrap().unwrap().status,
compliance_core::models::werkbank::JobStatus::Running
);
// A wrong token is a lost lease.
assert!(q
.heartbeat("j1", "wrong-token", Duration::from_secs(30), now)
.await
.unwrap()
.is_none());
// Cancelling an in-flight job flags it; the next heartbeat reports cancelled.
assert!(q.cancel("j1", now).await.unwrap());
let ack = q
.heartbeat("j1", &leased.lease_token, Duration::from_secs(30), now)
.await
.unwrap()
.expect("still leased");
assert!(ack.cancelled);
db.drop().await.ok();
}
#[tokio::test]
async fn complete_is_idempotent_and_token_guarded() {
let (q, db) = skip_if_no_mongo!();
let now = base_time();
q.enqueue(job("j1"), now).await.unwrap();
let leased = q
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), now)
.await
.unwrap()
.unwrap();
// Wrong token cannot complete.
let mut result = JobResult::succeeded("j1");
result.findings = Vec::new();
assert!(!q.complete("j1", "nope", &result, now).await.unwrap());
// The lease holder completes it once...
assert!(q
.complete("j1", &leased.lease_token, &result, now)
.await
.unwrap());
let rec = q.get("j1").await.unwrap().unwrap();
assert_eq!(
rec.status,
compliance_core::models::werkbank::JobStatus::Succeeded
);
assert!(rec.result.is_some());
assert!(rec.lease_token.is_none(), "lease cleared on completion");
// ...and a second (duplicate) completion is a no-op.
assert!(!q
.complete("j1", &leased.lease_token, &result, now)
.await
.unwrap());
db.drop().await.ok();
}
#[tokio::test]
async fn sweep_requeues_expired_then_expires_after_max_attempts() {
let (q, db) = skip_if_no_mongo!();
let t0 = base_time();
q.enqueue(job("j1"), t0).await.unwrap();
// Lease #1 with a 10s TTL; then time jumps past expiry.
q.lease("r1", Executor::Docker, &[], Duration::from_secs(10), t0)
.await
.unwrap()
.unwrap();
let past = t0 + chrono::Duration::seconds(60);
// attempts=1 < max=2 → requeued.
let swept = q.sweep_expired(past, 2).await.unwrap();
assert_eq!(swept.requeued, 1);
assert_eq!(swept.expired, 0);
assert_eq!(
q.get("j1").await.unwrap().unwrap().status,
compliance_core::models::werkbank::JobStatus::Queued
);
// Lease #2 (attempts=2), let it expire again → now expired (>= max).
q.lease("r2", Executor::Docker, &[], Duration::from_secs(10), past)
.await
.unwrap()
.unwrap();
let later = past + chrono::Duration::seconds(60);
let swept = q.sweep_expired(later, 2).await.unwrap();
assert_eq!(swept.requeued, 0);
assert_eq!(swept.expired, 1);
assert_eq!(
q.get("j1").await.unwrap().unwrap().status,
compliance_core::models::werkbank::JobStatus::Expired
);
db.drop().await.ok();
}