CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m31s
CI / Deploy Dashboard (push) Successful in 2m38s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m47s
310 lines
11 KiB
Rust
310 lines
11 KiB
Rust
//! The Mongo-backed Werkbank job queue (WB-02).
|
|
//!
|
|
//! A pull queue: the control plane [`enqueue`](JobQueue::enqueue)s jobs; a runner
|
|
//! [`lease`](JobQueue::lease)s the oldest queued job it can run (matched by
|
|
//! executor + labels), [`heartbeat`](JobQueue::heartbeat)s while it works, and
|
|
//! [`complete`](JobQueue::complete)s it. Leases carry a visibility timeout: if a
|
|
//! runner dies mid-job its heartbeats stop, the lease expires, and
|
|
//! [`sweep_expired`](JobQueue::sweep_expired) returns the job to `queued` (or
|
|
//! `expired` once it has been retried too many times).
|
|
//!
|
|
//! All state transitions are single atomic Mongo updates guarded by the lease
|
|
//! token, so two runners can never both own a job. Every operation takes an
|
|
//! explicit `now` so the queue's time-dependent behaviour is deterministically
|
|
//! testable.
|
|
|
|
use std::time::Duration;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use mongodb::bson::{doc, Bson, DateTime as BsonDateTime};
|
|
use mongodb::error::{ErrorKind, WriteFailure};
|
|
use mongodb::options::ReturnDocument;
|
|
use mongodb::Collection;
|
|
|
|
use compliance_core::models::werkbank::{
|
|
Executor, HeartbeatAck, Job, JobRecord, JobResult, JobStatus, LeasedJob,
|
|
};
|
|
|
|
use crate::database::Database;
|
|
use crate::error::AgentError;
|
|
|
|
/// The non-terminal states a job can be swept or cancelled from.
|
|
const ACTIVE_STATES: [&str; 2] = ["leased", "running"];
|
|
/// Every terminal state (no further transitions).
|
|
const TERMINAL_STATES: [&str; 4] = ["succeeded", "failed", "expired", "cancelled"];
|
|
|
|
/// What a visibility-timeout sweep did.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct SweepOutcome {
|
|
/// Expired-lease jobs returned to `queued` for another runner.
|
|
pub requeued: u64,
|
|
/// Jobs that had exhausted their attempts and were marked `expired`.
|
|
pub expired: u64,
|
|
}
|
|
|
|
/// The Mongo-backed job queue.
|
|
pub struct JobQueue {
|
|
coll: Collection<JobRecord>,
|
|
}
|
|
|
|
impl JobQueue {
|
|
/// Build a queue over a tenant database's `werkbank_jobs` collection.
|
|
pub fn new(db: &Database) -> Self {
|
|
Self {
|
|
coll: db.werkbank_jobs(),
|
|
}
|
|
}
|
|
|
|
/// Enqueue a job. Idempotent by job id: a job that is already present is a
|
|
/// no-op. Returns `true` if this call inserted it, `false` if it existed.
|
|
pub async fn enqueue(&self, job: Job, now: DateTime<Utc>) -> Result<bool, AgentError> {
|
|
let record = JobRecord::queued(job, now);
|
|
match self.coll.insert_one(&record).await {
|
|
Ok(_) => Ok(true),
|
|
Err(e) if is_duplicate_key(&e) => Ok(false),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Atomically lease the oldest `queued` job this runner can run — matched by
|
|
/// executor and by labels (every label the job requires must be one the
|
|
/// runner advertises). Returns the job plus a lease token, or `None` if
|
|
/// nothing is runnable.
|
|
pub async fn lease(
|
|
&self,
|
|
runner_id: &str,
|
|
executor: Executor,
|
|
runner_labels: &[String],
|
|
lease_ttl: Duration,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Option<LeasedJob>, AgentError> {
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
let expires = bson_dt(now + ttl(lease_ttl));
|
|
let executor_bson = mongodb::bson::to_bson(&executor).unwrap_or(Bson::Null);
|
|
|
|
let filter = doc! {
|
|
"status": "queued",
|
|
"cancel_requested": { "$ne": true },
|
|
"job.executor": executor_bson,
|
|
// Every label the job requires must be in the runner's set — i.e. the
|
|
// job has no label that is not offered by the runner. Absent/empty
|
|
// job labels match any runner.
|
|
"job.labels": { "$not": { "$elemMatch": { "$nin": runner_labels.to_vec() } } },
|
|
};
|
|
let update = doc! {
|
|
"$set": {
|
|
"status": "leased",
|
|
"lease_token": &token,
|
|
"leased_by": runner_id,
|
|
"lease_expires_at": expires,
|
|
"heartbeat_at": bson_dt(now),
|
|
"updated_at": bson_dt(now),
|
|
},
|
|
"$inc": { "attempts": 1 },
|
|
};
|
|
|
|
let record = self
|
|
.coll
|
|
.find_one_and_update(filter, update)
|
|
.sort(doc! { "created_at": 1 }) // FIFO
|
|
.return_document(ReturnDocument::After)
|
|
.await?;
|
|
Ok(record.map(|r| LeasedJob {
|
|
job: r.job,
|
|
lease_token: token,
|
|
}))
|
|
}
|
|
|
|
/// Extend a lease and report whether the job has been asked to cancel.
|
|
/// Transitions the job to `running` on the first heartbeat. Returns `None`
|
|
/// when the lease is no longer valid (token mismatch, or the job is already
|
|
/// terminal) — the runner should then abandon the work.
|
|
pub async fn heartbeat(
|
|
&self,
|
|
job_id: &str,
|
|
lease_token: &str,
|
|
lease_ttl: Duration,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Option<HeartbeatAck>, AgentError> {
|
|
let filter = doc! {
|
|
"job.id": job_id,
|
|
"lease_token": lease_token,
|
|
"status": { "$in": ACTIVE_STATES.to_vec() },
|
|
};
|
|
let update = doc! {
|
|
"$set": {
|
|
"status": "running",
|
|
"lease_expires_at": bson_dt(now + ttl(lease_ttl)),
|
|
"heartbeat_at": bson_dt(now),
|
|
"updated_at": bson_dt(now),
|
|
},
|
|
};
|
|
let record = self
|
|
.coll
|
|
.find_one_and_update(filter, update)
|
|
.return_document(ReturnDocument::After)
|
|
.await?;
|
|
Ok(record.map(|r| HeartbeatAck {
|
|
cancelled: r.cancel_requested,
|
|
}))
|
|
}
|
|
|
|
/// Record a job's terminal result. Guarded by the lease token and only from
|
|
/// an active (`leased`/`running`) state, so it is idempotent — a duplicate or
|
|
/// late submission after the job already finished matches nothing. Returns
|
|
/// `true` if this call recorded the result.
|
|
pub async fn complete(
|
|
&self,
|
|
job_id: &str,
|
|
lease_token: &str,
|
|
result: &JobResult,
|
|
now: DateTime<Utc>,
|
|
) -> Result<bool, AgentError> {
|
|
let status = result.status.unwrap_or(JobStatus::Failed);
|
|
let status_bson = mongodb::bson::to_bson(&status).unwrap_or(Bson::String("failed".into()));
|
|
let result_bson =
|
|
mongodb::bson::to_bson(result).map_err(|e| AgentError::Other(e.to_string()))?;
|
|
|
|
let filter = doc! {
|
|
"job.id": job_id,
|
|
"lease_token": lease_token,
|
|
"status": { "$in": ACTIVE_STATES.to_vec() },
|
|
};
|
|
let update = doc! {
|
|
"$set": {
|
|
"status": status_bson,
|
|
"result": result_bson,
|
|
"lease_token": Bson::Null,
|
|
"lease_expires_at": Bson::Null,
|
|
"updated_at": bson_dt(now),
|
|
},
|
|
};
|
|
let res = self.coll.update_one(filter, update).await?;
|
|
Ok(res.modified_count == 1)
|
|
}
|
|
|
|
/// Request cancellation of a job. A still-`queued` job is cancelled outright;
|
|
/// an in-flight one is flagged so the runner sees it on its next heartbeat and
|
|
/// tears down. Returns `true` if a non-terminal job matched.
|
|
pub async fn cancel(&self, job_id: &str, now: DateTime<Utc>) -> Result<bool, AgentError> {
|
|
let filter = doc! {
|
|
"job.id": job_id,
|
|
"status": { "$nin": TERMINAL_STATES.to_vec() },
|
|
};
|
|
// Pipeline update: flag cancellation, and if still queued flip straight to
|
|
// cancelled (nothing is running it).
|
|
let pipeline = vec![doc! {
|
|
"$set": {
|
|
"cancel_requested": true,
|
|
"status": {
|
|
"$cond": [ { "$eq": ["$status", "queued"] }, "cancelled", "$status" ]
|
|
},
|
|
"updated_at": bson_dt(now),
|
|
}
|
|
}];
|
|
let res = self.coll.update_one(filter, pipeline).await?;
|
|
Ok(res.matched_count == 1)
|
|
}
|
|
|
|
/// Sweep leases whose visibility timeout has elapsed: return them to `queued`
|
|
/// for another runner, or mark them `expired` once they have been leased
|
|
/// `max_attempts` times. This is what makes a crashed runner's job recover.
|
|
pub async fn sweep_expired(
|
|
&self,
|
|
now: DateTime<Utc>,
|
|
max_attempts: u32,
|
|
// (kept explicit rather than a const so callers can tune retry policy)
|
|
) -> Result<SweepOutcome, AgentError> {
|
|
let now_bson = bson_dt(now);
|
|
let max = i64::from(max_attempts);
|
|
|
|
let requeue = self
|
|
.coll
|
|
.update_many(
|
|
doc! {
|
|
"status": { "$in": ACTIVE_STATES.to_vec() },
|
|
"lease_expires_at": { "$lt": &now_bson },
|
|
"attempts": { "$lt": max },
|
|
},
|
|
doc! { "$set": {
|
|
"status": "queued",
|
|
"lease_token": Bson::Null,
|
|
"leased_by": Bson::Null,
|
|
"lease_expires_at": Bson::Null,
|
|
"updated_at": &now_bson,
|
|
} },
|
|
)
|
|
.await?;
|
|
|
|
let expire = self
|
|
.coll
|
|
.update_many(
|
|
doc! {
|
|
"status": { "$in": ACTIVE_STATES.to_vec() },
|
|
"lease_expires_at": { "$lt": &now_bson },
|
|
"attempts": { "$gte": max },
|
|
},
|
|
doc! { "$set": {
|
|
"status": "expired",
|
|
"lease_token": Bson::Null,
|
|
"lease_expires_at": Bson::Null,
|
|
"updated_at": &now_bson,
|
|
} },
|
|
)
|
|
.await?;
|
|
|
|
Ok(SweepOutcome {
|
|
requeued: requeue.modified_count,
|
|
expired: expire.modified_count,
|
|
})
|
|
}
|
|
|
|
/// Fetch a job record by job id (inspection / control-plane reads).
|
|
pub async fn get(&self, job_id: &str) -> Result<Option<JobRecord>, AgentError> {
|
|
Ok(self.coll.find_one(doc! { "job.id": job_id }).await?)
|
|
}
|
|
}
|
|
|
|
/// A `chrono::Duration` for a lease TTL, saturating rather than panicking on an
|
|
/// absurd input (`chrono::Duration::seconds` panics past its internal bound).
|
|
fn ttl(d: Duration) -> chrono::Duration {
|
|
let secs = i64::try_from(d.as_secs()).unwrap_or(i64::MAX);
|
|
chrono::Duration::try_seconds(secs).unwrap_or(chrono::Duration::MAX)
|
|
}
|
|
|
|
/// A chrono instant as a BSON date (so Mongo stores/compares it as a real date).
|
|
fn bson_dt(dt: DateTime<Utc>) -> BsonDateTime {
|
|
BsonDateTime::from_chrono(dt)
|
|
}
|
|
|
|
/// Whether a Mongo error is a duplicate-key (E11000) violation — a job with this
|
|
/// id is already enqueued.
|
|
fn is_duplicate_key(e: &mongodb::error::Error) -> bool {
|
|
match &*e.kind {
|
|
ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ttl_saturates_and_converts() {
|
|
assert_eq!(ttl(Duration::from_secs(30)), chrono::Duration::seconds(30));
|
|
// An absurd TTL saturates instead of panicking.
|
|
assert_eq!(ttl(Duration::from_secs(u64::MAX)), chrono::Duration::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn state_constants_are_disjoint() {
|
|
for s in ACTIVE_STATES {
|
|
assert!(
|
|
!TERMINAL_STATES.contains(&s),
|
|
"{s} cannot be both active and terminal"
|
|
);
|
|
}
|
|
}
|
|
}
|