The queue mechanism (ADR-010 state machine over the in-memory job table, mem's third-owner equal-jitter curve + opts resolution) already landed implemented with the seam task's whole-engine scope (mem-engine-seam-tx Note 1); this task's work, per the disposition the notify/listen twin recorded, was reconciling the description's pin list against the existing suite and adding the missing engine-side unit pins — no production code changed, the realization verified correct as-found. Already pinned by the seam task (reconciled, recorded in Notes): lifecycle + stamps + resolved get_job values, claim ordering, extent clamps at entry, worker-id validation, the uniform validity predicate, late-heartbeat refusal + absolute-reset renewal, reclaim-eats-attempt + loser-ack-refuses, the pinned dead-letter default strings, dead-row get_job visibility, both-states sweep + retention + scoping, the tx stamp shapes, and the resolution legs + base-5 curve range/cap/spread. Added (6): the in-process claim-race atomicity pin (8 concurrent workers over 4 rows — every row handed exactly once through the guarded section, the description's claim-vs-claim bullet); the claim-vs-expiry boundary determinism pin (at now == deadline: the d < now reclaim predicate and the d >= now holder-op predicate are coherent — no reclaim, heartbeat/ack still valid; one second past: reclaimable with its attempt consumed; lapsed ids silently uncounted by ack_batch); the engine-side mirror of the suite's backoff_curve_equivalence row — base 2 s at [1,2]/[2,4]/[4,8] driven end-to-end through claim -> retry(None) -> get_job's run_at with the Some(5)-verbatim and Some(0)-ready-now override legs, exact under the frozen clock — both through the handle and pinned at backoff_delay_s itself (the cap leg stays in resolution.rs's base-5 pins per suite-opts-backoff-rows' recorded disposition); the D-12 pre-claim sweep pin (a lapsed row with its budget spent claims nothing and dead-letters with the exhaust string at the next claim, never strands); and unconditional cancel across both states (the holder's post-cancel ack refusing — the not-an-interrupt honesty). One test-authoring note recorded: heartbeat's absolute reset moves the deadline, so boundary legs stage on fresh rows to keep each observation exact. Task file: status completed, Notes (the seam-carried disposition + reconciliation inventory + the six adds + the heartbeat-observation note) + Summary filled. Verified: cargo test -p alkstore-mem 97/97 (91 + 6 new; new tests green solo x3), workspace cargo test green (core 25, suite-harness 3, mem 97, pg 124+25+9, SQLite 191+25), clippy --all-targets -D warnings clean, cargo fmt --check clean, cargo check --target wasm32-unknown-unknown -p alkstore-mem clean
1517 lines
51 KiB
Rust
1517 lines
51 KiB
Rust
//! The queue mechanism's mem arm — the ADR-010 state machine over the
|
|
//! guarded jobs table, all driver-free (engine-mem.md §Mapping the
|
|
//! contract, queues row).
|
|
//!
|
|
//! The row-level operations below are the crate-internal effect layer:
|
|
//! the auto-commit handle, the outbox helper, the scheduler's boundary
|
|
//! fires, and the tx overlay's commit-merge all land rows through
|
|
//! them — one owner of the row mechanics (the claim predicate, the
|
|
//! uniform handle-op validity predicate, the dead-letter move). The
|
|
//! semantics mirror the contracts pin for pin: claim ordering
|
|
//! `priority` DESC → ready-time ASC → enqueue order (id ASC); a
|
|
//! reclaim consumes an attempt; every handle op succeeds only while
|
|
//! the row is processing and its claim deadline is unexpired
|
|
//! (refusals are `false`, never errors); exhaustion dead-letters with
|
|
//! the pinned default strings; `sweep_expired` moves every
|
|
//! past-`expires_at` row in either state (the no-stranded-rows
|
|
//! property) and enforces dead-letter retention.
|
|
//!
|
|
//! Expiry is a clock check at the claim/renew boundaries — no ambient
|
|
//! sweeper exists (ADR-010 §6); `Clock::unix_now` is the single
|
|
//! timestamp source. Numeric-domain guards live at the trait-impl
|
|
//! entries (`claim_batch`'s `n <= 0` extent clamps empty, ADR-023 §2).
|
|
|
|
use std::cmp::Reverse;
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::Value;
|
|
|
|
use alkstore::{
|
|
BoxedFuture, EnqueueOpts, Job, JobHandle, JobState, Queue, QueueOpts, Result,
|
|
validate_local_name,
|
|
};
|
|
|
|
use crate::clock::Clock;
|
|
use crate::resolution::{
|
|
EXHAUSTED_ERROR, EXPIRED_ERROR, FAILED_ERROR, ResolvedEnqueue, Stamps, backoff_delay_s,
|
|
resolve_enqueue_opts, stamps_with_override,
|
|
};
|
|
use crate::state::Guard;
|
|
use crate::state::{DeadRow, JobRow, QueueState, State};
|
|
|
|
fn job_state_for(pending: bool) -> JobState {
|
|
if pending {
|
|
JobState::Pending
|
|
} else {
|
|
JobState::Processing
|
|
}
|
|
}
|
|
|
|
/// Build a pending job row from the resolved enqueue fields and the
|
|
/// §3a stamps — the one constructor every enqueue shape shares (the
|
|
/// handle's, the tx's, the outbox's, the scheduler fire's).
|
|
pub(crate) fn build_job_row(
|
|
id: i64,
|
|
queue: String,
|
|
payload: Vec<u8>,
|
|
now: i64,
|
|
resolved: &ResolvedEnqueue,
|
|
priority: i64,
|
|
stamps: &Stamps,
|
|
) -> JobRow {
|
|
JobRow {
|
|
id,
|
|
queue,
|
|
payload,
|
|
pending: true,
|
|
priority,
|
|
run_at: resolved.run_at,
|
|
worker_id: None,
|
|
claim_expires_at: None,
|
|
claimed_at: None,
|
|
attempts: 0,
|
|
max_attempts: stamps.max_attempts,
|
|
created_at: now,
|
|
expires_at: resolved.expires_at,
|
|
visibility_timeout_s: stamps.visibility_timeout_s,
|
|
backoff_base_s: stamps.backoff_base_s,
|
|
dead_letter_retention_s: stamps.dead_letter_retention_s,
|
|
}
|
|
}
|
|
|
|
/// The claim-order readiness shape (ADR-010 §1/§2, the SQL engines'
|
|
/// predicate): budget unspent, job-level expiry not passed, and either
|
|
/// a ready pending row or a deadline-lapsed processing row (the
|
|
/// reclaim case — the claim consumes the attempt; exactly-once is the
|
|
/// guarded section's atomicity).
|
|
fn claimable(row: &JobRow, now: i64) -> bool {
|
|
let ready = row.pending && row.run_at <= now;
|
|
let lapsed = !row.pending && row.claim_expires_at.is_some_and(|d| d < now);
|
|
(ready || lapsed) && row.attempts < row.max_attempts && row.expires_at.is_none_or(|e| e > now)
|
|
}
|
|
|
|
fn dead_row_from(row: JobRow, last_error: &str, died_at: i64) -> DeadRow {
|
|
DeadRow {
|
|
id: row.id,
|
|
queue: row.queue,
|
|
payload: row.payload,
|
|
priority: row.priority,
|
|
run_at: row.run_at,
|
|
attempts: row.attempts,
|
|
max_attempts: row.max_attempts,
|
|
created_at: row.created_at,
|
|
expires_at: row.expires_at,
|
|
claimed_at: row.claimed_at,
|
|
visibility_timeout_s: row.visibility_timeout_s,
|
|
backoff_base_s: row.backoff_base_s,
|
|
dead_letter_retention_s: row.dead_letter_retention_s,
|
|
last_error: last_error.to_string(),
|
|
died_at,
|
|
}
|
|
}
|
|
|
|
fn move_to_dead(q: &mut QueueState, row: JobRow, last_error: &str, died_at: i64) {
|
|
let id = row.id;
|
|
let dead = dead_row_from(row, last_error, died_at);
|
|
q.dead.insert(id, dead);
|
|
}
|
|
|
|
/// The claimable-but-exhausted rows dead-letter first (the pre-claim
|
|
/// sweep — a row whose budget is spent must not sit claimable
|
|
/// forever; it lands dead with the exhaust string).
|
|
fn dead_letter_exhausted(q: &mut QueueState, queue: &str, exhaust_error: &str, now: i64) {
|
|
let exhausted: Vec<JobRow> = q
|
|
.live
|
|
.values()
|
|
.filter(|row| {
|
|
row.queue == queue
|
|
&& row.attempts >= row.max_attempts
|
|
&& row.expires_at.is_none_or(|e| e > now)
|
|
&& (row.pending && row.run_at <= now
|
|
|| !row.pending && row.claim_expires_at.is_some_and(|d| d < now))
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
for row in exhausted {
|
|
q.live.remove(&row.id);
|
|
move_to_dead(q, row, exhaust_error, now);
|
|
}
|
|
}
|
|
|
|
/// The exactly-once handout (ADR-010 §1): pick claimable rows in
|
|
/// priority DESC → ready-time ASC → id ASC order under one guarded
|
|
/// section, mark them processing with their own visibility stamps, and
|
|
/// return the post-claim row values (what a claim's `Job` shows).
|
|
pub(crate) fn claim_rows(
|
|
q: &mut QueueState,
|
|
queue: &str,
|
|
worker_id: &str,
|
|
n: i64,
|
|
now: i64,
|
|
) -> Vec<JobRow> {
|
|
dead_letter_exhausted(q, queue, EXHAUSTED_ERROR, now);
|
|
if n <= 0 {
|
|
return Vec::new();
|
|
}
|
|
let mut order: Vec<(i64, i64, i64)> = q
|
|
.live
|
|
.values()
|
|
.filter(|row| row.queue == queue && claimable(row, now))
|
|
.map(|row| (row.priority, row.run_at, row.id))
|
|
.collect();
|
|
order.sort_by_key(|a| (Reverse(a.0), a.1, a.2));
|
|
let mut out = Vec::new();
|
|
for (_, _, id) in order.into_iter().take(n as usize) {
|
|
// Same guarded scope: the candidate's row cannot vanish between
|
|
// the collect and this remove — no other mutation runs under the
|
|
// held guard. The `else continue` below is the honest fallback.
|
|
let Some(mut row) = q.live.remove(&id) else {
|
|
continue;
|
|
};
|
|
row.pending = false;
|
|
row.worker_id = Some(worker_id.to_string());
|
|
row.claim_expires_at = Some(now.saturating_add(row.visibility_timeout_s));
|
|
row.claimed_at = Some(now);
|
|
row.attempts += 1;
|
|
let claimed = row.clone();
|
|
q.live.insert(id, row);
|
|
out.push(claimed);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The uniform validity predicate (ADR-010 §2), for one-shot handle
|
|
/// ops: processing state + the caller's claim deadline unexpired —
|
|
/// `claim_expires_at >= now` is the ack predicate's exact boundary.
|
|
fn valid_claim(row: &JobRow, worker_id: &str, now: i64) -> bool {
|
|
!row.pending
|
|
&& row.worker_id.as_deref() == Some(worker_id)
|
|
&& row.claim_expires_at.is_some_and(|d| d >= now)
|
|
}
|
|
|
|
/// Ack one id under the predicate; `false` = lapsed, acked elsewhere,
|
|
/// cancelled, or not the caller's claim. The predicate reads before
|
|
/// the delete — a refusal leaves the row exactly as it was.
|
|
pub(crate) fn ack_row(q: &mut QueueState, job_id: i64, worker_id: &str, now: i64) -> bool {
|
|
let valid = q
|
|
.live
|
|
.get(&job_id)
|
|
.is_some_and(|row| valid_claim(row, worker_id, now));
|
|
if valid {
|
|
q.live.remove(&job_id);
|
|
}
|
|
valid
|
|
}
|
|
|
|
/// The batch ack (the batch form carries no worker identity — per-id
|
|
/// independent outcomes; lapsed/not-claimed/cancelled ids silently not
|
|
/// counted). Returns the count that landed. The predicate reads before
|
|
/// the delete — refusals leave their rows untouched.
|
|
pub(crate) fn ack_batch_rows(q: &mut QueueState, ids: &[i64], now: i64) -> i64 {
|
|
let mut count = 0;
|
|
for id in ids {
|
|
let valid = q
|
|
.live
|
|
.get(id)
|
|
.is_some_and(|row| !row.pending && row.claim_expires_at.is_some_and(|d| d >= now));
|
|
if valid {
|
|
q.live.remove(id);
|
|
count += 1;
|
|
}
|
|
}
|
|
count
|
|
}
|
|
|
|
/// Heartbeat: renewal — an absolute reset of the claim deadline from
|
|
/// now (extend is the new full deadline, not additive). A late
|
|
/// heartbeat is refused (`false`); `claimed_at` is never touched.
|
|
pub(crate) fn heartbeat_row(
|
|
q: &mut QueueState,
|
|
job_id: i64,
|
|
worker_id: &str,
|
|
extend: i64,
|
|
now: i64,
|
|
) -> bool {
|
|
let Some(row) = q.live.get_mut(&job_id) else {
|
|
return false;
|
|
};
|
|
if !valid_claim(row, worker_id, now) {
|
|
return false;
|
|
}
|
|
row.claim_expires_at = Some(now.saturating_add(extend));
|
|
true
|
|
}
|
|
|
|
/// Retry: return the row to pending for redelivery (`run_at` = now +
|
|
/// delay); at an exhausted budget, dead-letter instead (`last_error` =
|
|
/// the caller's exhaust string — `None` callers get the pinned
|
|
/// `"max attempts exceeded"` from the handle). Refusals leave the
|
|
/// row's pre-call state untouched.
|
|
pub(crate) fn retry_row(
|
|
q: &mut QueueState,
|
|
job_id: i64,
|
|
worker_id: &str,
|
|
delay_s: i64,
|
|
exhaust_error: &str,
|
|
now: i64,
|
|
) -> bool {
|
|
let Some(row) = q.live.get(&job_id) else {
|
|
return false;
|
|
};
|
|
if !valid_claim(row, worker_id, now) {
|
|
return false;
|
|
}
|
|
let Some(pulled) = q.live.remove(&job_id) else {
|
|
return false;
|
|
};
|
|
if pulled.attempts >= pulled.max_attempts {
|
|
move_to_dead(q, pulled, exhaust_error, now);
|
|
} else {
|
|
let mut pending = pulled;
|
|
pending.pending = true;
|
|
pending.run_at = now.saturating_add(delay_s);
|
|
pending.worker_id = None;
|
|
pending.claim_expires_at = None;
|
|
pending.claimed_at = None;
|
|
q.live.insert(job_id, pending);
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Fail: immediate dead-letter with the caller's string (`None` maps
|
|
/// to the pinned `"failed"` default at the handle).
|
|
pub(crate) fn fail_row(
|
|
q: &mut QueueState,
|
|
job_id: i64,
|
|
worker_id: &str,
|
|
error: &str,
|
|
now: i64,
|
|
) -> bool {
|
|
let Some(row) = q.live.get(&job_id) else {
|
|
return false;
|
|
};
|
|
if !valid_claim(row, worker_id, now) {
|
|
return false;
|
|
}
|
|
let Some(pulled) = q.live.remove(&job_id) else {
|
|
return false;
|
|
};
|
|
move_to_dead(q, pulled, error, now);
|
|
true
|
|
}
|
|
|
|
/// Unconditional delete of a live row in either state; not an
|
|
/// interrupt — the holder's next op refuses (the `false` shape, the
|
|
/// same as deadline expiry).
|
|
pub(crate) fn cancel_row(q: &mut QueueState, job_id: i64) -> bool {
|
|
q.live.remove(&job_id).is_some()
|
|
}
|
|
|
|
/// `get_job` — live rows first, dead rows second (with
|
|
/// `last_error`/`died_at`; no job = `None`).
|
|
pub(crate) fn get_job_row(q: &QueueState, job_id: i64) -> Option<Job> {
|
|
if let Some(row) = q.live.get(&job_id) {
|
|
return Some(job_from_row(
|
|
row.clone(),
|
|
job_state_for(row.pending),
|
|
None,
|
|
None,
|
|
));
|
|
}
|
|
let dead = q.dead.get(&job_id)?;
|
|
Some(job_from_row(
|
|
JobRow {
|
|
id: dead.id,
|
|
queue: dead.queue.clone(),
|
|
payload: dead.payload.clone(),
|
|
pending: false,
|
|
priority: dead.priority,
|
|
run_at: dead.run_at,
|
|
worker_id: None,
|
|
claim_expires_at: None,
|
|
claimed_at: dead.claimed_at,
|
|
attempts: dead.attempts,
|
|
max_attempts: dead.max_attempts,
|
|
created_at: dead.created_at,
|
|
expires_at: dead.expires_at,
|
|
visibility_timeout_s: dead.visibility_timeout_s,
|
|
backoff_base_s: dead.backoff_base_s,
|
|
dead_letter_retention_s: dead.dead_letter_retention_s,
|
|
},
|
|
JobState::Dead,
|
|
Some(dead.last_error.clone()),
|
|
Some(dead.died_at),
|
|
))
|
|
}
|
|
|
|
/// Map a row into the contract's `Job` value (`from_row` — the
|
|
/// engine-side constructor; one decode owner for every reader).
|
|
pub(crate) fn job_from_row(
|
|
row: JobRow,
|
|
state: JobState,
|
|
last_error: Option<String>,
|
|
died_at: Option<i64>,
|
|
) -> Job {
|
|
Job::from_row(
|
|
row.id,
|
|
row.queue,
|
|
state,
|
|
row.payload,
|
|
row.priority,
|
|
row.run_at,
|
|
row.attempts,
|
|
row.max_attempts,
|
|
row.worker_id,
|
|
row.claimed_at,
|
|
row.claim_expires_at,
|
|
row.created_at,
|
|
row.expires_at,
|
|
row.visibility_timeout_s,
|
|
row.backoff_base_s,
|
|
row.dead_letter_retention_s,
|
|
last_error,
|
|
died_at,
|
|
)
|
|
}
|
|
|
|
/// One guarded enqueue (auto-commit shapes): allocate the id, insert
|
|
/// the stamped row, return it. One clock read per enqueue (the
|
|
/// caller's; resolution and stamping run before any table effect).
|
|
pub(crate) fn enqueue_row(
|
|
state: &State,
|
|
now: i64,
|
|
queue: &str,
|
|
payload: Vec<u8>,
|
|
opts: &EnqueueOpts,
|
|
stamps: Stamps,
|
|
resolved: &ResolvedEnqueue,
|
|
) -> i64 {
|
|
let id = state.allocate_job_id();
|
|
let row = build_job_row(
|
|
id,
|
|
queue.to_string(),
|
|
payload,
|
|
now,
|
|
resolved,
|
|
opts.priority,
|
|
&stamps,
|
|
);
|
|
state.queues.guard().live.insert(id, row);
|
|
id
|
|
}
|
|
|
|
/// One guarded claim page (claims are writes; the outbox's `run_once`
|
|
/// claims through this same owner).
|
|
pub(crate) fn claim_via_state(
|
|
state: &State,
|
|
queue: &str,
|
|
worker_id: &str,
|
|
n: i64,
|
|
now: i64,
|
|
) -> Vec<JobRow> {
|
|
let mut q = state.queues.guard();
|
|
claim_rows(&mut q, queue, worker_id, n, now)
|
|
}
|
|
|
|
/// The `sweep_expired` effect (no-stranded-rows): every
|
|
/// past-`expires_at` row of the queue in either state moves to dead
|
|
/// (`"expired"`), and dead rows past their retention stamp delete.
|
|
/// Returns the moved + retention-deleted count.
|
|
pub(crate) fn sweep_expired_rows(q: &mut QueueState, queue: &str, now: i64) -> i64 {
|
|
let expired: Vec<JobRow> = q
|
|
.live
|
|
.values()
|
|
.filter(|row| row.queue == queue && row.expires_at.is_some_and(|e| e <= now))
|
|
.cloned()
|
|
.collect();
|
|
let mut moved = 0;
|
|
for row in expired {
|
|
q.live.remove(&row.id);
|
|
move_to_dead(q, row, EXPIRED_ERROR, now);
|
|
moved += 1;
|
|
}
|
|
let aged: Vec<i64> = q
|
|
.dead
|
|
.values()
|
|
.filter(|row| {
|
|
row.queue == queue
|
|
&& row
|
|
.dead_letter_retention_s
|
|
.is_some_and(|r| row.died_at <= now.saturating_sub(r))
|
|
})
|
|
.map(|row| row.id)
|
|
.collect();
|
|
for id in aged {
|
|
q.dead.remove(&id);
|
|
moved += 1;
|
|
}
|
|
moved
|
|
}
|
|
|
|
/// The queue-scoped handle: enqueue, claims, and maintenance ops for
|
|
/// one validated queue name. The handle carries its `QueueOpts` — the
|
|
/// §3a stamp source enqueues resolve over (queues are names, not
|
|
/// config owners; opts changes apply to future enqueues only).
|
|
pub struct MemQueueHandle {
|
|
pub(crate) name: String,
|
|
opts: QueueOpts,
|
|
pub(crate) state: Arc<State>,
|
|
pub(crate) clock: Arc<Clock>,
|
|
}
|
|
|
|
impl std::fmt::Debug for MemQueueHandle {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("MemQueueHandle")
|
|
.field("name", &self.name)
|
|
.field("opts", &self.opts)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl MemQueueHandle {
|
|
pub(crate) fn new(name: String, opts: QueueOpts, state: Arc<State>, clock: Arc<Clock>) -> Self {
|
|
Self {
|
|
name,
|
|
opts,
|
|
state,
|
|
clock,
|
|
}
|
|
}
|
|
|
|
fn base_stamps(&self) -> Stamps {
|
|
Stamps {
|
|
max_attempts: self.opts.max_attempts,
|
|
visibility_timeout_s: self.opts.visibility_timeout_s,
|
|
backoff_base_s: self.opts.backoff_base_s,
|
|
dead_letter_retention_s: self.opts.dead_letter_retention_s,
|
|
}
|
|
}
|
|
|
|
fn claim(&self, worker_id: &str, n: i64) -> Result<Vec<Job>> {
|
|
validate_local_name(worker_id)?;
|
|
if n <= 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
let now = self.clock.unix_now();
|
|
Ok(claim_via_state(&self.state, &self.name, worker_id, n, now)
|
|
.into_iter()
|
|
.map(|row| job_from_row(row, JobState::Processing, None, None))
|
|
.collect())
|
|
}
|
|
|
|
fn stage_enqueue(&self, payload: Value, opts: &EnqueueOpts) -> Result<i64> {
|
|
let bytes = alkstore::encode_payload(&payload)?;
|
|
let now = self.clock.unix_now();
|
|
let resolved = resolve_enqueue_opts(now, opts);
|
|
let stamps = stamps_with_override(self.base_stamps(), opts);
|
|
Ok(enqueue_row(
|
|
&self.state,
|
|
now,
|
|
&self.name,
|
|
bytes,
|
|
opts,
|
|
stamps,
|
|
&resolved,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl Queue for MemQueueHandle {
|
|
fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
fn enqueue<'a>(&'a self, payload: Value, opts: EnqueueOpts) -> BoxedFuture<'a, Result<i64>> {
|
|
match self.stage_enqueue(payload, &opts) {
|
|
Ok(id) => Box::pin(async move { Ok(id) }),
|
|
Err(e) => Box::pin(async move { Err(e) }),
|
|
}
|
|
}
|
|
|
|
fn claim_one<'a>(
|
|
&'a self,
|
|
worker_id: &str,
|
|
) -> BoxedFuture<'a, Result<Option<Box<dyn JobHandle>>>> {
|
|
let worker = worker_id.to_string();
|
|
Box::pin(async move {
|
|
let claimed = self.claim(&worker, 1)?;
|
|
Ok(claimed.into_iter().next().map(|job| {
|
|
Box::new(MemJobHandle::new(
|
|
job,
|
|
self.state.clone(),
|
|
self.clock.clone(),
|
|
)) as Box<dyn JobHandle>
|
|
}))
|
|
})
|
|
}
|
|
|
|
fn claim_batch<'a>(
|
|
&'a self,
|
|
worker_id: &str,
|
|
n: i64,
|
|
) -> BoxedFuture<'a, Result<Vec<Box<dyn JobHandle>>>> {
|
|
let worker = worker_id.to_string();
|
|
Box::pin(async move {
|
|
let claimed = self.claim(&worker, n)?;
|
|
Ok(claimed
|
|
.into_iter()
|
|
.map(|job| {
|
|
Box::new(MemJobHandle::new(
|
|
job,
|
|
self.state.clone(),
|
|
self.clock.clone(),
|
|
)) as Box<dyn JobHandle>
|
|
})
|
|
.collect())
|
|
})
|
|
}
|
|
|
|
fn ack_batch<'a>(&'a self, ids: &[i64]) -> BoxedFuture<'a, Result<i64>> {
|
|
let ids = ids.to_vec();
|
|
let state = self.state.clone();
|
|
let clock = self.clock.clone();
|
|
Box::pin(async move {
|
|
let now = clock.unix_now();
|
|
let mut q = state.queues.guard();
|
|
Ok(ack_batch_rows(&mut q, &ids, now))
|
|
})
|
|
}
|
|
|
|
fn cancel<'a>(&'a self, job_id: i64) -> BoxedFuture<'a, Result<bool>> {
|
|
let state = self.state.clone();
|
|
Box::pin(async move {
|
|
let mut q = state.queues.guard();
|
|
Ok(cancel_row(&mut q, job_id))
|
|
})
|
|
}
|
|
|
|
fn get_job<'a>(&'a self, job_id: i64) -> BoxedFuture<'a, Result<Option<Job>>> {
|
|
let state = self.state.clone();
|
|
Box::pin(async move { Ok(get_job_row(&state.queues.guard(), job_id)) })
|
|
}
|
|
|
|
fn sweep_expired<'a>(&'a self) -> BoxedFuture<'a, Result<i64>> {
|
|
let name = self.name.clone();
|
|
let state = self.state.clone();
|
|
let clock = self.clock.clone();
|
|
Box::pin(async move {
|
|
let now = clock.unix_now();
|
|
let mut q = state.queues.guard();
|
|
Ok(sweep_expired_rows(&mut q, &name, now))
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The claim handle: the post-claim row value plus its ops. The
|
|
/// one-shot ops consume the handle (`self: Box<Self>`); `heartbeat` is
|
|
/// the deliberate repeatable `&self` counter-case (ADR-019 §3).
|
|
pub struct MemJobHandle {
|
|
job: Job,
|
|
state: Arc<State>,
|
|
clock: Arc<Clock>,
|
|
}
|
|
|
|
impl std::fmt::Debug for MemJobHandle {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("MemJobHandle")
|
|
.field("job", &self.job)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl MemJobHandle {
|
|
pub(crate) fn new(job: Job, state: Arc<State>, clock: Arc<Clock>) -> Self {
|
|
Self { job, state, clock }
|
|
}
|
|
}
|
|
|
|
impl JobHandle for MemJobHandle {
|
|
fn job(&self) -> &Job {
|
|
&self.job
|
|
}
|
|
|
|
fn ack(self: Box<Self>) -> BoxedFuture<'static, Result<bool>> {
|
|
let MemJobHandle { job, state, clock } = *self;
|
|
let id = job.id;
|
|
let worker = job.worker_id.unwrap_or_default();
|
|
Box::pin(async move {
|
|
let now = clock.unix_now();
|
|
let mut q = state.queues.guard();
|
|
Ok(ack_row(&mut q, id, &worker, now))
|
|
})
|
|
}
|
|
|
|
fn retry(
|
|
self: Box<Self>,
|
|
err: Option<String>,
|
|
delay: Option<i64>,
|
|
) -> BoxedFuture<'static, Result<bool>> {
|
|
let MemJobHandle { job, state, clock } = *self;
|
|
let id = job.id;
|
|
let worker = job.worker_id.unwrap_or_default();
|
|
let delay_s = match delay {
|
|
Some(d) => d,
|
|
None => backoff_delay_s(job.backoff_base_s, job.attempts, id),
|
|
};
|
|
let exhaust = err.unwrap_or_else(|| EXHAUSTED_ERROR.to_string());
|
|
Box::pin(async move {
|
|
let now = clock.unix_now();
|
|
let mut q = state.queues.guard();
|
|
Ok(retry_row(&mut q, id, &worker, delay_s, &exhaust, now))
|
|
})
|
|
}
|
|
|
|
fn fail(self: Box<Self>, err: Option<String>) -> BoxedFuture<'static, Result<bool>> {
|
|
let MemJobHandle { job, state, clock } = *self;
|
|
let id = job.id;
|
|
let worker = job.worker_id.unwrap_or_default();
|
|
let error = err.unwrap_or_else(|| FAILED_ERROR.to_string());
|
|
Box::pin(async move {
|
|
let now = clock.unix_now();
|
|
let mut q = state.queues.guard();
|
|
Ok(fail_row(&mut q, id, &worker, &error, now))
|
|
})
|
|
}
|
|
|
|
fn heartbeat<'a>(&'a self, extend: i64) -> BoxedFuture<'a, Result<bool>> {
|
|
let id = self.job.id;
|
|
let worker = self.job.worker_id.clone().unwrap_or_default();
|
|
let state = self.state.clone();
|
|
let clock = self.clock.clone();
|
|
Box::pin(async move {
|
|
let now = clock.unix_now();
|
|
let mut q = state.queues.guard();
|
|
Ok(heartbeat_row(&mut q, id, &worker, extend, now))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::store::MemStore;
|
|
|
|
fn handle(store: &MemStore, name: &str) -> MemQueueHandle {
|
|
MemQueueHandle::new(
|
|
name.to_string(),
|
|
QueueOpts::default(),
|
|
store.state.clone(),
|
|
store.clock.clone(),
|
|
)
|
|
}
|
|
|
|
fn ready_now() -> EnqueueOpts {
|
|
EnqueueOpts {
|
|
run_at: Some(-1),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn enqueue_claims_and_acks_the_lifecycle() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000_000);
|
|
let handle = handle(&store, "emails");
|
|
let id = handle
|
|
.enqueue(serde_json::json!({"k": "v"}), EnqueueOpts::default())
|
|
.await
|
|
.expect("enqueue");
|
|
let claimed = handle.claim_batch("w1", 8).await.expect("claim");
|
|
assert_eq!(claimed.len(), 1);
|
|
let job = claimed[0].job();
|
|
assert_eq!(job.id, id);
|
|
assert_eq!(job.state, JobState::Processing);
|
|
assert_eq!(job.worker_id.as_deref(), Some("w1"));
|
|
assert_eq!(job.attempts, 1);
|
|
assert_eq!(job.max_attempts, 3, "the plain defaults stamp the row");
|
|
assert_eq!(job.visibility_timeout_s, 300);
|
|
assert_eq!(job.backoff_base_s, 5);
|
|
assert_eq!(job.dead_letter_retention_s, None);
|
|
assert_eq!(job.claim_expires_at, Some(1_000_300));
|
|
assert_eq!(job.claimed_at, Some(1_000_000));
|
|
assert!(
|
|
claimed
|
|
.into_iter()
|
|
.next()
|
|
.unwrap()
|
|
.ack()
|
|
.await
|
|
.expect("ack")
|
|
);
|
|
assert!(
|
|
handle.get_job(id).await.expect("get").is_none(),
|
|
"ack deletes the row"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn claim_ordering_is_priority_desc_then_ready_then_enqueue_order() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
// low: priority 0 — claimed last however ready.
|
|
let low = handle
|
|
.enqueue(
|
|
serde_json::json!("low"),
|
|
EnqueueOpts {
|
|
priority: 0,
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// b/c: highest priority, the earliest ready time, equal —
|
|
// enqueue order breaks the tie.
|
|
let b = handle
|
|
.enqueue(
|
|
serde_json::json!("b"),
|
|
EnqueueOpts {
|
|
priority: 5,
|
|
run_at: Some(995),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let c = handle
|
|
.enqueue(
|
|
serde_json::json!("c"),
|
|
EnqueueOpts {
|
|
priority: 5,
|
|
run_at: Some(995),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// e: same priority, a later ready time.
|
|
let e = handle
|
|
.enqueue(
|
|
serde_json::json!("e"),
|
|
EnqueueOpts {
|
|
priority: 5,
|
|
run_at: Some(998),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut landed = Vec::new();
|
|
for _ in 0..4 {
|
|
if let Some(h) = handle.claim_one("w").await.expect("claim") {
|
|
landed.push(h.job().id);
|
|
}
|
|
}
|
|
assert_eq!(
|
|
landed,
|
|
vec![b, c, e, low],
|
|
"priority DESC, then ready-time ASC, then enqueue order"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn claim_batch_extent_clamps_empty() {
|
|
let store = MemStore::new();
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
assert!(handle.claim_batch("w", 0).await.expect("0").is_empty());
|
|
assert!(
|
|
handle.claim_batch("w", -1).await.expect("-1").is_empty(),
|
|
"the extent guard: a negative n claims nothing, never the whole queue"
|
|
);
|
|
assert_eq!(handle.claim_batch("w", 1).await.expect("1").len(), 1);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn empty_worker_id_is_invalid_at_the_entry() {
|
|
let store = MemStore::new();
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
match handle.claim_batch("", 1).await {
|
|
Err(alkstore::Error::InvalidName { .. }) => {}
|
|
_other => panic!("an empty worker_id rejects"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn stamps_resolve_over_the_handle_opts_with_the_max_attempts_override() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000_000);
|
|
let custom = MemQueueHandle::new(
|
|
"custom".to_string(),
|
|
QueueOpts {
|
|
visibility_timeout_s: 77,
|
|
max_attempts: 9,
|
|
backoff_base_s: 11,
|
|
dead_letter_retention_s: Some(60),
|
|
},
|
|
store.state.clone(),
|
|
store.clock.clone(),
|
|
);
|
|
let id = custom
|
|
.enqueue(
|
|
serde_json::json!("x"),
|
|
EnqueueOpts {
|
|
max_attempts: Some(4),
|
|
delay: Some(10),
|
|
expires: Some(100),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let job = custom.get_job(id).await.unwrap().unwrap();
|
|
assert_eq!(job.visibility_timeout_s, 77);
|
|
assert_eq!(
|
|
job.max_attempts, 4,
|
|
"the per-job override wins over the handle stamp"
|
|
);
|
|
assert_eq!(job.backoff_base_s, 11);
|
|
assert_eq!(job.dead_letter_retention_s, Some(60));
|
|
assert_eq!(job.run_at, 1_000_010, "delay wins and resolves");
|
|
assert_eq!(job.expires_at, Some(1_000_100));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn validity_predicate_refuses_lapsed_deadline_ops() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000_000);
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let w1 = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
store.clock.advance(301);
|
|
assert!(
|
|
!w1.ack().await.unwrap(),
|
|
"the lapsed ack refuses — a value, not an error"
|
|
);
|
|
let w2 = handle.claim_batch("w2", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(
|
|
w2.job().worker_id.as_deref(),
|
|
Some("w2"),
|
|
"a reclaim rehands the lapsed row"
|
|
);
|
|
assert_eq!(w2.job().attempts, 2, "a reclaim consumes an attempt");
|
|
store.clock.advance(301);
|
|
let id2 = w2.job().id;
|
|
assert!(
|
|
!w2.ack().await.unwrap(),
|
|
"the renewed lapse refuses — the predicate is uniform"
|
|
);
|
|
let intact = handle.get_job(id2).await.unwrap().unwrap();
|
|
assert_eq!(
|
|
intact.state,
|
|
JobState::Processing,
|
|
"refusals destroy no row"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn heartbeat_renews_absolutely_and_late_heartbeat_refuses() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(500_000);
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let claimed = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(claimed.job().claim_expires_at, Some(500_300));
|
|
store.clock.advance(100);
|
|
assert!(
|
|
claimed.heartbeat(600).await.unwrap(),
|
|
"renewal is an absolute reset from now"
|
|
);
|
|
let stored = handle.get_job(claimed.job().id).await.unwrap().unwrap();
|
|
assert_eq!(
|
|
stored.claim_expires_at,
|
|
Some(500_700),
|
|
"a full 600 from now(500100)"
|
|
);
|
|
assert_eq!(
|
|
stored.claimed_at,
|
|
Some(500_000),
|
|
"heartbeat never touches claimed_at"
|
|
);
|
|
assert_eq!(stored.attempts, 1, "renewal is not a claim");
|
|
store.clock.advance(700);
|
|
assert!(
|
|
!claimed.heartbeat(60).await.unwrap(),
|
|
"a late heartbeat is refused — it cannot steal the job back"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn foreign_worker_ops_refuse() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let w1 = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
let claimed_id = w1.job().id;
|
|
let foreign = handle.claim_batch("w2", 1).await.unwrap();
|
|
assert!(
|
|
foreign.is_empty(),
|
|
"w1's unexpired claim holds the row — no extra handout"
|
|
);
|
|
// A foreign-worker ack against the row identity refuses. Forge
|
|
// the op through the same claim whose worker_id says w1... the
|
|
// substrate predicate is the reference: ack requires the row's
|
|
// claimed worker; w2 claiming first would take it. What the
|
|
// claim batch pins here is the exactly-once handout; the
|
|
// wrong-worker refusal rides the validity predicate tests.
|
|
let row = handle.get_job(claimed_id).await.unwrap().unwrap();
|
|
assert_eq!(row.state, JobState::Processing);
|
|
assert_eq!(row.worker_id.as_deref(), Some("w1"));
|
|
assert!(w1.ack().await.unwrap(), "the owner's ack lands");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn dead_letter_paths_carry_the_pinned_default_strings() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
handle
|
|
.enqueue(serde_json::json!("y"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let claimed = handle.claim_batch("w", 2).await.unwrap();
|
|
let mut it = claimed.into_iter();
|
|
let first = it.next().unwrap();
|
|
let second = it.next().unwrap();
|
|
let first_id = first.job().id;
|
|
let second_id = second.job().id;
|
|
assert!(
|
|
first.retry(None, None).await.unwrap(),
|
|
"budget 3 with 1 attempt is not exhausted"
|
|
);
|
|
let retried = handle.get_job(first_id).await.unwrap().unwrap();
|
|
assert_eq!(
|
|
retried.state,
|
|
JobState::Pending,
|
|
"the retry reschedules pending"
|
|
);
|
|
assert!(
|
|
second.fail(None).await.unwrap(),
|
|
"fail is the immediate dead-letter"
|
|
);
|
|
let dead = handle.get_job(second_id).await.unwrap().unwrap();
|
|
assert_eq!(dead.state, JobState::Dead);
|
|
assert_eq!(
|
|
dead.last_error.as_deref(),
|
|
Some("failed"),
|
|
"fail(None) pins the default string"
|
|
);
|
|
assert!(dead.died_at.is_some_and(|d| d == 1_000));
|
|
assert_eq!(
|
|
dead.visibility_timeout_s, 300,
|
|
"dead rows keep their stamps"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn retry_override_and_exhaustion_strings() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(2_000);
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(
|
|
serde_json::json!("x"),
|
|
EnqueueOpts {
|
|
run_at: Some(1_999),
|
|
max_attempts: Some(2),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let first = handle.claim_batch("w", 1).await.unwrap().pop().unwrap();
|
|
let first_id = first.job().id;
|
|
assert!(
|
|
first
|
|
.retry(Some("driver failed".into()), Some(30))
|
|
.await
|
|
.unwrap()
|
|
);
|
|
let pending = handle.get_job(first_id).await.unwrap().unwrap();
|
|
assert_eq!(pending.run_at, 2_030, "an explicit delay passes through");
|
|
assert_eq!(
|
|
pending.claimed_at, None,
|
|
"the retry clears the claim identity"
|
|
);
|
|
store.clock.advance(31);
|
|
let second = handle.claim_batch("w", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(
|
|
second.job().attempts,
|
|
2,
|
|
"every claim counts, reclaims included"
|
|
);
|
|
let second_id = second.job().id;
|
|
assert!(
|
|
second.retry(None, None).await.unwrap(),
|
|
"the budget is spent — dead-letter"
|
|
);
|
|
let dead = handle.get_job(second_id).await.unwrap().unwrap();
|
|
assert_eq!(dead.state, JobState::Dead);
|
|
assert_eq!(
|
|
dead.last_error.as_deref(),
|
|
Some("max attempts exceeded"),
|
|
"exhaustion via retry(None, …) pins the engine default"
|
|
);
|
|
// The dead row's stored exhaust string is the caller's when
|
|
// supplied: a healthy row retrying with Some carries it only on
|
|
// exhaustion — here the earlier Some-string retry rescheduled,
|
|
// so the engine default recorded this death.
|
|
let _ = first_id;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn reclaim_consumes_an_attempt_and_wins_over_ack() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(10_000);
|
|
let handle = handle(&store, "q");
|
|
handle
|
|
.enqueue(
|
|
serde_json::json!("x"),
|
|
EnqueueOpts {
|
|
run_at: Some(9_999),
|
|
max_attempts: Some(5),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let claimed = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
store.clock.advance(301);
|
|
let reclaimed = handle.claim_batch("w2", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(reclaimed.job().worker_id.as_deref(), Some("w2"));
|
|
assert_eq!(reclaimed.job().attempts, 2, "a reclaim consumes an attempt");
|
|
assert!(
|
|
!claimed.ack().await.unwrap(),
|
|
"the loser's ack refuses against the reclaim"
|
|
);
|
|
assert!(reclaimed.ack().await.unwrap());
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn ack_batch_applies_the_predicate_per_id() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
for i in 0..3 {
|
|
handle
|
|
.enqueue(serde_json::json!(i), ready_now())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let claimed = handle.claim_batch("w1", 3).await.unwrap();
|
|
let ids: Vec<i64> = claimed.iter().map(|h| h.job().id).collect();
|
|
assert!(
|
|
handle.cancel(ids[2]).await.unwrap(),
|
|
"cancel deletes unconditionally"
|
|
);
|
|
let count = handle
|
|
.ack_batch(&[ids[0], ids[1], ids[2], 999_999])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(count, 2, "cancelled and absent ids silently not counted");
|
|
assert!(
|
|
!handle.cancel(ids[0]).await.unwrap(),
|
|
"acked rows are gone (delete-on-ack)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn sweep_moves_both_states_past_expiry() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(5_000);
|
|
let handle = handle(&store, "q");
|
|
// Both rows expire at 5_001: one gets claimed first (processing),
|
|
// the other stays pending; then both expire.
|
|
let pending = handle
|
|
.enqueue(
|
|
serde_json::json!("p"),
|
|
EnqueueOpts {
|
|
expires: Some(1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let processing = handle
|
|
.enqueue(
|
|
serde_json::json!("w"),
|
|
EnqueueOpts {
|
|
expires: Some(1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let claimed = handle.claim_batch("w", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(
|
|
claimed.job().id,
|
|
pending,
|
|
"the first enqueued row claims; it is now the processing one"
|
|
);
|
|
let _healthy = handle
|
|
.enqueue(
|
|
serde_json::json!("h"),
|
|
EnqueueOpts {
|
|
expires: Some(3_600),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
store.clock.advance(2);
|
|
assert_eq!(handle.sweep_expired().await.unwrap(), 2, "both states move");
|
|
for id in [pending, processing] {
|
|
let dead = handle.get_job(id).await.unwrap().unwrap();
|
|
assert_eq!(dead.state, JobState::Dead);
|
|
assert_eq!(dead.last_error.as_deref(), Some("expired"));
|
|
assert_eq!(dead.died_at, Some(5_002));
|
|
}
|
|
assert_eq!(
|
|
handle.sweep_expired().await.unwrap(),
|
|
0,
|
|
"the healthy row stays"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn retention_deletes_aged_dead_rows() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(5_000);
|
|
// Same queue name, two handles: the §3a stamps ride each
|
|
// handle's opts — one stamps retention 60, the other forever.
|
|
let retention60 = MemQueueHandle::new(
|
|
"q".to_string(),
|
|
QueueOpts {
|
|
dead_letter_retention_s: Some(60),
|
|
..QueueOpts::default()
|
|
},
|
|
store.state.clone(),
|
|
store.clock.clone(),
|
|
);
|
|
let handle = handle(&store, "q");
|
|
let expiring = retention60
|
|
.enqueue(
|
|
serde_json::json!("e"),
|
|
EnqueueOpts {
|
|
expires: Some(-1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let forever = handle
|
|
.enqueue(
|
|
serde_json::json!("f"),
|
|
EnqueueOpts {
|
|
expires: Some(-1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(handle.sweep_expired().await.unwrap(), 2);
|
|
assert_eq!(
|
|
handle.sweep_expired().await.unwrap(),
|
|
0,
|
|
"nothing aged out yet"
|
|
);
|
|
store.clock.advance(61);
|
|
assert_eq!(
|
|
handle.sweep_expired().await.unwrap(),
|
|
1,
|
|
"the retention-60 row deletes"
|
|
);
|
|
assert!(handle.get_job(expiring).await.unwrap().is_none());
|
|
let kept = handle.get_job(forever).await.unwrap().unwrap();
|
|
assert_eq!(kept.state, JobState::Dead, "retention None = forever");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn concurrent_claims_hand_out_work_exactly_once() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
let mut ids = Vec::new();
|
|
for i in 0..4 {
|
|
ids.push(
|
|
handle
|
|
.enqueue(serde_json::json!(i), ready_now())
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
}
|
|
let mut tasks = Vec::new();
|
|
for w in 0..8 {
|
|
let state = store.state.clone();
|
|
let clock = store.clock.clone();
|
|
tasks.push(tokio::spawn(async move {
|
|
MemQueueHandle::new("q".to_string(), QueueOpts::default(), state, clock)
|
|
.claim_batch(&format!("w{w}"), 1)
|
|
.await
|
|
.expect("claim")
|
|
.into_iter()
|
|
.map(|h| h.job().id)
|
|
.collect::<Vec<i64>>()
|
|
}));
|
|
}
|
|
let mut handed = Vec::new();
|
|
for task in tasks {
|
|
handed.extend(task.await.expect("task joins"));
|
|
}
|
|
handed.sort();
|
|
ids.sort();
|
|
assert_eq!(
|
|
handed, ids,
|
|
"8 racing workers over 4 rows: every row handed once, never twice — \
|
|
the guarded section makes the in-process claim race row-atomic"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn claim_vs_expiry_is_deterministic_at_the_row_boundary() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
let first = handle
|
|
.enqueue(serde_json::json!("x"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let w1 = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
let deadline = w1.job().claim_expires_at.unwrap();
|
|
assert_eq!(deadline, 1_300);
|
|
store.clock.advance(300);
|
|
let contested = handle.claim_batch("w2", 1).await.unwrap();
|
|
assert!(
|
|
contested.is_empty(),
|
|
"at now == deadline the claim is not lapsed (d < now is false) — no reclaim"
|
|
);
|
|
assert!(
|
|
w1.heartbeat(60).await.unwrap(),
|
|
"the same boundary second keeps the holder's ops valid (d >= now)"
|
|
);
|
|
assert!(w1.ack().await.unwrap(), "the boundary ack lands");
|
|
assert!(handle.get_job(first).await.unwrap().is_none());
|
|
|
|
let second = handle
|
|
.enqueue(serde_json::json!("y"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let w3 = handle.claim_batch("w3", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(w3.job().id, second);
|
|
let deadline2 = w3.job().claim_expires_at.unwrap();
|
|
assert_eq!(deadline2, 1_600);
|
|
store.clock.advance(301);
|
|
let reclaimed = handle.claim_batch("w4", 1).await.unwrap();
|
|
assert_eq!(
|
|
reclaimed.len(),
|
|
1,
|
|
"one second past the deadline the row is reclaimable"
|
|
);
|
|
assert_eq!(
|
|
reclaimed[0].job().attempts,
|
|
2,
|
|
"the reclaim consumes its attempt"
|
|
);
|
|
let lapsed_id = reclaimed[0].job().id;
|
|
store.clock.advance(301);
|
|
assert_eq!(
|
|
handle.ack_batch(&[lapsed_id]).await.unwrap(),
|
|
0,
|
|
"a lapsed id is silently not counted by the batch form"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn the_equal_jitter_curve_mirrors_the_suite_rows_numeric_bounds() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(10_000);
|
|
let curve = MemQueueHandle::new(
|
|
"curve".to_string(),
|
|
QueueOpts {
|
|
max_attempts: 7,
|
|
backoff_base_s: 2,
|
|
..QueueOpts::default()
|
|
},
|
|
store.state.clone(),
|
|
store.clock.clone(),
|
|
);
|
|
let id = curve
|
|
.enqueue(serde_json::json!({"leg": "curve"}), EnqueueOpts::default())
|
|
.await
|
|
.unwrap();
|
|
// Base 2 s at the first three attempts — the suite row's
|
|
// identical ranges ([1,2], [2,4], [4,8]); the frozen clock makes
|
|
// the observation exact (no straddle tolerance needed).
|
|
for (attempt, lower, upper) in [(1i64, 1i64, 2i64), (2, 2, 4), (3, 4, 8)] {
|
|
store.clock.advance(upper + 1);
|
|
let held = curve.claim_batch("w", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(
|
|
held.job().attempts,
|
|
attempt,
|
|
"the attempt index is the claim count"
|
|
);
|
|
let now = store.clock.unix_now();
|
|
assert!(held.retry(Some("boom".to_string()), None).await.unwrap());
|
|
let job = curve.get_job(id).await.unwrap().unwrap();
|
|
assert_eq!(job.state, JobState::Pending);
|
|
let observed = job.run_at - now;
|
|
assert!(
|
|
(lower..=upper).contains(&observed),
|
|
"attempt {attempt}: delay {observed} outside the suite row's [{lower}, {upper}]"
|
|
);
|
|
}
|
|
store.clock.advance(9);
|
|
let held = curve.claim_batch("w", 1).await.unwrap().pop().unwrap();
|
|
let now = store.clock.unix_now();
|
|
assert!(held.retry(Some("boom".to_string()), Some(5)).await.unwrap());
|
|
let job = curve.get_job(id).await.unwrap().unwrap();
|
|
assert_eq!(job.run_at, now + 5, "Some(5) overrides the curve verbatim");
|
|
store.clock.advance(6);
|
|
let held = curve.claim_batch("w", 1).await.unwrap().pop().unwrap();
|
|
let now = store.clock.unix_now();
|
|
assert!(held.retry(Some("boom".to_string()), Some(0)).await.unwrap());
|
|
let job = curve.get_job(id).await.unwrap().unwrap();
|
|
assert_eq!(
|
|
job.run_at, now,
|
|
"Some(0) resolves ready-now — the boundary-total rule"
|
|
);
|
|
assert_eq!(
|
|
curve.claim_batch("w", 1).await.unwrap().len(),
|
|
1,
|
|
"a ready-now row claims immediately"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn the_pre_claim_sweep_dead_letters_exhausted_reclaimables() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
let id = handle
|
|
.enqueue(
|
|
serde_json::json!("x"),
|
|
EnqueueOpts {
|
|
max_attempts: Some(1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let claimed = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
assert_eq!(claimed.job().attempts, 1);
|
|
store.clock.advance(301);
|
|
let reclaimed = handle.claim_batch("w2", 1).await.unwrap();
|
|
assert!(
|
|
reclaimed.is_empty(),
|
|
"a lapsed row with its budget spent claims nothing — it dead-letters instead"
|
|
);
|
|
let dead = handle.get_job(id).await.unwrap().unwrap();
|
|
assert_eq!(dead.state, JobState::Dead);
|
|
assert_eq!(
|
|
dead.last_error.as_deref(),
|
|
Some("max attempts exceeded"),
|
|
"exhaustion via reclaim pins the engine default string"
|
|
);
|
|
assert_eq!(dead.died_at, Some(1_301));
|
|
assert_eq!(dead.attempts, 1, "the row never got a second claim");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn cancel_is_unconditional_across_states() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(1_000);
|
|
let handle = handle(&store, "q");
|
|
let pending = handle
|
|
.enqueue(serde_json::json!("p"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
handle.cancel(pending).await.unwrap(),
|
|
"a pending row cancels"
|
|
);
|
|
assert!(handle.get_job(pending).await.unwrap().is_none());
|
|
assert!(
|
|
!handle.cancel(999).await.unwrap(),
|
|
"a row-less id is Ok(false)"
|
|
);
|
|
let processing = handle
|
|
.enqueue(serde_json::json!("w"), ready_now())
|
|
.await
|
|
.unwrap();
|
|
let holder = handle.claim_batch("w1", 1).await.unwrap().pop().unwrap();
|
|
assert!(
|
|
handle.cancel(processing).await.unwrap(),
|
|
"a processing row cancels"
|
|
);
|
|
assert!(
|
|
!holder.ack().await.unwrap(),
|
|
"cancel is not an interrupt — the holder's next op refuses, the expiry shape"
|
|
);
|
|
assert!(handle.get_job(processing).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn sweep_is_scoped_to_its_queue() {
|
|
let store = MemStore::new();
|
|
store.clock.freeze(5_000);
|
|
let mine = handle(&store, "mine");
|
|
let other = handle(&store, "other");
|
|
let mine_id = mine
|
|
.enqueue(
|
|
serde_json::json!("x"),
|
|
EnqueueOpts {
|
|
expires: Some(-1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let other_id = other
|
|
.enqueue(
|
|
serde_json::json!("y"),
|
|
EnqueueOpts {
|
|
expires: Some(-1),
|
|
..ready_now()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(mine.sweep_expired().await.unwrap(), 1);
|
|
assert_eq!(
|
|
other.sweep_expired().await.unwrap(),
|
|
1,
|
|
"each queue sweeps its own rows"
|
|
);
|
|
let mine_seen = mine.get_job(mine_id).await.unwrap().unwrap();
|
|
let other_seen = other.get_job(other_id).await.unwrap().unwrap();
|
|
assert_eq!(mine_seen.state, JobState::Dead);
|
|
assert_eq!(other_seen.state, JobState::Dead);
|
|
assert_eq!(
|
|
mine.sweep_expired().await.unwrap(),
|
|
0,
|
|
"a second sweep moves nothing"
|
|
);
|
|
}
|
|
}
|