Skip to main content

headgate_core/
lib.rs

1//! headgate core — ports, envelope, and the state machine. No I/O lives here.
2//!
3//! The thesis: dequeue is an admission decision, not a fetch. See ARCHITECTURE.md architecture thesis.
4
5#![allow(dead_code)]
6use std::time::Duration;
7
8pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
9
10// ---------- tasks ----------
11
12/// A unit of work. `TYPE` is wire state — changing it strands enqueued jobs.
13pub trait Task: Sized + Send + Sync + 'static {
14    const TYPE: &'static str;
15    /// payload versioning Bump when the payload shape changes; implement `upcast` for the old one.
16    const VERSION: u32 = 1;
17    /// typed dispatch Kinds this worker also answers to. Enqueue always uses `TYPE`; dispatch
18    /// matches `TYPE` or any alias. Without this, renaming a task strands every job of
19    /// the old kind — the same failure payload versioning prevents, through a door payload versioning does not cover.
20    const ALIASES: &'static [&'static str] = &[];
21
22    fn encode(&self) -> Result<Vec<u8>, CodecError>;
23    fn decode(bytes: &[u8]) -> Result<Self, CodecError>;
24
25    /// Decode an older payload into the current shape. The default rejects anything
26    /// but the current version, which sends the job to `Undecodable` rather than
27    /// retrying a decode error 25 times.
28    fn upcast(version: u32, bytes: &[u8]) -> Result<Self, CodecError> {
29        if version == Self::VERSION {
30            Self::decode(bytes)
31        } else {
32            Err(CodecError::UnknownVersion(version))
33        }
34    }
35
36    fn options() -> TaskOptions {
37        TaskOptions::default()
38    }
39}
40
41/// content fingerprinting the fingerprint algorithm, specified in ARCHITECTURE.md and nowhere else:
42/// `lowercase_hex(SHA256(u32_le(len(kind)) || kind || u32_le(len(payload)) || payload)[0..16])`.
43/// Length-prefixed so ("a","bc") and ("ab","c") cannot collide; truncated to 128 bits
44/// because a collision over-quarantines. Derived CLIENT-SIDE at enqueue when the caller
45/// does not supply one; stores pass the value through untouched.
46pub fn fingerprint(kind: &str, payload: &[u8]) -> String {
47    use sha2::{Digest, Sha256};
48    let mut h = Sha256::new();
49    h.update((kind.len() as u32).to_le_bytes());
50    h.update(kind.as_bytes());
51    h.update((payload.len() as u32).to_le_bytes());
52    h.update(payload);
53    let digest = h.finalize();
54    let mut out = String::with_capacity(32);
55    for b in &digest[..16] {
56        out.push_str(&format!("{b:02x}"));
57    }
58    out
59}
60
61#[derive(Debug)]
62pub enum CodecError {
63    Malformed(String),
64    UnknownVersion(u32),
65}
66impl std::fmt::Display for CodecError {
67    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
68        match self {
69            CodecError::Malformed(m) => write!(f, "malformed payload: {m}"),
70            CodecError::UnknownVersion(v) => write!(f, "no upcast path for schema version {v}"),
71        }
72    }
73}
74impl std::error::Error for CodecError {}
75
76#[derive(Default, Clone)]
77pub struct TaskOptions {
78    pub queue: Option<String>,
79    pub max_attempts: Option<u32>,
80    pub priority: Option<i32>,
81    pub timeout: Option<Duration>,
82    pub deadline: Option<Duration>,
83    pub unique_ttl: Option<Duration>,
84    pub retention: Option<Duration>,
85    /// tenant fairness tenant/customer. Fair queuing keys on this.
86    pub partition_key: Option<String>,
87    /// admission policy fleet-wide limiter bucket. Usually a third-party API, not a job kind.
88    pub rate_class: Option<String>,
89    /// surveyed policy behavior estimated cost charged to the rate class at admission. This is unrelated
90    /// to queue-selection weight: queue weight chooses a queue; this value spends that
91    /// job's rate budget once the queue has been chosen.
92    pub weight: Option<u32>,
93}
94
95// ---------- outcomes ----------
96
97/// lifecycle state machine Exhaustive on purpose: adding a variant without handling it is a compile error,
98/// which is how a commented-out transition becomes impossible rather than silent.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum Outcome {
101    Success,
102    Retry,
103    Skip,
104    Revoke,
105    Snooze,
106    /// crash quarantine the worker died. Counted apart from `Retry` — quarantine depends on it.
107    LeaseLost,
108    Undecodable,
109    /// surveyed policy behavior NOT a failure. Re-queues without consuming an attempt, the way BullMQ's
110    /// RateLimitError and Sidekiq's OverLimit do. asynq makes users fake this.
111    RateLimited,
112}
113
114/// Versioned opaque bytes recorded atomically with successful completion.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct JobResult {
117    pub schema_version: u32,
118    pub bytes: Vec<u8>,
119}
120
121/// Largest opaque result/output schema version portable across every backend.
122pub const MAX_OPAQUE_SCHEMA_VERSION: u32 = i32::MAX as u32;
123
124/// The latest versioned opaque output persisted while a fenced attempt was running.
125/// `fence` identifies the attempt that wrote it; `updated_at_ms` is stamped by the
126/// store clock, never by the worker.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct JobOutput {
129    pub schema_version: u32,
130    pub bytes: Vec<u8>,
131    pub fence: u64,
132    pub updated_at_ms: i64,
133}
134
135/// A portable operator-facing progress update. `current` and `total` are exact units,
136/// not a floating-point percentage; applications that naturally report percentages use
137/// `total = 100`. The optional message is deliberately small because the console polls
138/// this value while a job is running—it is status, not another log channel.
139#[derive(Clone, Debug, Eq, PartialEq)]
140pub struct ProgressUpdate {
141    pub current: u64,
142    pub total: u64,
143    pub message: Option<String>,
144}
145
146/// The latest progress accepted from a fenced running attempt. `fence` identifies the
147/// writer and `updated_at_ms` always comes from the store clock.
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub struct JobProgress {
150    pub current: u64,
151    pub total: u64,
152    pub message: Option<String>,
153    pub fence: u64,
154    pub updated_at_ms: i64,
155}
156
157/// JSON numbers consumed by the shared browser console must remain exact too; this is
158/// JavaScript's `Number.MAX_SAFE_INTEGER`, narrower than the SQL BIGINT columns.
159pub const MAX_PROGRESS_VALUE: u64 = 9_007_199_254_740_991;
160pub const MAX_PROGRESS_MESSAGE_BYTES: usize = 512;
161
162pub fn validate_progress(update: &ProgressUpdate) -> Result<(), StoreError> {
163    if update.total == 0 {
164        return Err(StoreError::Invalid(
165            "progress total must be greater than zero".into(),
166        ));
167    }
168    if update.current > update.total {
169        return Err(StoreError::Invalid(
170            "progress current must not exceed total".into(),
171        ));
172    }
173    if update.total > MAX_PROGRESS_VALUE {
174        return Err(StoreError::Invalid(
175            "progress total exceeds the portable JSON safe-integer limit".into(),
176        ));
177    }
178    if let Some(message) = &update.message {
179        if message.as_bytes().len() > MAX_PROGRESS_MESSAGE_BYTES {
180            return Err(StoreError::Invalid(
181                "progress message exceeds the 512-byte limit".into(),
182            ));
183        }
184        if message.contains('\0') {
185            return Err(StoreError::Invalid(
186                "progress message must not contain NUL".into(),
187            ));
188        }
189    }
190    Ok(())
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum State {
195    Pending,
196    Scheduled,
197    Available,
198    Running,
199    Retryable,
200    Completed,
201    Archived,
202    Cancelled,
203    Quarantined,
204    Undecodable,
205    /// retention policy `retention_ms = 0` means DELETE, not keep forever. Not a stored state —
206    /// a transition into `Deleted` removes the record. Terminal by definition.
207    Deleted,
208}
209
210impl State {
211    pub const fn is_terminal(self) -> bool {
212        matches!(
213            self,
214            State::Completed
215                | State::Archived
216                | State::Cancelled
217                | State::Quarantined
218                | State::Undecodable
219                | State::Deleted
220        )
221    }
222}
223
224/// lifecycle state machine The transition table, mirroring conformance/state_machine.yaml row for row.
225/// `yaml_and_code_agree_row_for_row` in the tests parses that file and cross-checks every
226/// transition, so a row commented out THERE is a failing test HERE — and an unhandled
227/// `Outcome` variant here is a compile error. Both languages check against the same file
228/// so they cannot drift.
229pub fn transition(from: State, on: Outcome, ctx: &TransitionCtx) -> State {
230    match (from, on) {
231        (State::Running, Outcome::Success) => {
232            if ctx.retention_ms > 0 {
233                State::Completed
234            } else {
235                State::Deleted
236            }
237        }
238        (State::Running, Outcome::Skip) => State::Archived,
239        (State::Running, Outcome::Revoke) => State::Deleted, // explicit: drop entirely
240        (State::Running, Outcome::Snooze) => State::Scheduled,
241        (State::Running, Outcome::Undecodable) => State::Undecodable,
242        (State::Running, Outcome::RateLimited) => State::Available, // attempt NOT incremented
243        (State::Running, Outcome::Retry) => {
244            if ctx.attempt + 1 < ctx.max_attempts {
245                State::Retryable
246            } else {
247                State::Archived
248            }
249        }
250        // crash quarantine the branch apalis left commented out, in the shape that makes omitting it impossible
251        (State::Running, Outcome::LeaseLost) => {
252            if ctx.crash_attempt + 1 < ctx.crash_limit {
253                State::Retryable
254            } else {
255                State::Quarantined
256            }
257        }
258        (s, _) => s,
259    }
260}
261
262pub struct TransitionCtx {
263    pub attempt: u32,
264    pub max_attempts: u32,
265    pub crash_attempt: u32,
266    pub crash_limit: u32,
267    /// retention policy decides whether success completes or deletes. 0 = ephemeral.
268    pub retention_ms: i64,
269}
270
271/// The rows of conformance/state_machine.yaml that are driven by the lifecycle — sweeps
272/// and operator actions — rather than by a worker's ack. Kept beside `transition` so the
273/// yaml cross-check covers the whole table.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum LifecycleEvent {
276    OperatorPromote,
277    ScheduleDue,
278    Admitted,
279    BackoffDue,
280    CheckpointStale,
281    OperatorRetry,
282    OperatorRelease,
283    OperatorCancel,
284}
285
286/// `None` means the event is not valid in that state — terminal states never
287/// auto-transition, and e.g. `operator_release` only applies to `quarantined`.
288pub fn lifecycle_transition(from: State, ev: LifecycleEvent) -> Option<State> {
289    match (from, ev) {
290        (State::Pending, LifecycleEvent::OperatorPromote) => Some(State::Available),
291        (State::Scheduled, LifecycleEvent::ScheduleDue) => Some(State::Available),
292        (State::Available, LifecycleEvent::Admitted) => Some(State::Running),
293        (State::Retryable, LifecycleEvent::BackoffDue) => Some(State::Available),
294        // step replay a resumed job whose step set changed under it must NOT silently restart
295        (State::Running, LifecycleEvent::CheckpointStale) => Some(State::Undecodable),
296        (State::Archived, LifecycleEvent::OperatorRetry) => Some(State::Available),
297        (State::Quarantined, LifecycleEvent::OperatorRelease) => Some(State::Available),
298        (
299            State::Pending | State::Available | State::Scheduled | State::Running,
300            LifecycleEvent::OperatorCancel,
301        ) => Some(State::Cancelled),
302        _ => None,
303    }
304}
305
306// ---------- the store port (store port boundary) ----------
307
308pub const UNIQUE_REPLACE_PAYLOAD: u32 = 1 << 0;
309pub const UNIQUE_REPLACE_SCHEDULED_AT: u32 = 1 << 1;
310pub const UNIQUE_REPLACE_PRIORITY: u32 = 1 << 2;
311pub const UNIQUE_REPLACE_MAX_ATTEMPTS: u32 = 1 << 3;
312pub const UNIQUE_REPLACE_ALL: u32 = UNIQUE_REPLACE_PAYLOAD
313    | UNIQUE_REPLACE_SCHEDULED_AT
314    | UNIQUE_REPLACE_PRIORITY
315    | UNIQUE_REPLACE_MAX_ATTEMPTS;
316
317#[derive(Clone, Debug, Default)]
318pub struct Envelope {
319    pub id: String,
320    pub kind: String,
321    pub schema_version: u32,
322    pub payload: Vec<u8>,
323    pub queue: String,
324    pub partition_key: String,
325    pub rate_class: String,
326    /// surveyed policy behavior estimated rate-budget cost. `0` is the backward-compatible omitted wire
327    /// value and is normalized to 1 at the store boundary; APIs reject an explicit 0.
328    /// Actual usage may be reported by the handler and reconciled atomically on ack.
329    pub weight: u32,
330    pub fingerprint: String,
331    pub priority: i32,
332    pub attempt: u32,
333    pub crash_attempt: u32,
334    pub max_attempts: u32,
335    pub scheduled_at_ms: i64,
336    pub timeout_ms: i64,
337    pub deadline_ms: i64,
338    /// job uniqueness uniqueness is an index, not a lock. `None` opts out.
339    pub unique_key: Option<Vec<u8>>,
340    /// Bitmask of states uniqueness applies in — River's design (wire schema field 15).
341    pub unique_states: u32,
342    /// job uniqueness uniqueness mode. 0 = LIFECYCLE: one live job per key, released by terminal
343    /// state. > 0 = THROTTLE: at most one per this many ms, released by the clock.
344    /// Negative is invalid, and a caller-side duration that rounds to zero must be
345    /// REJECTED (boundary validation), never clamped into lifecycle mode.
346    pub unique_window_ms: i64,
347    /// surveyed policy behavior fields to replace atomically when `unique_key` conflicts. This is a
348    /// request-only bitmask; it is never persisted as job state. Unknown bits fail at
349    /// the boundary. Replacement is deliberately single-job so batch atomicity remains
350    /// explicit rather than partially mutating a mixed batch.
351    pub unique_replace: u32,
352    /// Trailing-edge debounce window. Requires a unique key. Store time determines the
353    /// due instant on the initial insert and every conflict.
354    pub unique_debounce_ms: i64,
355    /// When false the task kind is part of the effective uniqueness key. True removes
356    /// it deliberately, allowing equal caller keys to coalesce across kinds.
357    pub unique_exclude_kind: bool,
358    /// retention and eviction contract/retention policy retention after success. 0 = ephemeral: delete on completion.
359    pub retention_ms: i64,
360    /// Typed durable origin for periodic jobs. Both fields are set together; empty/zero
361    /// means an ordinary enqueue. Operators never have to parse ids or opaque headers.
362    pub periodic_schedule_id: String,
363    pub periodic_tick_ms: i64,
364    /// telemetry and trace context opaque caller metadata carried with the job (proto field 20). The store
365    /// never interprets these bytes — it round-trips them. Two keys are RESERVED:
366    /// [`TRACEPARENT`] and [`TRACESTATE`] (W3C Trace Context). A `BTreeMap` rather
367    /// than a hash map because the JSON the adapters write must be byte-identical
368    /// between the two languages, and Go's `encoding/json` sorts map keys.
369    pub headers: std::collections::BTreeMap<String, String>,
370    /// Canonical, operator-indexed labels. Stores persist these separately from headers.
371    pub tags: Vec<String>,
372    /// Durable but admission-ineligible until [`Inspect::promote_job`] succeeds.
373    pub pending: bool,
374    /// Exact stable worker identity allowed to claim this job. Empty means any worker.
375    /// The route survives retries and lease recovery because it is envelope state, not
376    /// lease state. Eligibility is enforced inside the atomic admission gate.
377    pub sticky_worker: String,
378}
379
380/// The rate-budget estimate every backend persists and charges. Proto3 scalar omission,
381/// old producers, and Rust/Go zero-value struct literals all arrive as zero, so zero is
382/// the compatibility sentinel for the documented default of one. Public APIs still
383/// reject an explicitly supplied zero because a zero-cost job should be reported as
384/// actual usage, not used to bypass admission.
385pub const fn effective_weight(weight: u32) -> u32 {
386    if weight == 0 { 1 } else { weight }
387}
388
389/// Versioned, collision-free uniqueness namespace. Including the kind is the safe
390/// default; the explicit exclude flag uses a distinct namespace so scoped and unscoped
391/// jobs can never alias accidentally.
392pub fn effective_unique_key(e: &Envelope) -> Option<Vec<u8>> {
393    let raw = e.unique_key.as_ref()?;
394    let mut out = Vec::with_capacity(raw.len() + e.kind.len() + 7);
395    out.push(1);
396    if e.unique_exclude_kind {
397        out.push(b'G');
398    } else {
399        out.push(b'K');
400        out.extend_from_slice(&(e.kind.len() as u32).to_be_bytes());
401        out.extend_from_slice(e.kind.as_bytes());
402    }
403    out.extend_from_slice(raw);
404    Some(out)
405}
406
407/// Canonical storage order for tags. Validation bounds the set before this allocates.
408pub fn canonical_tags(tags: &[String]) -> Vec<String> {
409    let mut out = tags.to_vec();
410    out.sort_unstable();
411    out.dedup();
412    out
413}
414
415// ---------- trace context on the envelope ----------
416
417/// The RESERVED envelope header carrying W3C Trace Context's `traceparent`.
418///
419/// The header name is specified here because an unwritten convention becomes multiple
420/// incompatible conventions across SDKs. The key is lowercase because W3C
421/// Trace Context defines these as HTTP header field names, which are case-insensitive
422/// on the wire and canonically lowercase; the envelope's header map is NOT
423/// case-insensitive, so the spec has to pick one spelling and this is it.
424pub const TRACEPARENT: &str = "traceparent";
425/// The RESERVED envelope header carrying W3C Trace Context's `tracestate`. Opaque:
426/// headgate never parses, validates, or truncates it — it round-trips the bytes.
427pub const TRACESTATE: &str = "tracestate";
428
429/// A parsed `traceparent` (plus the unparsed `tracestate`).
430///
431/// Producers set the headers at enqueue; the runtime parses `traceparent` at DISPATCH
432/// and hands the result to the handler and to the telemetry facade. See
433/// [`parse_traceparent`] for what "lenient" means here.
434#[derive(Clone, Debug, Default, PartialEq, Eq)]
435pub struct TraceContext {
436    /// 32 lowercase hex characters, never all zero.
437    pub trace_id: String,
438    /// 16 lowercase hex characters, never all zero. The PARENT span id: a job span
439    /// created from this context is a child of it.
440    pub span_id: String,
441    /// The 8 trace-flags bits. Bit 0 is `sampled`.
442    pub trace_flags: u8,
443    /// Verbatim `tracestate`, empty when absent. Never parsed.
444    pub trace_state: String,
445}
446
447impl TraceContext {
448    /// W3C's `sampled` flag (bit 0 of trace-flags).
449    pub const fn sampled(&self) -> bool {
450        self.trace_flags & 1 != 0
451    }
452
453    /// Re-render the `traceparent` header value. Round-trips [`parse_traceparent`]
454    /// exactly, so a runtime that re-injects the context into a downstream call emits
455    /// the same bytes the producer sent.
456    pub fn to_traceparent(&self) -> String {
457        format!(
458            "00-{}-{}-{:02x}",
459            self.trace_id, self.span_id, self.trace_flags
460        )
461    }
462}
463
464fn is_lower_hex(s: &str, len: usize) -> bool {
465    s.len() == len
466        && s.bytes()
467            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
468}
469
470/// Parse a W3C `traceparent` value: `00-{32 lowercase hex}-{16 lowercase hex}-{2 hex}`.
471///
472/// **Lenient means lenient about the CONSEQUENCE, strict about the FORMAT.** An
473/// unparseable value is treated as ABSENT — `None` — and is never an enqueue error and
474/// never a dispatch failure. The headers stay opaque bytes to the store either way, so
475/// a malformed trace header can lose you a trace link and can never lose you a job.
476/// Both languages implement this function identically; a divergence would mean one
477/// runtime silently drops a parent the other honours.
478///
479/// Rejected, each for a reason W3C names:
480/// * a version other than `00` — this specification pins one version rather than
481///   guessing at a future one's field layout;
482/// * uppercase hex — W3C mandates lowercase, and accepting both would make two
483///   producers disagree about whether two ids are the same id;
484/// * an all-zero trace-id or span-id — explicitly invalid in the spec;
485/// * any field of the wrong length, or extra/missing `-`-separated fields.
486pub fn parse_traceparent(value: &str) -> Option<TraceContext> {
487    let mut parts = value.split('-');
488    let (version, trace_id, span_id, flags) =
489        (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
490    if parts.next().is_some() {
491        return None; // trailing field: a future version's shape, not this one's
492    }
493    if version != "00"
494        || !is_lower_hex(trace_id, 32)
495        || !is_lower_hex(span_id, 16)
496        || !is_lower_hex(flags, 2)
497    {
498        return None;
499    }
500    if trace_id.bytes().all(|b| b == b'0') || span_id.bytes().all(|b| b == b'0') {
501        return None; // all-zero ids are invalid per W3C, not merely unusual
502    }
503    Some(TraceContext {
504        trace_id: trace_id.to_string(),
505        span_id: span_id.to_string(),
506        trace_flags: u8::from_str_radix(flags, 16).ok()?,
507        trace_state: String::new(),
508    })
509}
510
511/// The dispatch-time read: pull [`TRACEPARENT`] out of an envelope's headers and parse
512/// it, attaching [`TRACESTATE`] verbatim. `None` when the header is absent OR invalid —
513/// the two are deliberately indistinguishable to callers (see [`parse_traceparent`]).
514pub fn trace_context(headers: &std::collections::BTreeMap<String, String>) -> Option<TraceContext> {
515    let mut tc = parse_traceparent(headers.get(TRACEPARENT)?)?;
516    // tracestate without a valid traceparent is meaningless, so it rides along only
517    // when the parent parsed. Never validated: it is a vendor-extension blob.
518    tc.trace_state = headers.get(TRACESTATE).cloned().unwrap_or_default();
519    Some(tc)
520}
521
522pub struct AdmitRequest {
523    pub worker: String,
524    pub lease_id: String,
525    pub queues: Vec<String>,
526    pub capacity: u32,
527    pub lease: Duration,
528    pub quantum: i64,
529}
530
531pub struct Claim {
532    pub envelope: Envelope,
533    pub lease_id: String,
534    pub fence: u64,
535    pub expires_at_ms: i64,
536    /// step replay step progress persisted by earlier attempts; empty for a first attempt.
537    pub checkpoint: Checkpoint,
538}
539
540impl Claim {
541    pub fn lease_ref(&self) -> LeaseRef {
542        LeaseRef {
543            job_id: self.envelope.id.clone(),
544            lease_id: self.lease_id.clone(),
545            fence: self.fence,
546        }
547    }
548}
549
550/// batch-shaped admission an admission unit: ordinarily one job, occasionally a group admitted as one
551/// decision. v0.1 always returns units of size 1, but the CONTRACT is group-shaped now
552/// because batched execution changes the gate's accounting in four places (token spend,
553/// fairness quantum, concurrency reservation, crash attribution) and retrofitting that
554/// means reopening the atomic claim after it has traffic. Token spend and deficit charge
555/// count unit SIZE, never row count.
556pub struct AdmissionUnit {
557    pub claims: Vec<Claim>,
558}
559
560impl AdmissionUnit {
561    pub fn size(&self) -> usize {
562        self.claims.len()
563    }
564}
565
566/// Turn the flat, atomically-claimed result into deterministic handler units. Grouping
567/// happens only after the store has charged every row, so N members consume N units of
568/// rate, fairness, and concurrency capacity. It changes dispatch shape, never policy.
569pub fn group_admission_claims(claims: Vec<Claim>, max_unit_size: u32) -> Vec<AdmissionUnit> {
570    let max = max_unit_size.max(1) as usize;
571    let mut units: Vec<AdmissionUnit> = Vec::new();
572    for claim in claims {
573        if let Some(unit) = units.iter_mut().rev().find(|unit| {
574            unit.claims.len() < max
575                && unit
576                    .claims
577                    .first()
578                    .is_some_and(|first| first.envelope.kind == claim.envelope.kind)
579        }) {
580            unit.claims.push(claim);
581        } else {
582            units.push(AdmissionUnit {
583                claims: vec![claim],
584            });
585        }
586    }
587    units
588}
589
590/// Identifies one claimed job for `ack`/`renew`. `admit` writes ONE lease_id for every
591/// job claimed in the same call, and `fence` counts per job — so (lease_id, fence) alone
592/// is ambiguous: two jobs on their first claim in one call are both fence=1. The job id
593/// selects the row; lease_id + fence still gate the write (lease fencing) so a superseded holder
594/// is rejected, never silently no-opped.
595#[derive(Clone, Debug, PartialEq, Eq)]
596pub struct LeaseRef {
597    pub job_id: String,
598    pub lease_id: String,
599    pub fence: u64,
600}
601
602#[derive(Debug)]
603pub enum StoreError {
604    /// job uniqueness duplicate unique key. Carries the winner so the caller can join rather than
605    /// guess. A normal result, not an exception — and never a silent skip.
606    Duplicate {
607        existing_id: String,
608        /// True when the requested allowlisted fields were atomically written to the
609        /// existing non-running holder. It remains a duplicate result so the caller
610        /// still receives the winner's id (job uniqueness).
611        replaced: bool,
612    },
613    /// idempotent enqueue identity a caller-supplied `Envelope.id` that already names a row whose CONTENT
614    /// differs. Distinct from `Duplicate`: that one is best-effort uniqueness over a
615    /// key the caller chose to opt into, this one is the strict per-id guarantee asynq
616    /// separates as `TaskID(id)` + `ErrTaskIDConflict`. Its own variant because the two
617    /// carry different information (the winner's id vs. the id you asked for, which are
618    /// the same string here) and map to different API bodies; folding it into
619    /// `Invalid` — where it lived before — surfaced a 409 condition as a 400.
620    IdConflict {
621        job_id: String,
622    },
623    /// crash quarantine enqueue of a quarantined fingerprint is rejected until an operator releases.
624    Quarantined {
625        fingerprint: String,
626    },
627    /// Producer-side admission control. The store evaluated this against its exact,
628    /// incrementally-maintained unfinished count while serializing producers for the
629    /// queue; callers may retry after capacity is released, route elsewhere, or shed.
630    Backpressure {
631        queue: String,
632        limit: u64,
633        current: u64,
634        incoming: u64,
635    },
636    /// lease fencing the caller no longer holds this lease (reclaimed, or superseded by a newer
637    /// fence). The worker must stop this job immediately.
638    LeaseRejected {
639        job_id: String,
640    },
641    /// typed availability errors the store is unreachable. Typed apart from validation so callers can choose
642    /// between failing the request, degrading, or buffering themselves.
643    Unavailable(String),
644    /// The addressed job/resource does not exist.
645    NotFound(String),
646    /// A request rejected at the boundary — e.g. a duration that rounds to zero (boundary validation).
647    Invalid(String),
648    Backend(String),
649}
650
651impl std::fmt::Display for StoreError {
652    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
653        match self {
654            StoreError::Duplicate { existing_id, .. } => {
655                write!(f, "duplicate unique key; existing job {existing_id}")
656            }
657            StoreError::IdConflict { job_id } => write!(f, "id conflict: job {job_id}"),
658            StoreError::Quarantined { fingerprint } => {
659                write!(f, "fingerprint {fingerprint} is quarantined")
660            }
661            StoreError::Backpressure {
662                queue,
663                limit,
664                current,
665                incoming,
666            } => write!(
667                f,
668                "enqueue backpressure: queue {queue} has {current} unfinished jobs, limit {limit}, incoming {incoming}"
669            ),
670            StoreError::LeaseRejected { job_id } => write!(
671                f,
672                "lease no longer held for job {job_id}; stop work immediately"
673            ),
674            StoreError::Unavailable(m) => write!(f, "store unavailable: {m}"),
675            StoreError::NotFound(m) => write!(f, "not found: {m}"),
676            StoreError::Invalid(m) => write!(f, "invalid request: {m}"),
677            StoreError::Backend(m) => write!(f, "{m}"),
678        }
679    }
680}
681impl std::error::Error for StoreError {}
682
683#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
684pub struct Caps(pub u32);
685impl Caps {
686    pub const TRANSACTIONAL: Caps = Caps(1);
687    pub const NOTIFYING: Caps = Caps(2);
688    pub const INSPECT: Caps = Caps(4);
689    pub const fn has(self, c: Caps) -> bool {
690        self.0 & c.0 != 0
691    }
692}
693
694/// The whole port. Coarse on purpose — the admission decision must stay atomic inside
695/// the store, so a fine-grained port would force the gate back into the worker.
696///
697/// `async_trait` rather than RPITIT so `Box<dyn Store>` works: selecting a backend from
698/// a config string needs a trait object, and `impl Future` in trait position is not
699/// dyn-compatible. Store calls are I/O-bound, so the boxed future is noise.
700#[async_trait::async_trait]
701pub trait Store: Send + Sync + 'static {
702    /// The whole admission decision: policy + claim + lease, atomically, store-side.
703    async fn admit(&self, req: AdmitRequest) -> Result<Vec<AdmissionUnit>, StoreError>;
704    /// Apply the transition table for `outcome`, write the error history, honour the
705    /// fence. `delay_ms`: required for `Snooze` (must be > 0); for `Retry` it overrides
706    /// the store's default backoff (the retry-policy port computes it caller-side);
707    /// ignored otherwise. `LeaseLost` is never acked — it is the reclaimer's transition.
708    /// Convenience over [`Store::ack_attempt`] with no logs.
709    async fn ack(
710        &self,
711        lease: &LeaseRef,
712        outcome: Outcome,
713        err: Option<&str>,
714        delay_ms: Option<i64>,
715    ) -> Result<(), StoreError> {
716        self.ack_attempt_with_actual_weight(lease, outcome, err, delay_ms, &[], None)
717            .await
718    }
719    /// [`Store::ack`] plus attempt-log contract per-attempt execution logs (River's riverlog):
720    /// captured handler log lines land INSIDE the attempt's error-history entry, so
721    /// the console shows why an attempt failed, not just that it did. Recorded for
722    /// success / retry / skip / undecodable (a non-empty `logs` on success writes a
723    /// success entry — the only time one exists); dropped for snooze / rate_limited /
724    /// revoke, which by design record no attempt entry.
725    async fn ack_attempt(
726        &self,
727        lease: &LeaseRef,
728        outcome: Outcome,
729        err: Option<&str>,
730        delay_ms: Option<i64>,
731        logs: &[String],
732    ) -> Result<(), StoreError> {
733        self.ack_attempt_with_actual_weight(lease, outcome, err, delay_ms, logs, None)
734            .await
735    }
736    /// [`Store::ack_attempt`] plus surveyed policy behavior cost reconciliation. Admission charges the
737    /// envelope's estimated `weight`; `Some(actual)` corrects that charge under the
738    /// same fence and in the same atomic write as the state transition. `Some(0)` is a
739    /// real full refund; `None` means the estimate was exact. The extra method is kept
740    /// coarse on purpose: a separate reconcile call could commit after a rejected ack.
741    async fn ack_attempt_with_actual_weight(
742        &self,
743        lease: &LeaseRef,
744        outcome: Outcome,
745        err: Option<&str>,
746        delay_ms: Option<i64>,
747        logs: &[String],
748        actual_weight: Option<u32>,
749    ) -> Result<(), StoreError>;
750    /// Optional capability surface for a success transition that records result bytes
751    /// under the same fence. A backend implementing [`ResultStore`] returns it here.
752    fn as_result_store(&self) -> Option<&dyn ResultStore> {
753        None
754    }
755    /// Optional capability for fence-verified mid-run output writes. Unlike a final
756    /// result this does not transition the job; it succeeds only for the current
757    /// running lease and returns store-stamped attempt/time metadata.
758    fn as_output_store(&self) -> Option<&dyn OutputStore> {
759        None
760    }
761    /// Optional capability for operator-facing progress writes. Like mid-run output,
762    /// this is a fenced write that does not transition the job.
763    fn as_progress_store(&self) -> Option<&dyn ProgressStore> {
764        None
765    }
766    /// Extend leases; return the job ids of the leases that were LOST. A worker that
767    /// lost a lease must be able to stop — silently succeeding here is how asynq
768    /// stranded jobs in ACTIVE since 2022.
769    async fn renew(&self, leases: &[LeaseRef], lease: Duration) -> Result<Vec<String>, StoreError>;
770    async fn enqueue(&self, batch: &[Envelope]) -> Result<(), StoreError>;
771
772    /// step replay persist step progress, fence-verified: the write succeeds only while the
773    /// caller still holds the lease, so it doubles as the step boundary's lease check.
774    /// `LeaseRejected` here means STOP — do not run the next step's side effects.
775    /// Durable BEFORE the step runs, never after the worker returns (River's mistake).
776    async fn checkpoint(&self, lease: &LeaseRef, cp: &Checkpoint) -> Result<(), StoreError>;
777
778    /// lease fencing/crash quarantine the lease reclaimer's sweep. An expired lease is `Outcome::LeaseLost`,
779    /// NEVER `Retry`: it increments `crash_attempt` and leaves `attempt` alone. At the
780    /// crash limit the job parks in `quarantined` and its fingerprint is registered.
781    /// Safe under contention; run it via a duty lease to avoid redundant sweeps.
782    async fn reclaim_expired(&self, limit: i64) -> Result<Vec<Reclaimed>, StoreError>;
783
784    /// The `schedule_due`/`backoff_due` sweep: due `scheduled` and `retryable` jobs
785    /// become `available`. Returns how many were promoted.
786    async fn promote_due(&self, limit: i64) -> Result<u64, StoreError>;
787
788    /// retention and eviction contract the retention sweep: TERMINAL jobs whose `finalized_at_ms + retention_ms`
789    /// has lapsed are deleted (the transition table's `completed -> deleted` by
790    /// retention; `retention_ms = 0` already deleted at ack time). `quarantined` is
791    /// exempt — it parks VISIBLY until an operator acts, never silently expires.
792    /// Bounded per call; run under the `retention` duty lease.
793    async fn evict_retained(&self, limit: i64) -> Result<u64, StoreError>;
794
795    /// singleton duties claim (or renew) a singleton duty. Same compare-and-set as claiming a job,
796    /// on store time — a skewed node cannot steal a duty early. `false` = someone else
797    /// holds it; skip this tick, never block on it.
798    async fn claim_duty(
799        &self,
800        name: &str,
801        holder: &str,
802        lease: Duration,
803    ) -> Result<bool, StoreError>;
804
805    /// singleton duties step down by expiring the duty immediately, so takeover is fast. A no-match
806    /// (not the holder) is fine — release is best-effort on shutdown.
807    async fn release_duty(&self, name: &str, holder: &str) -> Result<(), StoreError>;
808
809    fn caps(&self) -> Caps;
810    /// runtime capability boundary Runtime capability upcast. `None` means genuinely unsupported — never a
811    /// silent no-op, and never a config knob that does nothing.
812    fn as_transactional(&self) -> Option<&dyn Transactional> {
813        None
814    }
815    /// control plane the inspection/control surface. Same rule as `as_transactional`.
816    fn as_inspect(&self) -> Option<&dyn Inspect> {
817        None
818    }
819    /// push wakeups push wakeup. MySQL never has this (poll only); PgBouncer in transaction
820    /// pooling breaks it, which is why poll-only remains a first-class mode.
821    fn as_notifying(&self) -> Option<&dyn Notifying> {
822        None
823    }
824}
825
826#[async_trait::async_trait]
827pub trait ResultStore: Send + Sync + 'static {
828    async fn ack_success_with_result(
829        &self,
830        lease: &LeaseRef,
831        logs: &[String],
832        actual_weight: Option<u32>,
833        result: &JobResult,
834    ) -> Result<(), StoreError>;
835}
836
837#[async_trait::async_trait]
838pub trait OutputStore: Send + Sync + 'static {
839    async fn write_job_output(
840        &self,
841        lease: &LeaseRef,
842        output: &JobResult,
843    ) -> Result<JobOutput, StoreError>;
844}
845
846#[async_trait::async_trait]
847pub trait ProgressStore: Send + Sync + 'static {
848    async fn write_job_progress(
849        &self,
850        lease: &LeaseRef,
851        update: &ProgressUpdate,
852    ) -> Result<JobProgress, StoreError>;
853}
854
855/// push wakeups push wakeup: sub-poll-interval latency when the store can signal new work.
856/// A missed or spurious notification costs LATENCY, never correctness — the poll
857/// fallback always stands (River's layered-fetch lesson).
858#[async_trait::async_trait]
859pub trait Notifying: Store {
860    /// Wait up to `timeout` for a hint that work may be available. An empty `queues`
861    /// slice matches ANY queue (the UI's one-subscription case, bounded live-control contract). Returns the
862    /// waking queue's name, or `None` on timeout. Wakeups may be spurious; callers
863    /// admit either way when their poll timer expires — a wakeup only shortcuts it.
864    async fn wait_wakeup(
865        &self,
866        queues: &[String],
867        timeout: Duration,
868    ) -> Result<Option<String>, StoreError>;
869}
870
871/// A job the lease reclaimer swept. `quarantined` tells the caller which counter and
872/// event to emit — eviction and quarantine are never silent (retention and eviction contract).
873#[derive(Clone, Debug)]
874pub struct Reclaimed {
875    pub job_id: String,
876    pub fingerprint: String,
877    pub crash_attempt: u32,
878    pub quarantined: bool,
879}
880
881/// A caller-owned store transaction. Adapters downcast to their own concrete handle via
882/// `as_any` and reject a foreign one — the compile-time path (transactional API) is generic and never
883/// hits this; the `dyn` path needs the runtime check.
884pub trait TxHandle: Send {
885    fn as_any(&mut self) -> &mut (dyn std::any::Any + Send);
886    /// Consuming downcast, for commit/rollback which take the handle by value.
887    fn into_any(self: Box<Self>) -> Box<dyn std::any::Any + Send>;
888}
889
890#[async_trait::async_trait]
891pub trait Transactional: Store {
892    /// Open a store transaction for the dyn path (transactional API). Callers holding their own
893    /// driver transaction wrap it instead (caller-owned transaction contract) — this is for code that only knows
894    /// `dyn Transactional`, like [`Job.Once`]-style helpers.
895    async fn begin_tx(&self) -> Result<Box<dyn TxHandle>, StoreError>;
896    async fn commit_tx(&self, tx: Box<dyn TxHandle>) -> Result<(), StoreError>;
897    async fn rollback_tx(&self, tx: Box<dyn TxHandle>) -> Result<(), StoreError>;
898    async fn enqueue_tx(&self, tx: &mut dyn TxHandle, batch: &[Envelope])
899    -> Result<(), StoreError>;
900    async fn complete_tx(&self, tx: &mut dyn TxHandle, lease: &LeaseRef) -> Result<(), StoreError> {
901        self.complete_tx_with_actual_weight(tx, lease, None).await
902    }
903    /// Transactional completion with the same surveyed policy behavior post-hoc correction as ack. This
904    /// exists separately because `once` completes inside the caller's transaction;
905    /// reconciling outside it could charge an effect whose fenced completion rolled back.
906    async fn complete_tx_with_actual_weight(
907        &self,
908        tx: &mut dyn TxHandle,
909        lease: &LeaseRef,
910        actual_weight: Option<u32>,
911    ) -> Result<(), StoreError>;
912    /// transactional effects claim an effect key inside the caller's transaction. `false` means the key
913    /// was already claimed by a COMMITTED transaction — the effect ran; skip the work.
914    /// The claim commits (or vanishes) with everything else in the transaction, which
915    /// is the entire mechanism behind at-most-once effects.
916    async fn claim_effect(&self, tx: &mut dyn TxHandle, key: &str) -> Result<bool, StoreError>;
917    /// step replay × transactional effects write the checkpoint inside the caller's transaction, fence-verified.
918    /// This is what makes a step's effects and its completion marker ONE commit: a
919    /// step-scoped `once` claims `{job}/{step}`, does its writes, and records the step
920    /// complete — atomically. A superseded holder fails here and everything rolls back.
921    async fn checkpoint_tx(
922        &self,
923        tx: &mut dyn TxHandle,
924        lease: &LeaseRef,
925        cp: &Checkpoint,
926    ) -> Result<(), StoreError>;
927}
928
929// ---------- control plane the inspection/control port ----------
930
931#[derive(Clone, Debug)]
932pub struct JobSummary {
933    pub id: String,
934    pub kind: String,
935    pub queue: String,
936    pub state: String,
937    pub schema_version: u32,
938    pub priority: i32,
939    pub attempt: u32,
940    pub crash_attempt: u32,
941    pub max_attempts: u32,
942    pub partition_key: String,
943    pub rate_class: String,
944    pub sticky_worker: String,
945    pub weight: u32,
946    pub fingerprint: String,
947    pub enqueued_at_ms: i64,
948    pub scheduled_at_ms: i64,
949    pub periodic_schedule_id: String,
950    pub periodic_tick_ms: i64,
951    pub finalized_at_ms: Option<i64>,
952    /// Invariant 9: `None` unless the caller explicitly asked. Payloads carry PII and
953    /// the console mounts at /admin.
954    pub payload: Option<Vec<u8>>,
955    /// The per-attempt error history, as the JSON the store keeps (attempt-log contract timeline).
956    pub errors_json: String,
957    pub tags: Vec<String>,
958}
959
960impl JobSummary {
961    /// True once the store has reclaimed this job from an expired worker lease.
962    /// This is durable provenance derived from `crash_attempt`, not a second state.
963    pub fn is_orphaned(&self) -> bool {
964        self.crash_attempt > 0
965    }
966}
967
968#[derive(Clone, Debug, Default)]
969pub struct JobFilter {
970    pub queue: Option<String>,
971    pub state: Option<String>,
972    pub kind: Option<String>,
973    /// A bare term in the `q` search grammar matches kind by prefix.
974    pub kind_prefix: Option<String>,
975    pub partition_key: Option<String>,
976    pub id: Option<String>,
977    pub fingerprint: Option<String>,
978    pub rate_class: Option<String>,
979    pub priority: Option<i32>,
980    /// Every listed tag must be present.
981    pub tags_all: Vec<String>,
982    /// At least one listed tag must be present.
983    pub tags_any: Vec<String>,
984}
985
986pub struct JobPage {
987    pub jobs: Vec<JobSummary>,
988    pub next_cursor: Option<String>,
989}
990
991/// bounded-count contract/bounded live-control contract counts come from a BOUNDED scan, never O(queue depth): past the
992/// threshold, `approximate` is set instead of paying for exactness.
993pub struct StateCounts {
994    pub counts: Vec<(String, i64)>,
995    pub approximate: bool,
996}
997
998pub struct QueueStats {
999    pub queue: String,
1000    /// Fleet policy used by the atomic gate to choose BETWEEN queues. This is unrelated
1001    /// to an envelope's rate-budget `weight`, which prices one job after a queue wins.
1002    pub weight: u32,
1003    /// Exact O(1) backlog count used by enqueue backpressure. Unlike `by_state`, this
1004    /// is never approximate and excludes every terminal state.
1005    pub unfinished_jobs: u64,
1006    /// `None` disables producer backpressure for this queue. Zero is a useful intake
1007    /// kill switch and is deliberately distinct from queue pause (which stops drain).
1008    pub max_unfinished_jobs: Option<u64>,
1009    pub by_state: Vec<(String, i64)>,
1010    pub counts_approximate: bool,
1011    /// backlog metrics jobs/sec over the last minute — a READ, not a Prometheus recording rule.
1012    pub arrival_rate: f64,
1013    pub drain_rate: f64,
1014    /// `None` when arrival >= drain: THIS is the alert condition, not depth.
1015    pub time_to_drain_ms: Option<i64>,
1016    /// backlog metrics store-clock age of the oldest job that is currently `available`.
1017    /// `None` means the queue has no available job. This is an age rather than the
1018    /// underlying timestamp so callers can compare it directly with a latency SLO.
1019    pub oldest_available_ms: Option<i64>,
1020    /// backlog metrics the same four backlog signals after partitions with disproportionate
1021    /// in-flight work are excluded. One tenant's flood must not page an operator about
1022    /// every other tenant's latency.
1023    pub quiet_groups: QuietGroupMetrics,
1024    pub paused: bool,
1025    /// Last bounded, explicitly sampled storage estimate. Never computed synchronously
1026    /// by this read; `None` means this backend or queue has no sample yet.
1027    pub memory_bytes: Option<u64>,
1028}
1029
1030#[derive(Clone, Debug, Default)]
1031pub struct QuietGroupMetrics {
1032    pub arrival_rate: f64,
1033    pub drain_rate: f64,
1034    pub time_to_drain_ms: Option<i64>,
1035    pub oldest_available_ms: Option<i64>,
1036    /// Exposed so an identical-looking quiet view cannot silently mean "no peers seen".
1037    pub noisy_partitions: u32,
1038    /// True when the fixed partition or backlog bound was hit.
1039    pub approximate: bool,
1040}
1041
1042/// Classify noisy neighbours from observed in-flight skew (tenant fairness/backlog metrics).
1043///
1044/// A partition is noisy when it holds at least two jobs and more than twice the mean
1045/// in-flight work of every peer partition. One partition alone is never noisy: there is
1046/// nobody for it to disturb. Integer cross-products keep Rust and Go identical at the
1047/// threshold boundary.
1048pub fn noisy_partition_keys(loads: &[(String, i64)]) -> std::collections::BTreeSet<String> {
1049    let mut out = std::collections::BTreeSet::new();
1050    if loads.len() < 2 {
1051        return out;
1052    }
1053    for (i, (key, raw_n)) in loads.iter().enumerate() {
1054        let n = (*raw_n).max(0) as u128;
1055        if n < 2 {
1056            continue;
1057        }
1058        let others: u128 = loads
1059            .iter()
1060            .enumerate()
1061            .filter(|(j, _)| *j != i)
1062            .map(|(_, (_, v))| (*v).max(0) as u128)
1063            .sum();
1064        if n * (loads.len() as u128 - 1) > 2 * others {
1065            out.insert(key.clone());
1066        }
1067    }
1068    out
1069}
1070
1071#[derive(Clone, Debug)]
1072pub struct RateClassConfig {
1073    pub name: String,
1074    pub limit: i64,
1075    pub window_ms: i64,
1076    pub burst: i64,
1077    /// Invariant 16: the kill switch. Admit nothing in this class until unpaused.
1078    pub paused: bool,
1079}
1080
1081/// surveyed policy behavior the action the atomic gate takes when a partition has reached its configured
1082/// concurrency ceiling. String values are the wire/storage contract across all backends.
1083#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1084pub enum SaturationStrategy {
1085    #[default]
1086    Queue,
1087    Discard,
1088    CancelRunning,
1089    CancelIncoming,
1090}
1091
1092impl SaturationStrategy {
1093    pub fn as_str(self) -> &'static str {
1094        match self {
1095            Self::Queue => "queue",
1096            Self::Discard => "discard",
1097            Self::CancelRunning => "cancel_running",
1098            Self::CancelIncoming => "cancel_incoming",
1099        }
1100    }
1101}
1102
1103impl TryFrom<&str> for SaturationStrategy {
1104    type Error = StoreError;
1105
1106    fn try_from(value: &str) -> Result<Self, Self::Error> {
1107        match value {
1108            "queue" => Ok(Self::Queue),
1109            "discard" => Ok(Self::Discard),
1110            "cancel_running" => Ok(Self::CancelRunning),
1111            "cancel_incoming" => Ok(Self::CancelIncoming),
1112            _ => Err(StoreError::Invalid(format!(
1113                "unknown saturation strategy `{value}`"
1114            ))),
1115        }
1116    }
1117}
1118
1119#[derive(Clone, Debug, Eq, PartialEq)]
1120pub struct ConcurrencyLimitConfig {
1121    pub name: String,
1122    pub queue: String,
1123    pub max_concurrent: u64,
1124    pub on_saturated: SaturationStrategy,
1125}
1126
1127pub struct RateClassState {
1128    pub name: String,
1129    pub tokens_available: i64,
1130    pub burst: i64,
1131    pub limit_per_window: i64,
1132    pub window_ms: i64,
1133    pub jobs_waiting: i64,
1134    pub paused: bool,
1135}
1136
1137pub struct PartitionState {
1138    pub partition_key: String,
1139    pub deficit: i64,
1140    pub waiting: i64,
1141}
1142
1143pub struct QuarantineEntry {
1144    pub fingerprint: String,
1145    pub kind: String,
1146    pub crash_count: i64,
1147    pub quarantined_at_ms: i64,
1148    pub reason: String,
1149}
1150
1151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1152pub enum BlockedBy {
1153    RateClass,
1154    ConcurrencyLimit,
1155    Fairness,
1156    Quarantine,
1157    Schedule,
1158    QueuePaused,
1159}
1160
1161impl BlockedBy {
1162    pub const fn as_str(self) -> &'static str {
1163        match self {
1164            BlockedBy::RateClass => "rate_class",
1165            BlockedBy::ConcurrencyLimit => "concurrency_limit",
1166            BlockedBy::Fairness => "fairness",
1167            BlockedBy::Quarantine => "quarantine",
1168            BlockedBy::Schedule => "schedule",
1169            BlockedBy::QueuePaused => "queue_paused",
1170        }
1171    }
1172}
1173
1174/// admission policy the answer to "why is this job not running" — the question this design creates
1175/// and the endpoint no predecessor needs, because no predecessor has a gate.
1176pub struct AdmissionExplain {
1177    pub state: String,
1178    pub admissible: bool,
1179    pub blocked_by: Option<BlockedBy>,
1180    /// State of the blocking policy — tokens left, queue position, crash count.
1181    pub detail: Vec<(String, String)>,
1182    /// `None` when the block will not clear on its own (quarantine, paused queue).
1183    pub estimated_admission_ms: Option<i64>,
1184}
1185
1186#[derive(Clone, Debug)]
1187pub struct HistoryBucket {
1188    pub at_ms: i64,
1189    pub arrived: i64,
1190    pub completed: i64,
1191}
1192
1193/// surveyed policy behavior what happens to periodic runs missed during downtime. Nobody in the surveyed
1194/// field backfills; River can skip a tick entirely across a leader election.
1195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1196pub enum MissedPolicy {
1197    /// Default, and what every other queue does: a stale backlog is dropped; the most
1198    /// recent tick fires only if it is less than one period old.
1199    Skip,
1200    /// One catch-up run covers the backlog, no matter how old.
1201    RunOnce,
1202    /// Fire the most recent `backfill_limit` missed ticks, each as its own job.
1203    Backfill,
1204}
1205
1206impl MissedPolicy {
1207    pub const fn as_str(self) -> &'static str {
1208        match self {
1209            MissedPolicy::Skip => "skip",
1210            MissedPolicy::RunOnce => "run_once",
1211            MissedPolicy::Backfill => "backfill",
1212        }
1213    }
1214    pub fn parse(s: &str) -> Option<Self> {
1215        match s {
1216            "skip" => Some(Self::Skip),
1217            "run_once" => Some(Self::RunOnce),
1218            "backfill" => Some(Self::Backfill),
1219            _ => None,
1220        }
1221    }
1222}
1223
1224/// A periodic entry. Durable in the store (surveyed policy behavior), never in a leader's memory. The
1225/// store treats `spec` as opaque; tick computation lives caller-side so every backend
1226/// stays spec-agnostic.
1227#[derive(Clone, Debug)]
1228pub struct Schedule {
1229    pub id: String,
1230    pub kind: String,
1231    pub payload: Vec<u8>,
1232    pub queue: String,
1233    pub partition_key: String,
1234    pub rate_class: String,
1235    pub priority: i32,
1236    pub max_attempts: u32,
1237    pub retention_ms: i64,
1238    /// "@every:<ms>" (epoch-aligned) or a UTC cron expression.
1239    pub spec: String,
1240    /// The next UNFIRED tick. Advancing past it is a compare-and-set, so racing
1241    /// scheduler nodes cannot double-advance.
1242    pub next_run_ms: i64,
1243    pub last_enqueued_ms: Option<i64>,
1244    pub on_missed: MissedPolicy,
1245    pub backfill_limit: u32,
1246    pub paused: bool,
1247}
1248
1249/// One durable scheduler enqueue attempt. Stores retain only the newest
1250/// [`SCHEDULE_EVENT_LIMIT`] records per schedule, so operator inspection is bounded.
1251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1252pub enum ScheduleEventOutcome {
1253    Enqueued,
1254    Deduplicated,
1255    Failed,
1256    Skipped,
1257}
1258
1259impl ScheduleEventOutcome {
1260    pub fn as_str(self) -> &'static str {
1261        match self {
1262            Self::Enqueued => "enqueued",
1263            Self::Deduplicated => "deduplicated",
1264            Self::Failed => "failed",
1265            Self::Skipped => "skipped",
1266        }
1267    }
1268
1269    pub fn parse(value: &str) -> Option<Self> {
1270        match value {
1271            "enqueued" => Some(Self::Enqueued),
1272            "deduplicated" => Some(Self::Deduplicated),
1273            "failed" => Some(Self::Failed),
1274            "skipped" => Some(Self::Skipped),
1275            _ => None,
1276        }
1277    }
1278}
1279
1280pub const SCHEDULE_EVENT_LIMIT: u32 = 100;
1281
1282#[derive(Clone, Debug, Eq, PartialEq)]
1283pub struct ScheduleEvent {
1284    /// Store-generated monotonic sequence, used as the opaque pagination cursor.
1285    pub event_id: u64,
1286    pub schedule_id: String,
1287    pub tick_ms: i64,
1288    pub job_id: String,
1289    pub outcome: ScheduleEventOutcome,
1290    /// Stable, low-cardinality classification; never a raw backend error or payload.
1291    pub reason: String,
1292    /// Populated from store time by the backend.
1293    pub recorded_at_ms: i64,
1294}
1295
1296#[derive(Clone, Debug, Default)]
1297pub struct WorkerMeta {
1298    pub worker_id: String,
1299    pub host: String,
1300    pub pid: i32,
1301    pub queues: Vec<String>,
1302    /// The worker's configured capacity — the denominator of `inflight / capacity`.
1303    pub concurrency: u32,
1304    pub started_at_ms: i64,
1305    pub heartbeat_at_ms: i64,
1306    // ----- the cluster view and the backlog metrics autoscaling signal -----
1307    // ADDITIVE on the heartbeat that already runs. The registry knew each worker's
1308    // queues and capacity but not what it was DOING, so "which queues have zero live
1309    // workers" and "is this fleet the right size" were both unanswerable from the
1310    // store. All three are levels reported by the worker, never derived by the server.
1311    /// Jobs this worker is running right now.
1312    pub inflight: u32,
1313    /// Admissions attempted in the runner's rolling window.
1314    pub polls: u64,
1315    /// Of those, how many returned zero jobs. The RATIO is the scale-down signal;
1316    /// the two counters ride the wire instead of a float so the aggregate is exact
1317    /// and so neither language has to agree with the other about float formatting.
1318    pub empty_polls: u64,
1319}
1320
1321impl WorkerMeta {
1322    /// backlog metrics `inflight / capacity`. 0.0 when capacity is 0 — never a division by zero,
1323    /// and never 1.0 for a worker that cannot run anything.
1324    pub fn utilization(&self) -> f64 {
1325        if self.concurrency == 0 {
1326            0.0
1327        } else {
1328            self.inflight as f64 / self.concurrency as f64
1329        }
1330    }
1331    /// backlog metrics empty admissions / total admissions over the reported window. 0.0 when the
1332    /// window is empty — an idle-since-startup worker has no evidence either way, and
1333    /// reporting 1.0 there would signal "scale down" from no data at all.
1334    pub fn empty_poll_ratio(&self) -> f64 {
1335        if self.polls == 0 {
1336            0.0
1337        } else {
1338            self.empty_polls as f64 / self.polls as f64
1339        }
1340    }
1341}
1342
1343/// control API contract a bulk mutation as data: created by the API, executed by a duty in bounded
1344/// batches, polled by the caller. An empty selector is rejected at the boundary.
1345#[derive(Clone, Debug)]
1346pub struct BulkRequest {
1347    pub id: String,
1348    pub action: String,
1349    pub queue: Option<String>,
1350    pub state: Option<String>,
1351    pub kind: Option<String>,
1352    pub partition_key: Option<String>,
1353    pub older_than_ms: Option<i64>,
1354    pub dry_run: bool,
1355}
1356
1357#[derive(Clone, Debug)]
1358pub struct OperationStatus {
1359    pub id: String,
1360    pub status: String,
1361    pub affected: i64,
1362    pub total_estimated: i64,
1363    pub dry_run: bool,
1364    pub error: Option<String>,
1365}
1366
1367/// control plane the control API's store surface. Separate from [`Store`] the way
1368/// [`Transactional`] is (runtime capability boundary): a backend that cannot answer these does not have them.
1369/// Every read here is bounded — no method may be O(queue depth) (invariant 6).
1370#[async_trait::async_trait]
1371pub trait Inspect: Store {
1372    /// Optional explicit result-byte reader. Keeping this separate from job/list reads
1373    /// prevents an accidental payload leak and preserves capability honesty.
1374    fn as_result_inspect(&self) -> Option<&dyn ResultInspect> {
1375        None
1376    }
1377    /// Optional explicit reader for mid-run output bytes. Ordinary job/list reads keep
1378    /// omitting them for the same PII posture as final results and payloads.
1379    fn as_output_inspect(&self) -> Option<&dyn OutputInspect> {
1380        None
1381    }
1382    /// Optional explicit reader for operator-facing progress. It stays outside ordinary
1383    /// job/list reads because even a short application message may contain sensitive data.
1384    fn as_progress_inspect(&self) -> Option<&dyn ProgressInspect> {
1385        None
1386    }
1387    async fn get_job(
1388        &self,
1389        id: &str,
1390        include_payload: bool,
1391    ) -> Result<Option<JobSummary>, StoreError>;
1392    async fn list_jobs(
1393        &self,
1394        filter: &JobFilter,
1395        cursor: Option<&str>,
1396        limit: u32,
1397    ) -> Result<JobPage, StoreError>;
1398    async fn counts(&self, queue: Option<&str>) -> Result<StateCounts, StoreError>;
1399    async fn queue_stats(&self) -> Result<Vec<QueueStats>, StoreError>;
1400    async fn set_queue_paused(&self, queue: &str, paused: bool) -> Result<(), StoreError>;
1401    /// Invariant 16: queue-selection weight is fleet policy, not a worker-local polling
1402    /// hint. `weight == 0` is invalid; omitted/unconfigured queues read as weight 1.
1403    async fn set_queue_weight(&self, queue: &str, weight: u32) -> Result<(), StoreError>;
1404    /// Configure the fleet-wide enqueue bound. The store may accept a limit below the
1405    /// current depth; that immediately stops growth until drain catches up.
1406    async fn set_enqueue_limit(
1407        &self,
1408        queue: &str,
1409        max_unfinished_jobs: Option<u64>,
1410    ) -> Result<(), StoreError>;
1411    async fn rate_classes(&self) -> Result<Vec<RateClassState>, StoreError>;
1412    /// Invariant 16: any policy the gate reads, the API can write — a fleet limit you
1413    /// cannot change without a redeploy is not an operational feature.
1414    async fn upsert_rate_class(&self, cfg: &RateClassConfig) -> Result<(), StoreError>;
1415    async fn concurrency_limits(&self) -> Result<Vec<ConcurrencyLimitConfig>, StoreError>;
1416    async fn upsert_concurrency_limit(
1417        &self,
1418        cfg: &ConcurrencyLimitConfig,
1419    ) -> Result<(), StoreError>;
1420    async fn partitions(&self, queue: &str) -> Result<Vec<PartitionState>, StoreError>;
1421    async fn quarantine_list(&self) -> Result<Vec<QuarantineEntry>, StoreError>;
1422    /// crash quarantine deliberate operator action: quarantined jobs of this fingerprint become
1423    /// available (`operator_release`) and new enqueues are accepted again. Returns how
1424    /// many jobs were released. A released job re-quarantines on its next crash.
1425    async fn quarantine_release(&self, fingerprint: &str) -> Result<u64, StoreError>;
1426    /// `archived → available` (`operator_retry`). Any other state is an error — the
1427    /// transition table defines exactly which rows exist.
1428    async fn operator_retry(&self, id: &str) -> Result<(), StoreError>;
1429    /// `scheduled|available|running → cancelled` (`operator_cancel`). Cancelling a
1430    /// running job clears its lease, so the holder's next renew/ack/checkpoint is
1431    /// rejected and its handler stops within a heartbeat.
1432    async fn operator_cancel(&self, id: &str) -> Result<(), StoreError>;
1433    /// `pending -> available`. No timer or dependency watcher may perform this change.
1434    async fn promote_job(&self, id: &str) -> Result<(), StoreError>;
1435    /// Delete a non-running job. Deleting mid-flight is refused (asynq's rule).
1436    async fn delete_job(&self, id: &str) -> Result<(), StoreError>;
1437    async fn explain_admission(&self, id: &str) -> Result<Option<AdmissionExplain>, StoreError>;
1438    /// backlog metrics time series from the incrementally-maintained counters — never a scan.
1439    async fn history(
1440        &self,
1441        queue: &str,
1442        since_ms: i64,
1443        bucket_ms: i64,
1444    ) -> Result<Vec<HistoryBucket>, StoreError>;
1445
1446    /// crash quarantine the quarantine sweeper (singleton duties's duty): waiting jobs whose fingerprint is
1447    /// quarantined move to the terminal `quarantined` state, VISIBLY — without this
1448    /// they sit gate-excluded forever, which is an invisible skip. Returns how many
1449    /// moved. Bounded per call; run under a duty lease.
1450    async fn quarantine_sweep(&self, limit: i64) -> Result<u64, StoreError>;
1451
1452    /// Move a waiting job's run time. Defined only for `scheduled` and `retryable` —
1453    /// no state changes, so no transition-table row is needed.
1454    async fn reschedule_job(&self, id: &str, at_ms: i64) -> Result<(), StoreError>;
1455    /// Edit-then-retry (control API contract). Non-running jobs only. The fingerprint is derived
1456    /// caller-side (content fingerprinting) and passed in, because it must change with the payload.
1457    async fn edit_payload(
1458        &self,
1459        id: &str,
1460        payload: &[u8],
1461        schema_version: u32,
1462        fingerprint: &str,
1463    ) -> Result<(), StoreError>;
1464
1465    // ----- surveyed policy behavior periodic schedules (durable, leaderless) -----
1466
1467    /// Idempotent upsert (BullMQ's `upsertJobScheduler`). `next_run_ms` is kept from
1468    /// the existing row when the spec is unchanged, so re-deploying a config does not
1469    /// reset the phase of a running schedule.
1470    async fn upsert_schedule(&self, s: &Schedule) -> Result<(), StoreError>;
1471    async fn delete_schedule(&self, id: &str) -> Result<(), StoreError>;
1472    async fn list_schedules(&self) -> Result<Vec<Schedule>, StoreError>;
1473    /// Due entries plus STORE time — tick math must not use a worker clock.
1474    async fn due_schedules(&self, limit: i64) -> Result<(Vec<Schedule>, i64), StoreError>;
1475    /// Compare-and-set advance: succeeds only if `next_run_ms` still equals `from`.
1476    /// Losing the race means another node already advanced — never an error.
1477    async fn advance_schedule(
1478        &self,
1479        id: &str,
1480        from_next_run_ms: i64,
1481        to_next_run_ms: i64,
1482    ) -> Result<bool, StoreError>;
1483    /// Append one scheduler enqueue attempt and trim older history atomically.
1484    async fn record_schedule_event(&self, event: &ScheduleEvent) -> Result<(), StoreError>;
1485    /// Newest first. `limit` must be in `1..=SCHEDULE_EVENT_LIMIT`.
1486    async fn list_schedule_events(
1487        &self,
1488        schedule_id: &str,
1489        before_event_id: Option<u64>,
1490        limit: u32,
1491    ) -> Result<Vec<ScheduleEvent>, StoreError>;
1492
1493    // ----- worker registry + surveyed policy behavior server->worker control channel -----
1494
1495    /// Upsert the worker row and return any pending operator COMMAND for it — the
1496    /// control channel rides the heartbeat that is already happening (Faktory's BEAT):
1497    /// "quiet" stops admitting, "resume" resumes, "restart" drains without a
1498    /// timeout, "terminate" performs a bounded shutdown, and "resign" releases
1499    /// singleton duties.
1500    async fn heartbeat_worker(&self, w: &WorkerMeta) -> Result<Option<String>, StoreError>;
1501    /// Workers whose heartbeat is within `stale_after_ms` of store-now.
1502    async fn list_workers(&self, stale_after_ms: i64) -> Result<Vec<WorkerMeta>, StoreError>;
1503    /// surveyed policy behavior set (or clear, with `None`) a worker's pending command. Delivered on its
1504    /// next heartbeat; sticky until changed.
1505    async fn signal_worker(&self, worker_id: &str, command: Option<&str>)
1506    -> Result<(), StoreError>;
1507    /// typed dispatch distinct kinds currently present among waiting jobs (bounded sample), so a
1508    /// runner can warn at startup about kinds no registered handler answers.
1509    async fn distinct_kinds(&self, limit: i64) -> Result<Vec<String>, StoreError>;
1510
1511    // ----- control API contract async bulk operations -----
1512
1513    async fn create_operation(&self, req: &BulkRequest) -> Result<(), StoreError>;
1514    async fn get_operation(&self, id: &str) -> Result<Option<OperationStatus>, StoreError>;
1515    /// Execute one bounded batch of each pending operation (run under a duty lease).
1516    /// Returns rows affected this sweep; an operation whose batch comes back short is
1517    /// marked completed.
1518    async fn run_pending_operations(&self, batch: i64) -> Result<u64, StoreError>;
1519
1520    /// Refuse a non-empty queue unless `force`; forced deletion is represented by a
1521    /// bounded async operation and therefore returns its operation id.
1522    async fn delete_queue(&self, queue: &str, force: bool) -> Result<Option<String>, StoreError>;
1523
1524    /// Refresh bounded queue memory samples. Implementations must cap work to `limit`;
1525    /// ordinary queue reads only return the last stored sample.
1526    async fn sample_queue_memory(&self, limit: u32) -> Result<u32, StoreError>;
1527}
1528
1529#[async_trait::async_trait]
1530pub trait ResultInspect: Send + Sync + 'static {
1531    /// Explicit result access. Implementations return `None` for a missing job or a job
1532    /// with no completed result; payload/list reads never include these bytes implicitly.
1533    async fn get_job_result(&self, id: &str) -> Result<Option<JobResult>, StoreError>;
1534}
1535
1536#[async_trait::async_trait]
1537pub trait OutputInspect: Send + Sync + 'static {
1538    /// Explicit output access. A previous attempt's latest output may remain visible
1539    /// until the current holder replaces it; `JobOutput::fence` identifies its author.
1540    async fn get_job_output(&self, id: &str) -> Result<Option<JobOutput>, StoreError>;
1541}
1542
1543#[async_trait::async_trait]
1544pub trait ProgressInspect: Send + Sync + 'static {
1545    /// A previous attempt's last report may remain until the current holder replaces it;
1546    /// `JobProgress::fence` makes that provenance explicit.
1547    async fn get_job_progress(&self, id: &str) -> Result<Option<JobProgress>, StoreError>;
1548}
1549
1550// ---------- step replay step replay ----------
1551
1552/// Progress within a single job. Persisted with the lease renewal that is already
1553/// happening, so a mid-step crash does not lose it — River's default writes this only
1554/// after the worker returns, which is the one case it is needed.
1555#[derive(Clone, Debug, Default, PartialEq)]
1556pub struct Checkpoint {
1557    pub last_completed_step: Option<String>,
1558    /// The completed steps IN ORDER. Replay compares positionally: the step at index i
1559    /// of the new attempt must match `completed_steps[i]`, or the step set changed under
1560    /// the checkpoint and the job goes to `undecodable` — never a silent restart.
1561    pub completed_steps: Vec<String>,
1562    /// crash quarantine the step that was running when the checkpoint was last written. Written
1563    /// BEFORE the step's side effects; the reclaimer attributes a crash to it.
1564    pub in_progress_step: Option<String>,
1565    pub cursor_step: Option<String>,
1566    pub cursor: Option<Vec<u8>>,
1567    /// payload versioning × step replay — the step set this checkpoint was written against.
1568    pub schema_version: u32,
1569    pub step_set_hash: String,
1570    /// crash quarantine crash counts per step. "Always dies at `transcode`" beats "dies".
1571    pub crashes_by_step: Vec<(String, u32)>,
1572}
1573
1574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1575pub enum Resume {
1576    /// Step set unchanged — skip completed steps and continue.
1577    Continue,
1578    /// Step set changed but the version maps — resume at the mapped step.
1579    Remapped,
1580    /// No mapping. Terminal. Silently restarting would re-run completed side effects
1581    /// with no signal that a deploy caused it.
1582    Undecodable,
1583}
1584
1585impl Checkpoint {
1586    /// Decide how (or whether) a job may resume. The conservative branch is the default:
1587    /// an unrecognized step set never silently restarts from step one.
1588    pub fn resumability(&self, current_version: u32, current_step_set_hash: &str) -> Resume {
1589        if self.step_set_hash.is_empty() {
1590            return Resume::Continue; // no steps were used
1591        }
1592        if self.step_set_hash == current_step_set_hash {
1593            Resume::Continue
1594        } else if self.schema_version != current_version {
1595            Resume::Remapped // an upcast exists; the task's step mapping decides where
1596        } else {
1597            Resume::Undecodable
1598        }
1599    }
1600}
1601
1602// ---------- other ports (payload codecs) ----------
1603
1604pub trait Telemetry: Send + Sync + 'static {
1605    fn on_event(&self, ev: Event<'_>);
1606}
1607
1608/// Events emitted through the telemetry facade.
1609///
1610/// `#[non_exhaustive]` lets the facade grow without breaking exhaustive downstream
1611/// matches. Job spans and worker-saturation gauges were both additive signals, and
1612/// without this attribute every such addition is a breaking change for anyone who wrote an exhaustive `match`
1613/// in their bridge. That is the wrong incentive: it makes "do not emit the signal" the
1614/// cheap option. Adding a variant is now additive; changing an existing variant's
1615/// fields still is not, which is why the two additions below are new variants rather
1616/// than new fields on `Completed`.
1617#[non_exhaustive]
1618pub enum Event<'a> {
1619    Admitted {
1620        queue: &'a str,
1621        count: usize,
1622    },
1623    Rejected {
1624        queue: &'a str,
1625        policy: &'a str,
1626        count: usize,
1627    },
1628    Completed {
1629        kind: &'a str,
1630        ms: u64,
1631    },
1632    Quarantined {
1633        fingerprint: &'a str,
1634        crashes: u32,
1635    },
1636    /// Eviction is always observable through both this event and a counter.
1637    Evicted {
1638        queue: &'a str,
1639        count: u64,
1640    },
1641    /// Emitted exactly once per attempt after the handler returns, carrying everything
1642    /// an OTel-bridged deployment needs to build
1643    /// one span: identity, outcome, and — the point of the addition — the `traceparent`
1644    /// the PRODUCER put on the envelope, already parsed.
1645    ///
1646    /// It fires at the END and carries `started_at_ms` + `ms` rather than firing at the
1647    /// start, because a facade has no span object to hand back: a start-only callback
1648    /// would force every bridge to keep its own job-id→span map and to leak one whenever
1649    /// a worker is killed mid-attempt. An OTel span builder takes explicit start and end
1650    /// timestamps, so one event is enough and nothing has to be remembered.
1651    ///
1652    /// `trace` is `None` when the envelope carried no `traceparent` OR carried an
1653    /// invalid one — see [`parse_traceparent`]. A bridge then starts a root span.
1654    JobSpan {
1655        job_id: &'a str,
1656        kind: &'a str,
1657        queue: &'a str,
1658        attempt: u32,
1659        /// `success` | `retry` | `skip` | `revoke` | `snooze` | `undecodable`
1660        /// | `rate_limited` — the `Outcome` the runtime acked (or would have).
1661        outcome: &'a str,
1662        started_at_ms: i64,
1663        ms: u64,
1664        trace: Option<&'a TraceContext>,
1665    },
1666    /// Worker-saturation gauges emitted by the runner on
1667    /// every heartbeat, alongside the registry upsert that already happens — so the
1668    /// same numbers reach a metrics exporter and `GET /cluster` from one place and
1669    /// cannot disagree. This is a SIGNAL, not an autoscaler: headgate never sizes a
1670    /// fleet, it only publishes the two numbers that decide the direction.
1671    ///
1672    /// * `utilization` = `inflight / capacity` — scale UP when it is high AND the
1673    ///   backlog's time-to-drain is growing (backlog metrics).
1674    /// * `empty_poll_ratio` = admits that returned zero / total admits, over the
1675    ///   runner's rolling window — scale DOWN when it is high: the fleet is asking
1676    ///   for work that is not there.
1677    ///
1678    /// Its own variant rather than fields on `Admitted` for the reason in the type's
1679    /// doc: `Admitted` is per-admission and these are per-worker levels.
1680    WorkerSaturation {
1681        worker: &'a str,
1682        inflight: u32,
1683        capacity: u32,
1684        utilization: f64,
1685        empty_poll_ratio: f64,
1686        /// Window totals behind the ratio, so an exporter can publish counters too.
1687        polls: u64,
1688        empty_polls: u64,
1689    },
1690    /// Process-memory sample emitted by the worker guard. `restart_requested` is true
1691    /// only for the threshold-crossing sample that starts graceful shutdown.
1692    WorkerMemory {
1693        worker: &'a str,
1694        used_bytes: u64,
1695        limit_bytes: u64,
1696        restart_requested: bool,
1697    },
1698}
1699
1700pub struct NoopTelemetry;
1701impl Telemetry for NoopTelemetry {
1702    fn on_event(&self, _: Event<'_>) {}
1703}
1704
1705pub trait Clock: Send + Sync + 'static {
1706    fn now_ms(&self) -> i64;
1707}
1708
1709/// failure classification Does this error consume an attempt? asynq's `Config.IsFailure` generalizes what
1710/// surveyed policy behavior adopted as the one-off `Outcome::RateLimited`: returning false re-queues the job
1711/// WITHOUT incrementing `attempt` and without polluting queue failure statistics.
1712/// Upstream rate limits, planned maintenance windows, and "not my turn yet" all belong
1713/// here rather than burning a retry budget that exists for real failures.
1714pub trait IsFailure: Send + Sync + 'static {
1715    fn is_failure(&self, err: &(dyn std::error::Error + 'static)) -> bool;
1716}
1717
1718/// The default: every error is a real failure.
1719pub struct AllErrorsAreFailures;
1720impl IsFailure for AllErrorsAreFailures {
1721    fn is_failure(&self, _: &(dyn std::error::Error + 'static)) -> bool {
1722        true
1723    }
1724}
1725pub trait IdGen: Send + Sync + 'static {
1726    fn new_id(&self) -> String;
1727}
1728
1729/// typed dispatch Every kind and alias must be globally unique across the registry, or dispatch is
1730/// ambiguous. Checked once at startup rather than discovered one job at a time. Every
1731/// name — TYPE and alias alike — must also pass [`validate_kind`]: an alias is a dispatch
1732/// key that jobs are enqueued under during a rename, so a rule that skipped aliases would
1733/// let the rename introduce exactly the kind the rule exists to forbid.
1734pub fn check_kind_collisions(kinds: &[(&str, &[&str])]) -> Result<(), String> {
1735    let mut seen = std::collections::HashSet::new();
1736    for (ty, aliases) in kinds {
1737        for k in std::iter::once(ty).chain(aliases.iter()) {
1738            validate_kind(k)?;
1739            if !seen.insert(*k) {
1740                return Err(format!("kind `{k}` is registered more than once"));
1741            }
1742        }
1743    }
1744    Ok(())
1745}
1746
1747/// The one kind-format rule (typed dispatch), enforced identically at handler registration, at
1748/// enqueue in every backend, and at the HTTP API.
1749///
1750/// `[A-Za-z0-9_]` first, then word characters or one of `- [ ] < > / . : +`, 1..=128
1751/// bytes. That is River's charset (`\A[\w][\w\-\[\]<>/.·:+]+\z`) with three deliberate
1752/// differences, each with a reason:
1753///
1754/// * **ASCII-only word characters.** Go's `\w` is ASCII and Rust's `regex` `\w` is
1755///   Unicode-aware; a rule written as `\w` would mean two different things in the two
1756///   languages, which is precisely the drift the conformance suite exists to catch.
1757/// * **Minimum length ONE, where River requires two.** River's trailing `+` forbids a
1758///   single-character kind. headgate's own conformance corpus enqueues kind `w`, and a
1759///   one-letter kind is not a hazard — it is a short name.
1760/// * **No `·` (U+00B7).** It follows from ASCII-only; nothing in the corpus uses it.
1761///
1762/// Whitespace and control characters are rejected by construction: neither is in the
1763/// permitted set. The message is raw (no `Display` prefix) because the API serves it
1764/// verbatim in a 400 body and both servers must emit the same bytes.
1765pub fn validate_kind(kind: &str) -> Result<(), String> {
1766    const RULE: &str =
1767        "1-128 characters, first [A-Za-z0-9_], rest [A-Za-z0-9_] or one of -[]<>/.:+";
1768    const EXTRA: &str = "-[]<>/.:+";
1769    fn word(c: char) -> bool {
1770        c.is_ascii_alphanumeric() || c == '_'
1771    }
1772    let ok = !kind.is_empty()
1773        && kind.len() <= 128
1774        && kind.starts_with(word)
1775        && kind.chars().skip(1).all(|c| word(c) || EXTRA.contains(c));
1776    if ok {
1777        Ok(())
1778    } else {
1779        Err(format!("invalid kind `{kind}`: {RULE}"))
1780    }
1781}
1782
1783/// The queue an envelope actually lands in. Every backend defaults an empty queue to
1784/// `default` on write, so the idempotent enqueue identity id comparison must normalize the same way or a
1785/// replay that omitted the queue would read as a conflict against its own row.
1786pub fn enqueue_queue(e: &Envelope) -> &str {
1787    if e.queue.is_empty() {
1788        "default"
1789    } else {
1790        &e.queue
1791    }
1792}
1793
1794/// idempotent enqueue identity does the row that already owns this id hold the SAME job?
1795///
1796/// The comparison set is (kind, content fingerprinting fingerprint, queue). The fingerprint is content
1797/// identity over kind+payload by construction — it is length-prefixed SHA-256, derived
1798/// client-side, and passed through untouched by every store — so comparing it compares
1799/// the payload without shipping the payload back. Kind is compared as well as hashed so
1800/// that two envelopes which both omit the fingerprint still cannot pass as each other.
1801/// The queue is in the set because routing is part of what a replay must not silently
1802/// change. Equal → idempotent success; different → [`StoreError::IdConflict`].
1803pub fn same_job_content(e: &Envelope, kind: &str, fingerprint: &str, queue: &str) -> bool {
1804    e.kind == kind && e.fingerprint == fingerprint && enqueue_queue(e) == queue
1805}
1806
1807/// The boundary validation every backend's `enqueue` runs before it writes anything —
1808/// ONE function so the rule cannot drift between four adapters, and the layer is the
1809/// store because the API and the harnesses call `Store::enqueue` directly, never through
1810/// the runtime. Batch-level: a repeated id WITHIN one batch is an `IdConflict` on every
1811/// backend rather than a constraint error from whichever row the database reached first.
1812pub fn validate_enqueue(batch: &[Envelope]) -> Result<(), StoreError> {
1813    let mut seen = std::collections::HashSet::with_capacity(batch.len());
1814    for e in batch {
1815        if e.id.is_empty() {
1816            return Err(StoreError::Invalid("envelope id must not be empty".into()));
1817        }
1818        validate_kind(&e.kind).map_err(StoreError::Invalid)?;
1819        if e.unique_window_ms < 0 {
1820            return Err(StoreError::Invalid("unique_window_ms must be >= 0".into()));
1821        }
1822        if e.unique_debounce_ms < 0 {
1823            return Err(StoreError::Invalid(
1824                "unique_debounce_ms must be >= 0".into(),
1825            ));
1826        }
1827        if e.unique_debounce_ms > 0
1828            && (e.unique_key.as_ref().map_or(true, Vec::is_empty) || e.unique_window_ms > 0)
1829        {
1830            return Err(StoreError::Invalid(
1831                "unique_debounce_ms requires lifecycle unique_key".into(),
1832            ));
1833        }
1834        if e.unique_replace & !UNIQUE_REPLACE_ALL != 0 {
1835            return Err(StoreError::Invalid(
1836                "unique_replace contains unknown fields".into(),
1837            ));
1838        }
1839        if e.unique_replace != 0 && e.unique_key.as_ref().map_or(true, Vec::is_empty) {
1840            return Err(StoreError::Invalid(
1841                "unique_replace requires unique_key".into(),
1842            ));
1843        }
1844        if e.tags.len() > 32 {
1845            return Err(StoreError::Invalid(
1846                "tags must contain at most 32 values".into(),
1847            ));
1848        }
1849        let mut tags = std::collections::HashSet::with_capacity(e.tags.len());
1850        for tag in &e.tags {
1851            if tag.is_empty() || tag.len() > 64 || !tag.is_ascii() {
1852                return Err(StoreError::Invalid(
1853                    "each tag must be 1-64 ASCII bytes".into(),
1854                ));
1855            }
1856            if !tags.insert(tag) {
1857                return Err(StoreError::Invalid(
1858                    "tags must not contain duplicates".into(),
1859                ));
1860            }
1861        }
1862        if e.pending && e.scheduled_at_ms != 0 {
1863            return Err(StoreError::Invalid(
1864                "pending jobs cannot also set scheduled_at_ms".into(),
1865            ));
1866        }
1867        if !e.sticky_worker.is_empty()
1868            && (e.sticky_worker.len() > 255 || !e.sticky_worker.is_ascii())
1869        {
1870            return Err(StoreError::Invalid(
1871                "sticky_worker must be at most 255 ASCII bytes".into(),
1872            ));
1873        }
1874        if e.periodic_schedule_id.is_empty() != (e.periodic_tick_ms == 0) || e.periodic_tick_ms < 0
1875        {
1876            return Err(StoreError::Invalid(
1877                "periodic_schedule_id and positive periodic_tick_ms must be set together".into(),
1878            ));
1879        }
1880        if !seen.insert(e.id.as_str()) {
1881            return Err(StoreError::IdConflict {
1882                job_id: e.id.clone(),
1883            });
1884        }
1885    }
1886    if batch.len() != 1
1887        && batch
1888            .iter()
1889            .any(|e| e.unique_replace != 0 || e.unique_debounce_ms > 0)
1890    {
1891        return Err(StoreError::Invalid(
1892            "unique replacement and debounce require a single-job enqueue".into(),
1893        ));
1894    }
1895    Ok(())
1896}
1897
1898#[cfg(test)]
1899mod tests {
1900    use super::*;
1901    fn ctx(a: u32, ma: u32, c: u32, cl: u32) -> TransitionCtx {
1902        TransitionCtx {
1903            attempt: a,
1904            max_attempts: ma,
1905            crash_attempt: c,
1906            crash_limit: cl,
1907            retention_ms: 86_400_000,
1908        }
1909    }
1910
1911    #[test]
1912    fn abort_is_honored_not_retried() {
1913        // The exact bug apalis shipped: an explicit abort recorded as a normal failure.
1914        assert_eq!(
1915            transition(State::Running, Outcome::Skip, &ctx(0, 25, 0, 3)),
1916            State::Archived
1917        );
1918    }
1919
1920    #[test]
1921    fn fingerprint_matches_the_spec_vectors() {
1922        // content fingerprinting — these six vectors ARE the conformance scenario. Both languages must
1923        // reproduce them byte-for-byte; drift here silently splits quarantine across
1924        // languages. The ("",""), row pins the layout: SHA-256 of eight zero bytes.
1925        for (kind, payload, want) in [
1926            (
1927                "email:welcome",
1928                b"".as_slice(),
1929                "bed0eecb39af02d79d5cdc8026a9b817",
1930            ),
1931            ("", b"".as_slice(), "af5570f5a1810b7af78caf4bc70a660f"),
1932            ("a", b"bc".as_slice(), "47ea6f805c5b663e33012cd34184e139"),
1933            ("ab", b"c".as_slice(), "60014a36d7b05b0730e42a8b96faa1ff"),
1934            (
1935                "charge",
1936                [0u8, 1, 2].as_slice(),
1937                "295e280cea51e7f3978bc3195d8fd4ae",
1938            ),
1939            (
1940                "résumé:parse",
1941                b"{}".as_slice(),
1942                "a9b8c5d03aa1a0710129091fa3dc0a1d",
1943            ),
1944        ] {
1945            assert_eq!(
1946                fingerprint(kind, payload),
1947                want,
1948                "vector ({kind:?}, {payload:?})"
1949            );
1950        }
1951        // The property the length prefix exists for:
1952        assert_ne!(fingerprint("a", b"bc"), fingerprint("ab", b"c"));
1953    }
1954
1955    #[test]
1956    fn success_respects_retention() {
1957        // retention policy retention_ms = 0 means DELETE, not keep forever.
1958        assert_eq!(
1959            transition(State::Running, Outcome::Success, &ctx(0, 25, 0, 3)),
1960            State::Completed
1961        );
1962        let ephemeral = TransitionCtx {
1963            retention_ms: 0,
1964            ..ctx(0, 25, 0, 3)
1965        };
1966        assert_eq!(
1967            transition(State::Running, Outcome::Success, &ephemeral),
1968            State::Deleted
1969        );
1970    }
1971
1972    #[test]
1973    fn revoke_drops_entirely() {
1974        assert_eq!(
1975            transition(State::Running, Outcome::Revoke, &ctx(0, 25, 0, 3)),
1976            State::Deleted
1977        );
1978    }
1979
1980    #[test]
1981    fn crash_is_not_a_retry() {
1982        // crash quarantine three crashes quarantine; retries do not.
1983        assert_eq!(
1984            transition(State::Running, Outcome::LeaseLost, &ctx(0, 25, 0, 3)),
1985            State::Retryable
1986        );
1987        assert_eq!(
1988            transition(State::Running, Outcome::LeaseLost, &ctx(0, 25, 2, 3)),
1989            State::Quarantined
1990        );
1991        assert_eq!(
1992            transition(State::Running, Outcome::Retry, &ctx(0, 25, 2, 3)),
1993            State::Retryable
1994        );
1995    }
1996
1997    #[test]
1998    fn undecodable_never_retries() {
1999        assert_eq!(
2000            transition(State::Running, Outcome::Undecodable, &ctx(0, 25, 0, 3)),
2001            State::Undecodable
2002        );
2003    }
2004
2005    #[test]
2006    fn snooze_does_not_consume_an_attempt() {
2007        assert_eq!(
2008            transition(State::Running, Outcome::Snooze, &ctx(0, 25, 0, 3)),
2009            State::Scheduled
2010        );
2011    }
2012
2013    #[test]
2014    fn rate_limited_is_not_a_failure() {
2015        // surveyed policy behavior back to available, and the caller must not increment `attempt`.
2016        assert_eq!(
2017            transition(State::Running, Outcome::RateLimited, &ctx(3, 25, 0, 3)),
2018            State::Available
2019        );
2020    }
2021
2022    #[test]
2023    fn changed_step_set_never_silently_restarts() {
2024        // step replay the dangerous default is restarting from step one after a deploy and
2025        // re-running completed side effects with no signal that a deploy caused it.
2026        let cp = Checkpoint {
2027            last_completed_step: Some("transcode".into()),
2028            schema_version: 1,
2029            step_set_hash: "abc".into(),
2030            ..Default::default()
2031        };
2032        assert_eq!(cp.resumability(1, "abc"), Resume::Continue);
2033        assert_eq!(cp.resumability(2, "xyz"), Resume::Remapped);
2034        assert_eq!(cp.resumability(1, "xyz"), Resume::Undecodable);
2035    }
2036
2037    #[test]
2038    fn no_steps_means_always_resumable() {
2039        assert_eq!(
2040            Checkpoint::default().resumability(1, "anything"),
2041            Resume::Continue
2042        );
2043    }
2044
2045    #[test]
2046    fn aliases_let_a_task_be_renamed() {
2047        struct Renamed;
2048        impl Task for Renamed {
2049            const TYPE: &'static str = "notify:welcome";
2050            const ALIASES: &'static [&'static str] = &["email:welcome"];
2051            fn encode(&self) -> Result<Vec<u8>, CodecError> {
2052                Ok(vec![])
2053            }
2054            fn decode(_: &[u8]) -> Result<Self, CodecError> {
2055                Ok(Renamed)
2056            }
2057        }
2058        // enqueue uses TYPE; dispatch must accept the old kind still sitting in the store
2059        assert_eq!(Renamed::TYPE, "notify:welcome");
2060        assert!(Renamed::ALIASES.contains(&"email:welcome"));
2061    }
2062
2063    #[test]
2064    fn colliding_kinds_are_rejected_at_startup() {
2065        assert!(check_kind_collisions(&[("a", &[]), ("b", &[])]).is_ok());
2066        // an alias that collides with another task's TYPE is ambiguous dispatch
2067        assert!(check_kind_collisions(&[("a", &[]), ("b", &["a"])]).is_err());
2068        // typed dispatch the format rule covers ALIASES too — a rename must not smuggle in a kind
2069        // that a fresh registration would have been refused.
2070        assert!(check_kind_collisions(&[("a", &["bad kind"])]).is_err());
2071    }
2072
2073    #[test]
2074    fn kind_format_rule_is_exactly_one_rule() {
2075        // Accepted. Length ONE is deliberate: River requires two, the corpus uses "w".
2076        for k in [
2077            "w",
2078            "k",
2079            "_",
2080            "0",
2081            "email:welcome",
2082            "notify:welcome",
2083            "a-b",
2084            "a.b",
2085            "a/b",
2086            "a+b",
2087            "a<b>",
2088            "a[b]",
2089            "Job_1",
2090            &"x".repeat(128),
2091        ] {
2092            assert_eq!(validate_kind(k), Ok(()), "should accept {k:?}");
2093        }
2094        // Rejected: empty, too long, bad first char, bad char, whitespace, control.
2095        for k in [
2096            "",
2097            &"x".repeat(129),
2098            "-lead",
2099            ".lead",
2100            ":lead",
2101            "+lead",
2102            "[lead",
2103            "a b",
2104            " a",
2105            "a\t",
2106            "a\n",
2107            "a\u{0}",
2108            "a!",
2109            "a#b",
2110            "a,b",
2111            "a(b)",
2112            "a*",
2113            "résumé:parse",
2114            "a·b",
2115            "a%b",
2116            "a\"b",
2117        ] {
2118            assert!(validate_kind(k).is_err(), "should reject {k:?}");
2119        }
2120        // The message is raw and names the rule — both servers serve it byte-identically.
2121        assert_eq!(
2122            validate_kind("a b").unwrap_err(),
2123            "invalid kind `a b`: 1-128 characters, first [A-Za-z0-9_], \
2124             rest [A-Za-z0-9_] or one of -[]<>/.:+"
2125        );
2126    }
2127
2128    #[test]
2129    fn enqueue_validation_is_one_function_for_every_backend() {
2130        let ok = Envelope {
2131            id: "a".into(),
2132            kind: "w".into(),
2133            ..Default::default()
2134        };
2135        assert!(validate_enqueue(&[ok.clone()]).is_ok());
2136        assert!(
2137            validate_enqueue(&[Envelope {
2138                sticky_worker: "w".repeat(255),
2139                ..ok.clone()
2140            }])
2141            .is_ok()
2142        );
2143        for sticky_worker in ["é".to_string(), "w".repeat(256)] {
2144            assert!(matches!(
2145                validate_enqueue(&[Envelope {
2146                    sticky_worker,
2147                    ..ok.clone()
2148                }]),
2149                Err(StoreError::Invalid(_))
2150            ));
2151        }
2152        let no_id = Envelope {
2153            id: String::new(),
2154            ..ok.clone()
2155        };
2156        assert!(matches!(
2157            validate_enqueue(&[no_id]),
2158            Err(StoreError::Invalid(_))
2159        ));
2160        let bad_kind = Envelope {
2161            kind: "bad kind".into(),
2162            ..ok.clone()
2163        };
2164        assert!(matches!(
2165            validate_enqueue(&[bad_kind]),
2166            Err(StoreError::Invalid(_))
2167        ));
2168        let neg = Envelope {
2169            unique_window_ms: -1,
2170            ..ok.clone()
2171        };
2172        assert!(matches!(
2173            validate_enqueue(&[neg]),
2174            Err(StoreError::Invalid(_))
2175        ));
2176        // idempotent enqueue identity a repeated id inside ONE batch is a conflict, not a constraint error.
2177        match validate_enqueue(&[ok.clone(), ok.clone()]) {
2178            Err(StoreError::IdConflict { job_id }) => assert_eq!(job_id, "a"),
2179            other => panic!("want IdConflict, got {other:?}"),
2180        }
2181
2182        let replace_without_key = Envelope {
2183            unique_replace: UNIQUE_REPLACE_PRIORITY,
2184            ..ok.clone()
2185        };
2186        assert!(matches!(
2187            validate_enqueue(&[replace_without_key]),
2188            Err(StoreError::Invalid(_))
2189        ));
2190        let replace_unknown = Envelope {
2191            unique_key: Some(b"k".to_vec()),
2192            unique_replace: UNIQUE_REPLACE_ALL | (1 << 8),
2193            ..ok.clone()
2194        };
2195        assert!(matches!(
2196            validate_enqueue(&[replace_unknown]),
2197            Err(StoreError::Invalid(_))
2198        ));
2199        let replace = Envelope {
2200            unique_key: Some(b"k".to_vec()),
2201            unique_replace: UNIQUE_REPLACE_PRIORITY,
2202            ..ok.clone()
2203        };
2204        assert!(validate_enqueue(&[replace.clone()]).is_ok());
2205        let second = Envelope {
2206            id: "b".into(),
2207            ..ok
2208        };
2209        assert!(matches!(
2210            validate_enqueue(&[replace, second]),
2211            Err(StoreError::Invalid(_))
2212        ));
2213    }
2214
2215    #[test]
2216    fn omitted_envelope_weight_normalizes_to_one_without_erasing_real_costs() {
2217        // Protobuf and the public core use zero as the backwards-compatible omitted
2218        // sentinel. HTTP can reject an explicit zero because JSON preserves presence;
2219        // the store boundary cannot distinguish it and therefore normalizes it.
2220        assert_eq!(effective_weight(0), 1);
2221        assert_eq!(effective_weight(1), 1);
2222        assert_eq!(effective_weight(7), 7);
2223    }
2224
2225    #[test]
2226    fn id_conflict_compares_kind_fingerprint_and_queue() {
2227        // idempotent enqueue identity the exact comparison set the API replay path depends on.
2228        let e = Envelope {
2229            id: "a".into(),
2230            kind: "w".into(),
2231            fingerprint: fingerprint("w", b"{}"),
2232            payload: b"{}".to_vec(),
2233            ..Default::default()
2234        };
2235        // An empty queue IS `default` — a replay that omits it must not read as conflict.
2236        assert_eq!(enqueue_queue(&e), "default");
2237        assert!(same_job_content(
2238            &e,
2239            "w",
2240            &fingerprint("w", b"{}"),
2241            "default"
2242        ));
2243        assert!(!same_job_content(
2244            &e,
2245            "w",
2246            &fingerprint("w", b"{\"a\":1}"),
2247            "default"
2248        ));
2249        assert!(!same_job_content(
2250            &e,
2251            "v",
2252            &fingerprint("w", b"{}"),
2253            "default"
2254        ));
2255        assert!(!same_job_content(
2256            &e,
2257            "w",
2258            &fingerprint("w", b"{}"),
2259            "other"
2260        ));
2261    }
2262
2263    #[test]
2264    fn id_conflict_message_is_the_uniform_one() {
2265        assert_eq!(
2266            StoreError::IdConflict {
2267                job_id: "c1".into()
2268            }
2269            .to_string(),
2270            "id conflict: job c1"
2271        );
2272    }
2273
2274    // ---------- telemetry and trace context trace context on the envelope ----------
2275
2276    /// The vectors ARE the spec. Both languages run this exact table
2277    /// (go/tracecontext_test.go) — a divergence here is one runtime silently honouring
2278    /// a parent the other drops, which is the failure the 🔶 row named.
2279    #[test]
2280    fn traceparent_parses_exactly_the_w3c_shape() {
2281        let tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
2282        let tc = parse_traceparent(tp).expect("the canonical W3C example must parse");
2283        assert_eq!(tc.trace_id, "4bf92f3577b34da6a3ce929d0e0e4736");
2284        assert_eq!(tc.span_id, "00f067aa0ba902b7");
2285        assert_eq!(tc.trace_flags, 1);
2286        assert!(tc.sampled());
2287        // Round-trips byte for byte, so re-injection emits what the producer sent.
2288        assert_eq!(tc.to_traceparent(), tp);
2289        // flags 00 is valid and simply means "not sampled" — not an error.
2290        let un = parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")
2291            .expect("unsampled is still a valid parent");
2292        assert!(!un.sampled());
2293        assert_eq!(un.trace_flags, 0);
2294    }
2295
2296    #[test]
2297    fn an_invalid_traceparent_is_absent_never_an_error() {
2298        // Every one of these is treated as ABSENT. None of them is an enqueue error and
2299        // none is a dispatch failure — the headers stay opaque bytes to the store.
2300        for bad in [
2301            "",                                                              // empty
2302            "garbage",                                                       // not the shape
2303            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7",          // 3 fields
2304            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra", // 5 fields
2305            "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",       // version != 00
2306            "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",       // uppercase
2307            "00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01",        // 31-char trace
2308            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b-01",        // 15-char span
2309            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1",        // 1-char flags
2310            "00-00000000000000000000000000000000-00f067aa0ba902b7-01",       // zero trace-id
2311            "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01",       // zero span-id
2312            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-zz",       // non-hex flags
2313            " 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",      // leading space
2314        ] {
2315            assert_eq!(parse_traceparent(bad), None, "must read as ABSENT: {bad:?}");
2316        }
2317    }
2318
2319    #[test]
2320    fn trace_context_reads_the_two_reserved_headers() {
2321        let mut h = std::collections::BTreeMap::new();
2322        assert_eq!(trace_context(&h), None); // no headers at all
2323        h.insert(
2324            TRACEPARENT.into(),
2325            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
2326        );
2327        h.insert(TRACESTATE.into(), "vendor=opaque,other=1".to_string());
2328        let tc = trace_context(&h).expect("valid parent");
2329        // tracestate is carried VERBATIM — never parsed, never truncated.
2330        assert_eq!(tc.trace_state, "vendor=opaque,other=1");
2331        // An invalid parent takes the tracestate down with it: a vendor blob with no
2332        // trace to belong to is not a trace context.
2333        h.insert(TRACEPARENT.into(), "nonsense".to_string());
2334        assert_eq!(trace_context(&h), None);
2335        // Reserved keys are exact, lowercase strings. A different spelling is just an
2336        // ordinary opaque header, not a near-miss the runtime tries to rescue.
2337        h.remove(TRACEPARENT);
2338        h.insert(
2339            "Traceparent".into(),
2340            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
2341        );
2342        assert_eq!(trace_context(&h), None);
2343    }
2344
2345    #[test]
2346    fn worker_saturation_never_divides_by_zero() {
2347        // backlog metrics a worker with no capacity is 0% utilized, not 100%; a worker that has
2348        // not polled yet has no empty-poll evidence, so its ratio is 0, not 1.
2349        let idle = WorkerMeta {
2350            concurrency: 0,
2351            inflight: 0,
2352            polls: 0,
2353            ..Default::default()
2354        };
2355        assert_eq!(idle.utilization(), 0.0);
2356        assert_eq!(idle.empty_poll_ratio(), 0.0);
2357        let busy = WorkerMeta {
2358            concurrency: 8,
2359            inflight: 6,
2360            polls: 10,
2361            empty_polls: 4,
2362            ..Default::default()
2363        };
2364        assert_eq!(busy.utilization(), 0.75);
2365        assert_eq!(busy.empty_poll_ratio(), 0.4);
2366    }
2367
2368    #[test]
2369    fn quiet_group_noise_detection_is_skew_based_and_work_conserving() {
2370        let loads = |xs: &[(&str, i64)]| {
2371            xs.iter()
2372                .map(|(k, n)| ((*k).to_string(), *n))
2373                .collect::<Vec<_>>()
2374        };
2375        assert!(
2376            noisy_partition_keys(&loads(&[("only", 500)])).is_empty(),
2377            "a lone partition has nobody to disturb and must stay visible"
2378        );
2379        assert!(
2380            noisy_partition_keys(&loads(&[("a", 1), ("b", 0)])).is_empty(),
2381            "one claim is not enough evidence to call a tenant noisy"
2382        );
2383        assert!(
2384            noisy_partition_keys(&loads(&[("a", 4), ("b", 2)])).is_empty(),
2385            "exactly twice the peer mean is the boundary, not over it"
2386        );
2387        let got = noisy_partition_keys(&loads(&[("flood", 9), ("quiet-a", 1), ("quiet-b", 2)]));
2388        assert_eq!(got.into_iter().collect::<Vec<_>>(), vec!["flood"]);
2389        assert!(
2390            noisy_partition_keys(&loads(&[("a", 3), ("b", 3), ("c", 3)])).is_empty(),
2391            "balanced busy tenants are not noisy neighbours"
2392        );
2393        let got = noisy_partition_keys(&loads(&[("negative", -7), ("flood", 2)]));
2394        assert!(
2395            got.contains("flood") && !got.contains("negative"),
2396            "a corrupt negative counter is treated as zero, never inverted"
2397        );
2398    }
2399
2400    #[test]
2401    fn saturation_strategy_spellings_are_one_cross_backend_contract() {
2402        for (raw, want) in [
2403            ("queue", SaturationStrategy::Queue),
2404            ("discard", SaturationStrategy::Discard),
2405            ("cancel_running", SaturationStrategy::CancelRunning),
2406            ("cancel_incoming", SaturationStrategy::CancelIncoming),
2407        ] {
2408            let got = SaturationStrategy::try_from(raw).unwrap();
2409            assert_eq!(got, want);
2410            assert_eq!(got.as_str(), raw);
2411        }
2412        assert!(matches!(
2413            SaturationStrategy::try_from("cancel_newest"),
2414            Err(StoreError::Invalid(msg)) if msg == "unknown saturation strategy `cancel_newest`"
2415        ));
2416    }
2417
2418    #[test]
2419    fn terminal_states_are_terminal() {
2420        for s in [
2421            State::Completed,
2422            State::Archived,
2423            State::Cancelled,
2424            State::Quarantined,
2425            State::Undecodable,
2426            State::Deleted,
2427        ] {
2428            assert!(s.is_terminal());
2429            assert_eq!(transition(s, Outcome::Retry, &ctx(0, 25, 0, 3)), s);
2430            for ev in [
2431                LifecycleEvent::ScheduleDue,
2432                LifecycleEvent::Admitted,
2433                LifecycleEvent::BackoffDue,
2434                LifecycleEvent::CheckpointStale,
2435            ] {
2436                assert_eq!(
2437                    lifecycle_transition(s, ev),
2438                    None,
2439                    "{s:?} must never auto-transition"
2440                );
2441            }
2442        }
2443    }
2444
2445    // ---------- lifecycle state machine the yaml IS the table; this test is the "generated from" bond ----------
2446
2447    /// Every row of conformance/state_machine.yaml, cross-checked against `transition`
2448    /// and `lifecycle_transition`. A row commented out in the yaml fails here (the row
2449    /// count is pinned); a branch dropped from the Rust match fails here too. This is the
2450    /// property lifecycle state machine exists for — apalis's commented-out abort branch was silent.
2451    #[test]
2452    fn yaml_and_code_agree_row_for_row() {
2453        let yaml = include_str!("../../../conformance/state_machine.yaml");
2454        let mut rows = 0usize;
2455        for line in yaml.lines() {
2456            let line = line.trim();
2457            let Some(body) = line.strip_prefix("- {").and_then(|r| r.split('}').next()) else {
2458                continue;
2459            };
2460            let mut from = "";
2461            let mut on = "";
2462            let mut to = "";
2463            let mut when = "";
2464            for field in split_top_level(body) {
2465                let (k, v) = field.split_once(':').expect("field");
2466                let v = v.trim().trim_matches('"');
2467                match k.trim() {
2468                    "from" => from = v,
2469                    "on" => on = v,
2470                    "to" => to = v,
2471                    "when" => when = v,
2472                    "note" => {}
2473                    other => panic!("unknown key `{other}` in state_machine.yaml"),
2474                }
2475            }
2476            rows += 1;
2477            check_row(from, on, to, when);
2478        }
2479        // Pinned on purpose: adding or removing a transition must be deliberate — the
2480        // yaml's own invariant requires a conformance scenario per new row.
2481        assert_eq!(
2482            rows, 22,
2483            "state_machine.yaml row count changed; update the table AND its scenarios"
2484        );
2485    }
2486
2487    /// Split `a: b, c: "d, e"` on commas that are not inside quotes.
2488    fn split_top_level(s: &str) -> Vec<&str> {
2489        let mut out = Vec::new();
2490        let mut depth_quote = false;
2491        let mut start = 0;
2492        for (i, c) in s.char_indices() {
2493            match c {
2494                '"' => depth_quote = !depth_quote,
2495                ',' if !depth_quote => {
2496                    out.push(&s[start..i]);
2497                    start = i + 1;
2498                }
2499                _ => {}
2500            }
2501        }
2502        out.push(&s[start..]);
2503        out
2504    }
2505
2506    fn state(name: &str) -> State {
2507        match name {
2508            "pending" => State::Pending,
2509            "scheduled" => State::Scheduled,
2510            "available" => State::Available,
2511            "running" => State::Running,
2512            "retryable" => State::Retryable,
2513            "completed" => State::Completed,
2514            "archived" => State::Archived,
2515            "cancelled" => State::Cancelled,
2516            "quarantined" => State::Quarantined,
2517            "undecodable" => State::Undecodable,
2518            "deleted" => State::Deleted,
2519            other => panic!("unknown state `{other}` in state_machine.yaml"),
2520        }
2521    }
2522
2523    /// Build a ctx that satisfies (or minimally violates) the row's `when` guard.
2524    fn ctx_for(when: &str) -> TransitionCtx {
2525        let mut c = TransitionCtx {
2526            attempt: 0,
2527            max_attempts: 25,
2528            crash_attempt: 0,
2529            crash_limit: 3,
2530            retention_ms: 86_400_000,
2531        };
2532        match when {
2533            "" => {}
2534            "retention_ms > 0" => c.retention_ms = 1,
2535            "retention_ms == 0" => c.retention_ms = 0,
2536            "attempt + 1 < max_attempts" => {
2537                c.attempt = 0;
2538                c.max_attempts = 25
2539            }
2540            "attempt + 1 >= max_attempts" => {
2541                c.attempt = 24;
2542                c.max_attempts = 25
2543            }
2544            "crash_attempt + 1 < crash_limit" => {
2545                c.crash_attempt = 0;
2546                c.crash_limit = 3
2547            }
2548            "crash_attempt + 1 >= crash_limit" => {
2549                c.crash_attempt = 2;
2550                c.crash_limit = 3
2551            }
2552            other => {
2553                panic!("unknown guard `{other}` in state_machine.yaml — teach ctx_for about it")
2554            }
2555        }
2556        c
2557    }
2558
2559    fn check_row(from: &str, on: &str, to: &str, when: &str) {
2560        let from = state(from);
2561        let want = state(to);
2562        let outcome = match on {
2563            "success" => Some(Outcome::Success),
2564            "retry" => Some(Outcome::Retry),
2565            "skip" => Some(Outcome::Skip),
2566            "revoke" => Some(Outcome::Revoke),
2567            "snooze" => Some(Outcome::Snooze),
2568            "undecodable" => Some(Outcome::Undecodable),
2569            "rate_limited" => Some(Outcome::RateLimited),
2570            "lease_lost" => Some(Outcome::LeaseLost),
2571            _ => None,
2572        };
2573        if let Some(o) = outcome {
2574            assert_eq!(
2575                transition(from, o, &ctx_for(when)),
2576                want,
2577                "yaml row ({from:?}, {on}, when: `{when}`) disagrees with transition()"
2578            );
2579            return;
2580        }
2581        let ev = match on {
2582            "operator_promote" => LifecycleEvent::OperatorPromote,
2583            "schedule_due" => LifecycleEvent::ScheduleDue,
2584            "admitted" => LifecycleEvent::Admitted,
2585            "backoff_due" => LifecycleEvent::BackoffDue,
2586            "checkpoint_stale" => LifecycleEvent::CheckpointStale,
2587            "operator_retry" => LifecycleEvent::OperatorRetry,
2588            "operator_release" => LifecycleEvent::OperatorRelease,
2589            "operator_cancel" => LifecycleEvent::OperatorCancel,
2590            other => panic!("unknown event `{other}` in state_machine.yaml"),
2591        };
2592        assert_eq!(
2593            lifecycle_transition(from, ev),
2594            Some(want),
2595            "yaml row ({from:?}, {on}) disagrees with lifecycle_transition()"
2596        );
2597    }
2598
2599    #[test]
2600    fn admission_units_group_same_kind_and_respect_bound() {
2601        let claims = [
2602            ("a1", "mail"),
2603            ("b1", "index"),
2604            ("a2", "mail"),
2605            ("a3", "mail"),
2606        ]
2607        .into_iter()
2608        .map(|(id, kind)| Claim {
2609            envelope: Envelope {
2610                id: id.into(),
2611                kind: kind.into(),
2612                ..Envelope::default()
2613            },
2614            lease_id: "lease".into(),
2615            fence: 1,
2616            expires_at_ms: 1,
2617            checkpoint: Checkpoint::default(),
2618        })
2619        .collect();
2620        let units = group_admission_claims(claims, 2);
2621        let ids: Vec<Vec<&str>> = units
2622            .iter()
2623            .map(|unit| {
2624                unit.claims
2625                    .iter()
2626                    .map(|claim| claim.envelope.id.as_str())
2627                    .collect()
2628            })
2629            .collect();
2630        assert_eq!(ids, vec![vec!["a1", "a2"], vec!["b1"], vec!["a3"]]);
2631        assert!(units.iter().all(|unit| unit.size() <= 2));
2632    }
2633}