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