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