#![allow(dead_code)]
use std::time::Duration;
pub use headgate_shared::{Checkpoint, MissedPolicy, Outcome, Resume};
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub trait Task: Sized + Send + Sync + 'static {
const TYPE: &'static str;
const VERSION: u32 = 1;
const ALIASES: &'static [&'static str] = &[];
fn encode(&self) -> Result<Vec<u8>, CodecError>;
fn decode(bytes: &[u8]) -> Result<Self, CodecError>;
fn upcast(version: u32, bytes: &[u8]) -> Result<Self, CodecError> {
if version == Self::VERSION {
Self::decode(bytes)
} else {
Err(CodecError::UnknownVersion(version))
}
}
fn options() -> TaskOptions {
TaskOptions::default()
}
}
pub fn fingerprint(kind: &str, payload: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update((kind.len() as u32).to_le_bytes());
h.update(kind.as_bytes());
h.update((payload.len() as u32).to_le_bytes());
h.update(payload);
let digest = h.finalize();
let mut out = String::with_capacity(32);
for b in &digest[..16] {
out.push_str(&format!("{b:02x}"));
}
out
}
#[derive(Debug)]
pub enum CodecError {
Malformed(String),
UnknownVersion(u32),
}
impl std::fmt::Display for CodecError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
CodecError::Malformed(m) => write!(f, "malformed payload: {m}"),
CodecError::UnknownVersion(v) => write!(f, "no upcast path for schema version {v}"),
}
}
}
impl std::error::Error for CodecError {}
#[derive(Default, Clone)]
pub struct TaskOptions {
pub queue: Option<String>,
pub max_attempts: Option<u32>,
pub priority: Option<i32>,
pub timeout: Option<Duration>,
pub deadline: Option<Duration>,
pub unique_ttl: Option<Duration>,
pub retention: Option<Duration>,
pub partition_key: Option<String>,
pub rate_class: Option<String>,
pub weight: Option<u32>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JobResult {
pub schema_version: u32,
pub bytes: Vec<u8>,
}
pub const MAX_OPAQUE_SCHEMA_VERSION: u32 = headgate_shared::MAX_OPAQUE_SCHEMA_VERSION;
pub const MAX_OPAQUE_BYTES: usize = 32 * 1024 * 1024;
pub fn validate_opaque_value(subject: &str, value: &JobResult) -> Result<(), StoreError> {
use headgate_shared::OpaqueSchemaValidation;
match headgate_shared::validate_opaque_schema(value.schema_version) {
OpaqueSchemaValidation::Zero => Err(StoreError::Invalid(format!(
"{subject} schema_version must be greater than zero"
))),
OpaqueSchemaValidation::TooLarge => Err(StoreError::Invalid(format!(
"{subject} schema_version exceeds the portable signed-integer limit"
))),
OpaqueSchemaValidation::Valid if value.bytes.len() > MAX_OPAQUE_BYTES => Err(
StoreError::Invalid(format!("{subject} bytes exceed the 32 MiB limit")),
),
OpaqueSchemaValidation::Valid => Ok(()),
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JobOutput {
pub schema_version: u32,
pub bytes: Vec<u8>,
pub fence: u64,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProgressUpdate {
pub current: u64,
pub total: u64,
pub message: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JobProgress {
pub current: u64,
pub total: u64,
pub message: Option<String>,
pub fence: u64,
pub updated_at_ms: i64,
}
pub const MAX_PROGRESS_VALUE: u64 = 9_007_199_254_740_991;
pub const MAX_PROGRESS_MESSAGE_BYTES: usize = 512;
pub fn validate_progress(update: &ProgressUpdate) -> Result<(), StoreError> {
if update.total == 0 {
return Err(StoreError::Invalid(
"progress total must be greater than zero".into(),
));
}
if update.current > update.total {
return Err(StoreError::Invalid(
"progress current must not exceed total".into(),
));
}
if update.total > MAX_PROGRESS_VALUE {
return Err(StoreError::Invalid(
"progress total exceeds the portable JSON safe-integer limit".into(),
));
}
if let Some(message) = &update.message {
if message.len() > MAX_PROGRESS_MESSAGE_BYTES {
return Err(StoreError::Invalid(
"progress message exceeds the 512-byte limit".into(),
));
}
if message.contains('\0') {
return Err(StoreError::Invalid(
"progress message must not contain NUL".into(),
));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Pending,
Scheduled,
Available,
Running,
Retryable,
Completed,
Archived,
Cancelled,
Quarantined,
Undecodable,
Deleted,
}
impl State {
pub const fn is_terminal(self) -> bool {
matches!(
self,
State::Completed
| State::Archived
| State::Cancelled
| State::Quarantined
| State::Undecodable
| State::Deleted
)
}
}
pub fn transition(from: State, on: Outcome, ctx: &TransitionCtx) -> State {
match (from, on) {
(State::Running, Outcome::Success) => {
if ctx.retention_ms > 0 {
State::Completed
} else {
State::Deleted
}
}
(State::Running, Outcome::Skip) => State::Archived,
(State::Running, Outcome::Revoke) => State::Deleted, (State::Running, Outcome::Snooze) => State::Scheduled,
(State::Running, Outcome::Undecodable) => State::Undecodable,
(State::Running, Outcome::RateLimited) => State::Available, (State::Running, Outcome::Retry) => {
if ctx.attempt + 1 < ctx.max_attempts {
State::Retryable
} else {
State::Archived
}
}
(State::Running, Outcome::LeaseLost) => {
if ctx.crash_attempt + 1 < ctx.crash_limit {
State::Retryable
} else {
State::Quarantined
}
}
(s, _) => s,
}
}
pub struct TransitionCtx {
pub attempt: u32,
pub max_attempts: u32,
pub crash_attempt: u32,
pub crash_limit: u32,
pub retention_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleEvent {
OperatorPromote,
ScheduleDue,
Admitted,
BackoffDue,
CheckpointStale,
OperatorRetry,
OperatorRelease,
OperatorCancel,
}
pub fn lifecycle_transition(from: State, ev: LifecycleEvent) -> Option<State> {
match (from, ev) {
(State::Pending, LifecycleEvent::OperatorPromote) => Some(State::Available),
(State::Scheduled, LifecycleEvent::ScheduleDue) => Some(State::Available),
(State::Available, LifecycleEvent::Admitted) => Some(State::Running),
(State::Retryable, LifecycleEvent::BackoffDue) => Some(State::Available),
(State::Running, LifecycleEvent::CheckpointStale) => Some(State::Undecodable),
(State::Archived, LifecycleEvent::OperatorRetry) => Some(State::Available),
(State::Quarantined, LifecycleEvent::OperatorRelease) => Some(State::Available),
(
State::Pending | State::Available | State::Scheduled | State::Running,
LifecycleEvent::OperatorCancel,
) => Some(State::Cancelled),
_ => None,
}
}
pub const UNIQUE_REPLACE_PAYLOAD: u32 = 1 << 0;
pub const UNIQUE_REPLACE_SCHEDULED_AT: u32 = 1 << 1;
pub const UNIQUE_REPLACE_PRIORITY: u32 = 1 << 2;
pub const UNIQUE_REPLACE_MAX_ATTEMPTS: u32 = 1 << 3;
pub const UNIQUE_REPLACE_ALL: u32 = UNIQUE_REPLACE_PAYLOAD
| UNIQUE_REPLACE_SCHEDULED_AT
| UNIQUE_REPLACE_PRIORITY
| UNIQUE_REPLACE_MAX_ATTEMPTS;
#[derive(Clone, Debug, Default)]
pub struct Envelope {
pub id: String,
pub kind: String,
pub schema_version: u32,
pub payload: Vec<u8>,
pub queue: String,
pub partition_key: String,
pub rate_class: String,
pub weight: u32,
pub fingerprint: String,
pub priority: i32,
pub attempt: u32,
pub crash_attempt: u32,
pub max_attempts: u32,
pub scheduled_at_ms: i64,
pub timeout_ms: i64,
pub deadline_ms: i64,
pub unique_key: Option<Vec<u8>>,
pub unique_states: u32,
pub unique_window_ms: i64,
pub unique_replace: u32,
pub unique_debounce_ms: i64,
pub unique_exclude_kind: bool,
pub retention_ms: i64,
pub periodic_schedule_id: String,
pub periodic_tick_ms: i64,
pub headers: std::collections::BTreeMap<String, String>,
pub tags: Vec<String>,
pub pending: bool,
pub sticky_worker: String,
}
pub const fn effective_weight(weight: u32) -> u32 {
headgate_shared::effective_weight(weight)
}
pub const fn effective_schema_version(version: u32) -> u32 {
headgate_shared::effective_schema_version(version)
}
pub const fn effective_max_attempts(max_attempts: u32) -> u32 {
headgate_shared::effective_max_attempts(max_attempts)
}
pub fn effective_unique_key(e: &Envelope) -> Option<Vec<u8>> {
let raw = e.unique_key.as_ref()?;
let mut out = Vec::with_capacity(raw.len() + e.kind.len() + 7);
out.push(1);
if e.unique_exclude_kind {
out.push(b'G');
} else {
out.push(b'K');
out.extend_from_slice(&(e.kind.len() as u32).to_be_bytes());
out.extend_from_slice(e.kind.as_bytes());
}
out.extend_from_slice(raw);
Some(out)
}
pub fn canonical_tags(tags: &[String]) -> Vec<String> {
let mut out = tags.to_vec();
out.sort_unstable();
out.dedup();
out
}
pub const TRACEPARENT: &str = "traceparent";
pub const TRACESTATE: &str = "tracestate";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TraceContext {
pub trace_id: String,
pub span_id: String,
pub trace_flags: u8,
pub trace_state: String,
}
impl TraceContext {
pub const fn sampled(&self) -> bool {
self.trace_flags & 1 != 0
}
pub fn to_traceparent(&self) -> String {
format!(
"00-{}-{}-{:02x}",
self.trace_id, self.span_id, self.trace_flags
)
}
}
fn is_lower_hex(s: &str, len: usize) -> bool {
s.len() == len
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn parse_traceparent(value: &str) -> Option<TraceContext> {
let mut parts = value.split('-');
let (version, trace_id, span_id, flags) =
(parts.next()?, parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None; }
if version != "00"
|| !is_lower_hex(trace_id, 32)
|| !is_lower_hex(span_id, 16)
|| !is_lower_hex(flags, 2)
{
return None;
}
if trace_id.bytes().all(|b| b == b'0') || span_id.bytes().all(|b| b == b'0') {
return None; }
Some(TraceContext {
trace_id: trace_id.to_string(),
span_id: span_id.to_string(),
trace_flags: u8::from_str_radix(flags, 16).ok()?,
trace_state: String::new(),
})
}
pub fn trace_context(headers: &std::collections::BTreeMap<String, String>) -> Option<TraceContext> {
let mut tc = parse_traceparent(headers.get(TRACEPARENT)?)?;
tc.trace_state = headers.get(TRACESTATE).cloned().unwrap_or_default();
Some(tc)
}
pub struct AdmitRequest {
pub worker: String,
pub lease_id: String,
pub queues: Vec<String>,
pub capacity: u32,
pub lease: Duration,
pub quantum: i64,
}
pub fn normalize_admit_request(mut req: AdmitRequest) -> Result<(AdmitRequest, i64), StoreError> {
req.queues = headgate_shared::normalize_queues(req.queues);
let lease_ms = headgate_shared::duration_millis(req.lease)
.ok_or_else(|| StoreError::Invalid("lease must be >= 1ms".into()))?;
Ok((req, lease_ms))
}
pub fn validate_ack_request(outcome: Outcome, delay_ms: Option<i64>) -> Result<(), StoreError> {
match headgate_shared::validate_ack(outcome, delay_ms) {
headgate_shared::AckValidation::LeaseLost => Err(StoreError::Invalid(
"lease_lost is applied by the reclaimer, not acked".into(),
)),
headgate_shared::AckValidation::SnoozeDelayRequired => {
Err(StoreError::Invalid("snooze requires delay_ms > 0".into()))
}
headgate_shared::AckValidation::Valid => Ok(()),
}
}
pub struct Claim {
pub envelope: Envelope,
pub lease_id: String,
pub fence: u64,
pub expires_at_ms: i64,
pub checkpoint: Checkpoint,
}
impl Claim {
pub fn lease_ref(&self) -> LeaseRef {
LeaseRef {
job_id: self.envelope.id.clone(),
lease_id: self.lease_id.clone(),
fence: self.fence,
}
}
}
pub struct AdmissionUnit {
pub claims: Vec<Claim>,
}
impl AdmissionUnit {
pub fn size(&self) -> usize {
self.claims.len()
}
}
pub fn group_admission_claims(claims: Vec<Claim>, max_unit_size: u32) -> Vec<AdmissionUnit> {
let max = max_unit_size.max(1) as usize;
let mut units: Vec<AdmissionUnit> = Vec::new();
for claim in claims {
if let Some(unit) = units.iter_mut().rev().find(|unit| {
unit.claims.len() < max
&& unit
.claims
.first()
.is_some_and(|first| first.envelope.kind == claim.envelope.kind)
}) {
unit.claims.push(claim);
} else {
units.push(AdmissionUnit {
claims: vec![claim],
});
}
}
units
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LeaseRef {
pub job_id: String,
pub lease_id: String,
pub fence: u64,
}
#[derive(Debug)]
pub enum StoreError {
Duplicate {
existing_id: String,
replaced: bool,
},
IdConflict {
job_id: String,
},
Quarantined {
fingerprint: String,
},
Backpressure {
queue: String,
limit: u64,
current: u64,
incoming: u64,
},
LeaseRejected {
job_id: String,
},
Unavailable(String),
NotFound(String),
Invalid(String),
Backend(String),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
StoreError::Duplicate { existing_id, .. } => {
write!(f, "duplicate unique key; existing job {existing_id}")
}
StoreError::IdConflict { job_id } => write!(f, "id conflict: job {job_id}"),
StoreError::Quarantined { fingerprint } => {
write!(f, "fingerprint {fingerprint} is quarantined")
}
StoreError::Backpressure {
queue,
limit,
current,
incoming,
} => write!(
f,
"enqueue backpressure: queue {queue} has {current} unfinished jobs, limit {limit}, incoming {incoming}"
),
StoreError::LeaseRejected { job_id } => write!(
f,
"lease no longer held for job {job_id}; stop work immediately"
),
StoreError::Unavailable(m) => write!(f, "store unavailable: {m}"),
StoreError::NotFound(m) => write!(f, "not found: {m}"),
StoreError::Invalid(m) => write!(f, "invalid request: {m}"),
StoreError::Backend(m) => write!(f, "{m}"),
}
}
}
impl std::error::Error for StoreError {}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct Caps(pub u32);
impl Caps {
pub const TRANSACTIONAL: Caps = Caps(1);
pub const NOTIFYING: Caps = Caps(2);
pub const INSPECT: Caps = Caps(4);
pub const fn has(self, c: Caps) -> bool {
self.0 & c.0 != 0
}
}
#[async_trait::async_trait]
pub trait Store: Send + Sync + 'static {
async fn admit(&self, req: AdmitRequest) -> Result<Vec<AdmissionUnit>, StoreError>;
async fn ack(
&self,
lease: &LeaseRef,
outcome: Outcome,
err: Option<&str>,
delay_ms: Option<i64>,
) -> Result<(), StoreError> {
self.ack_attempt_with_actual_weight(lease, outcome, err, delay_ms, &[], None)
.await
}
async fn ack_attempt(
&self,
lease: &LeaseRef,
outcome: Outcome,
err: Option<&str>,
delay_ms: Option<i64>,
logs: &[String],
) -> Result<(), StoreError> {
self.ack_attempt_with_actual_weight(lease, outcome, err, delay_ms, logs, None)
.await
}
async fn ack_attempt_with_actual_weight(
&self,
lease: &LeaseRef,
outcome: Outcome,
err: Option<&str>,
delay_ms: Option<i64>,
logs: &[String],
actual_weight: Option<u32>,
) -> Result<(), StoreError>;
fn as_result_store(&self) -> Option<&dyn ResultStore> {
None
}
fn as_output_store(&self) -> Option<&dyn OutputStore> {
None
}
fn as_progress_store(&self) -> Option<&dyn ProgressStore> {
None
}
async fn renew(&self, leases: &[LeaseRef], lease: Duration) -> Result<Vec<String>, StoreError>;
async fn enqueue(&self, batch: &[Envelope]) -> Result<(), StoreError>;
async fn checkpoint(&self, lease: &LeaseRef, cp: &Checkpoint) -> Result<(), StoreError>;
async fn reclaim_expired(&self, limit: i64) -> Result<Vec<Reclaimed>, StoreError>;
async fn promote_due(&self, limit: i64) -> Result<u64, StoreError>;
async fn evict_retained(&self, limit: i64) -> Result<u64, StoreError>;
async fn claim_duty(
&self,
name: &str,
holder: &str,
lease: Duration,
) -> Result<bool, StoreError>;
async fn release_duty(&self, name: &str, holder: &str) -> Result<(), StoreError>;
fn caps(&self) -> Caps;
fn as_transactional(&self) -> Option<&dyn Transactional> {
None
}
fn as_inspect(&self) -> Option<&dyn Inspect> {
None
}
fn as_notifying(&self) -> Option<&dyn Notifying> {
None
}
}
#[async_trait::async_trait]
pub trait ResultStore: Send + Sync + 'static {
async fn ack_success_with_result(
&self,
lease: &LeaseRef,
logs: &[String],
actual_weight: Option<u32>,
result: &JobResult,
) -> Result<(), StoreError>;
}
#[async_trait::async_trait]
pub trait OutputStore: Send + Sync + 'static {
async fn write_job_output(
&self,
lease: &LeaseRef,
output: &JobResult,
) -> Result<JobOutput, StoreError>;
}
#[async_trait::async_trait]
pub trait ProgressStore: Send + Sync + 'static {
async fn write_job_progress(
&self,
lease: &LeaseRef,
update: &ProgressUpdate,
) -> Result<JobProgress, StoreError>;
}
#[async_trait::async_trait]
pub trait Notifying: Store {
async fn wait_wakeup(
&self,
queues: &[String],
timeout: Duration,
) -> Result<Option<String>, StoreError>;
}
#[derive(Clone, Debug)]
pub struct Reclaimed {
pub job_id: String,
pub fingerprint: String,
pub crash_attempt: u32,
pub quarantined: bool,
}
pub trait TxHandle: Send {
fn as_any(&mut self) -> &mut (dyn std::any::Any + Send);
fn into_any(self: Box<Self>) -> Box<dyn std::any::Any + Send>;
}
#[async_trait::async_trait]
pub trait Transactional: Store {
async fn begin_tx(&self) -> Result<Box<dyn TxHandle>, StoreError>;
async fn commit_tx(&self, tx: Box<dyn TxHandle>) -> Result<(), StoreError>;
async fn rollback_tx(&self, tx: Box<dyn TxHandle>) -> Result<(), StoreError>;
async fn enqueue_tx(&self, tx: &mut dyn TxHandle, batch: &[Envelope])
-> Result<(), StoreError>;
async fn complete_tx(&self, tx: &mut dyn TxHandle, lease: &LeaseRef) -> Result<(), StoreError> {
self.complete_tx_with_actual_weight(tx, lease, None).await
}
async fn complete_tx_with_actual_weight(
&self,
tx: &mut dyn TxHandle,
lease: &LeaseRef,
actual_weight: Option<u32>,
) -> Result<(), StoreError>;
async fn claim_effect(&self, tx: &mut dyn TxHandle, key: &str) -> Result<bool, StoreError>;
async fn checkpoint_tx(
&self,
tx: &mut dyn TxHandle,
lease: &LeaseRef,
cp: &Checkpoint,
) -> Result<(), StoreError>;
}
#[derive(Clone, Debug)]
pub struct JobSummary {
pub id: String,
pub kind: String,
pub queue: String,
pub state: String,
pub schema_version: u32,
pub priority: i32,
pub attempt: u32,
pub crash_attempt: u32,
pub max_attempts: u32,
pub partition_key: String,
pub rate_class: String,
pub sticky_worker: String,
pub weight: u32,
pub fingerprint: String,
pub enqueued_at_ms: i64,
pub scheduled_at_ms: i64,
pub claimed_at_ms: Option<i64>,
pub periodic_schedule_id: String,
pub periodic_tick_ms: i64,
pub finalized_at_ms: Option<i64>,
pub payload: Option<Vec<u8>>,
pub headers: std::collections::BTreeMap<String, String>,
pub errors_json: String,
pub tags: Vec<String>,
}
impl JobSummary {
pub fn is_orphaned(&self) -> bool {
self.crash_attempt > 0
}
}
#[derive(Clone, Debug, Default)]
pub struct JobFilter {
pub queue: Option<String>,
pub state: Option<String>,
pub kind: Option<String>,
pub kind_prefix: Option<String>,
pub partition_key: Option<String>,
pub id: Option<String>,
pub fingerprint: Option<String>,
pub rate_class: Option<String>,
pub priority: Option<i32>,
pub tags_all: Vec<String>,
pub tags_any: Vec<String>,
}
pub struct JobPage {
pub jobs: Vec<JobSummary>,
pub next_cursor: Option<String>,
}
pub struct StateCounts {
pub counts: Vec<(String, i64)>,
pub approximate: bool,
}
pub struct QueueStats {
pub queue: String,
pub weight: u32,
pub unfinished_jobs: u64,
pub max_unfinished_jobs: Option<u64>,
pub by_state: Vec<(String, i64)>,
pub counts_approximate: bool,
pub arrival_rate: f64,
pub drain_rate: f64,
pub time_to_drain_ms: Option<i64>,
pub oldest_available_ms: Option<i64>,
pub quiet_groups: QuietGroupMetrics,
pub paused: bool,
pub memory_bytes: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct QuietGroupMetrics {
pub arrival_rate: f64,
pub drain_rate: f64,
pub time_to_drain_ms: Option<i64>,
pub oldest_available_ms: Option<i64>,
pub noisy_partitions: u32,
pub approximate: bool,
}
pub use headgate_shared::inspection::{age_ms, time_to_drain_ms};
pub fn noisy_partition_keys(loads: &[(String, i64)]) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
if loads.len() < 2 {
return out;
}
for (i, (key, raw_n)) in loads.iter().enumerate() {
let n = (*raw_n).max(0) as u128;
if n < 2 {
continue;
}
let others: u128 = loads
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, (_, v))| (*v).max(0) as u128)
.sum();
if n * (loads.len() as u128 - 1) > 2 * others {
out.insert(key.clone());
}
}
out
}
#[derive(Clone, Debug)]
pub struct RateClassConfig {
pub name: String,
pub limit: i64,
pub window_ms: i64,
pub burst: i64,
pub paused: bool,
}
pub fn validate_rate_class_config(cfg: &RateClassConfig) -> Result<(), StoreError> {
if cfg.window_ms < 1 {
return Err(StoreError::Invalid("window_ms must be >= 1".into()));
}
if cfg.limit < 0 || cfg.burst < 1 {
return Err(StoreError::Invalid(
"limit must be >= 0 and burst >= 1".into(),
));
}
Ok(())
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SaturationStrategy {
#[default]
Queue,
Discard,
CancelRunning,
CancelIncoming,
}
impl SaturationStrategy {
pub fn as_str(self) -> &'static str {
match self {
Self::Queue => "queue",
Self::Discard => "discard",
Self::CancelRunning => "cancel_running",
Self::CancelIncoming => "cancel_incoming",
}
}
}
impl TryFrom<&str> for SaturationStrategy {
type Error = StoreError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"queue" => Ok(Self::Queue),
"discard" => Ok(Self::Discard),
"cancel_running" => Ok(Self::CancelRunning),
"cancel_incoming" => Ok(Self::CancelIncoming),
_ => Err(StoreError::Invalid(format!(
"unknown saturation strategy `{value}`"
))),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConcurrencyLimitConfig {
pub name: String,
pub queue: String,
pub max_concurrent: u64,
pub on_saturated: SaturationStrategy,
}
pub fn validate_concurrency_limit(cfg: &ConcurrencyLimitConfig) -> Result<i64, StoreError> {
if cfg.name.is_empty() || cfg.queue.is_empty() {
return Err(StoreError::Invalid(
"name and queue must not be empty".into(),
));
}
if cfg.max_concurrent == 0 {
return Err(StoreError::Invalid("max_concurrent must be >= 1".into()));
}
i64::try_from(cfg.max_concurrent)
.map_err(|_| StoreError::Invalid("max_concurrent is too large".into()))
}
pub fn validate_schedule_event_limit(limit: u32) -> Result<(), StoreError> {
if limit == 0 || limit > SCHEDULE_EVENT_LIMIT {
Err(StoreError::Invalid(
"schedule event limit must be between 1 and 100".into(),
))
} else {
Ok(())
}
}
pub struct RateClassState {
pub name: String,
pub tokens_available: i64,
pub burst: i64,
pub limit_per_window: i64,
pub window_ms: i64,
pub jobs_waiting: i64,
pub paused: bool,
}
pub struct PartitionState {
pub partition_key: String,
pub deficit: i64,
pub waiting: i64,
}
pub struct QuarantineEntry {
pub fingerprint: String,
pub kind: String,
pub crash_count: i64,
pub quarantined_at_ms: i64,
pub reason: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockedBy {
RateClass,
ConcurrencyLimit,
Fairness,
Quarantine,
Schedule,
QueuePaused,
}
impl BlockedBy {
pub const fn as_str(self) -> &'static str {
match self {
BlockedBy::RateClass => "rate_class",
BlockedBy::ConcurrencyLimit => "concurrency_limit",
BlockedBy::Fairness => "fairness",
BlockedBy::Quarantine => "quarantine",
BlockedBy::Schedule => "schedule",
BlockedBy::QueuePaused => "queue_paused",
}
}
}
pub struct AdmissionExplain {
pub state: String,
pub admissible: bool,
pub blocked_by: Option<BlockedBy>,
pub detail: Vec<(String, String)>,
pub estimated_admission_ms: Option<i64>,
}
pub fn evaluate_admission(facts: &headgate_shared::AdmissionFacts) -> AdmissionExplain {
let evaluation = headgate_shared::evaluate_admission(facts);
AdmissionExplain {
state: facts.state.clone(),
admissible: evaluation.admissible,
blocked_by: evaluation.blocked_by.map(|blocked| match blocked {
"rate_class" => BlockedBy::RateClass,
"concurrency_limit" => BlockedBy::ConcurrencyLimit,
"fairness" => BlockedBy::Fairness,
"quarantine" => BlockedBy::Quarantine,
"schedule" => BlockedBy::Schedule,
"queue_paused" => BlockedBy::QueuePaused,
_ => unreachable!("shared evaluator returned an unknown admission block"),
}),
detail: evaluation.detail,
estimated_admission_ms: evaluation.estimated_admission_ms,
}
}
#[derive(Clone, Debug)]
pub struct HistoryBucket {
pub at_ms: i64,
pub arrived: i64,
pub completed: i64,
}
#[derive(Clone, Debug)]
pub struct Schedule {
pub id: String,
pub kind: String,
pub payload: Vec<u8>,
pub queue: String,
pub partition_key: String,
pub rate_class: String,
pub priority: i32,
pub max_attempts: u32,
pub retention_ms: i64,
pub spec: String,
pub next_run_ms: i64,
pub last_enqueued_ms: Option<i64>,
pub on_missed: MissedPolicy,
pub backfill_limit: u32,
pub paused: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScheduleEventOutcome {
Enqueued,
Deduplicated,
Failed,
Skipped,
}
impl ScheduleEventOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Enqueued => "enqueued",
Self::Deduplicated => "deduplicated",
Self::Failed => "failed",
Self::Skipped => "skipped",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"enqueued" => Some(Self::Enqueued),
"deduplicated" => Some(Self::Deduplicated),
"failed" => Some(Self::Failed),
"skipped" => Some(Self::Skipped),
_ => None,
}
}
}
pub const SCHEDULE_EVENT_LIMIT: u32 = 100;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScheduleEvent {
pub event_id: u64,
pub schedule_id: String,
pub tick_ms: i64,
pub job_id: String,
pub outcome: ScheduleEventOutcome,
pub reason: String,
pub recorded_at_ms: i64,
}
#[derive(Clone, Debug, Default)]
pub struct WorkerMeta {
pub worker_id: String,
pub host: String,
pub pid: i32,
pub queues: Vec<String>,
pub concurrency: u32,
pub started_at_ms: i64,
pub heartbeat_at_ms: i64,
pub inflight: u32,
pub polls: u64,
pub empty_polls: u64,
pub status: String,
pub duties_active: bool,
pub pending_command: Option<String>,
}
impl WorkerMeta {
pub fn utilization(&self) -> f64 {
if self.concurrency == 0 {
0.0
} else {
self.inflight as f64 / self.concurrency as f64
}
}
pub fn empty_poll_ratio(&self) -> f64 {
if self.polls == 0 {
0.0
} else {
self.empty_polls as f64 / self.polls as f64
}
}
}
#[derive(Clone, Debug)]
pub struct BulkRequest {
pub id: String,
pub action: String,
pub queue: Option<String>,
pub state: Option<String>,
pub kind: Option<String>,
pub partition_key: Option<String>,
pub older_than_ms: Option<i64>,
pub dry_run: bool,
}
impl BulkRequest {
pub fn has_selector(&self) -> bool {
self.queue.is_some()
|| self.state.is_some()
|| self.kind.is_some()
|| self.partition_key.is_some()
|| self.older_than_ms.is_some()
}
}
pub fn bulk_action_states(action: &str) -> Option<&'static [&'static str]> {
headgate_shared::bulk_action_states(action)
}
pub fn valid_worker_command(command: &str) -> bool {
headgate_shared::valid_worker_command(command)
}
pub fn format_generated_id(now_ms: u64, process_id: u32, sequence: u64) -> String {
headgate_shared::format_generated_id(now_ms, process_id, sequence)
}
#[derive(Clone, Debug)]
pub struct OperationStatus {
pub id: String,
pub status: String,
pub affected: i64,
pub total_estimated: i64,
pub dry_run: bool,
pub error: Option<String>,
}
#[async_trait::async_trait]
pub trait Inspect: Store {
fn as_result_inspect(&self) -> Option<&dyn ResultInspect> {
None
}
fn as_output_inspect(&self) -> Option<&dyn OutputInspect> {
None
}
fn as_progress_inspect(&self) -> Option<&dyn ProgressInspect> {
None
}
fn as_checkpoint_inspect(&self) -> Option<&dyn CheckpointInspect> {
None
}
async fn get_job(
&self,
id: &str,
include_payload: bool,
) -> Result<Option<JobSummary>, StoreError>;
async fn list_jobs(
&self,
filter: &JobFilter,
cursor: Option<&str>,
limit: u32,
) -> Result<JobPage, StoreError>;
async fn counts(&self, queue: Option<&str>) -> Result<StateCounts, StoreError>;
async fn queue_stats(&self) -> Result<Vec<QueueStats>, StoreError>;
async fn set_queue_paused(&self, queue: &str, paused: bool) -> Result<(), StoreError>;
async fn set_queue_weight(&self, queue: &str, weight: u32) -> Result<(), StoreError>;
async fn set_enqueue_limit(
&self,
queue: &str,
max_unfinished_jobs: Option<u64>,
) -> Result<(), StoreError>;
async fn rate_classes(&self) -> Result<Vec<RateClassState>, StoreError>;
async fn upsert_rate_class(&self, cfg: &RateClassConfig) -> Result<(), StoreError>;
async fn concurrency_limits(&self) -> Result<Vec<ConcurrencyLimitConfig>, StoreError>;
async fn upsert_concurrency_limit(
&self,
cfg: &ConcurrencyLimitConfig,
) -> Result<(), StoreError>;
async fn partitions(&self, queue: &str) -> Result<Vec<PartitionState>, StoreError>;
async fn quarantine_list(&self) -> Result<Vec<QuarantineEntry>, StoreError>;
async fn quarantine_release(&self, fingerprint: &str) -> Result<u64, StoreError>;
async fn operator_retry(&self, id: &str) -> Result<(), StoreError>;
async fn operator_cancel(&self, id: &str) -> Result<(), StoreError>;
async fn promote_job(&self, id: &str) -> Result<(), StoreError>;
async fn delete_job(&self, id: &str) -> Result<(), StoreError>;
async fn explain_admission(&self, id: &str) -> Result<Option<AdmissionExplain>, StoreError>;
async fn history(
&self,
queue: &str,
since_ms: i64,
bucket_ms: i64,
) -> Result<Vec<HistoryBucket>, StoreError>;
async fn quarantine_sweep(&self, limit: i64) -> Result<u64, StoreError>;
async fn reschedule_job(&self, id: &str, at_ms: i64) -> Result<(), StoreError>;
async fn edit_payload(
&self,
id: &str,
payload: &[u8],
schema_version: u32,
fingerprint: &str,
) -> Result<(), StoreError>;
async fn upsert_schedule(&self, s: &Schedule) -> Result<(), StoreError>;
async fn delete_schedule(&self, id: &str) -> Result<(), StoreError>;
async fn list_schedules(&self) -> Result<Vec<Schedule>, StoreError>;
async fn due_schedules(&self, limit: i64) -> Result<(Vec<Schedule>, i64), StoreError>;
async fn advance_schedule(
&self,
id: &str,
from_next_run_ms: i64,
to_next_run_ms: i64,
) -> Result<bool, StoreError>;
async fn record_schedule_event(&self, event: &ScheduleEvent) -> Result<(), StoreError>;
async fn list_schedule_events(
&self,
schedule_id: &str,
before_event_id: Option<u64>,
limit: u32,
) -> Result<Vec<ScheduleEvent>, StoreError>;
async fn heartbeat_worker(&self, w: &WorkerMeta) -> Result<Option<String>, StoreError>;
async fn list_workers(&self, stale_after_ms: i64) -> Result<Vec<WorkerMeta>, StoreError>;
async fn signal_worker(&self, worker_id: &str, command: Option<&str>)
-> Result<(), StoreError>;
async fn distinct_kinds(&self, limit: i64) -> Result<Vec<String>, StoreError>;
async fn create_operation(&self, req: &BulkRequest) -> Result<(), StoreError>;
async fn get_operation(&self, id: &str) -> Result<Option<OperationStatus>, StoreError>;
async fn run_pending_operations(&self, batch: i64) -> Result<u64, StoreError>;
async fn delete_queue(&self, queue: &str, force: bool) -> Result<Option<String>, StoreError>;
async fn sample_queue_memory(&self, limit: u32) -> Result<u32, StoreError>;
}
#[async_trait::async_trait]
pub trait ResultInspect: Send + Sync + 'static {
async fn get_job_result(&self, id: &str) -> Result<Option<JobResult>, StoreError>;
}
#[async_trait::async_trait]
pub trait OutputInspect: Send + Sync + 'static {
async fn get_job_output(&self, id: &str) -> Result<Option<JobOutput>, StoreError>;
}
#[async_trait::async_trait]
pub trait ProgressInspect: Send + Sync + 'static {
async fn get_job_progress(&self, id: &str) -> Result<Option<JobProgress>, StoreError>;
}
#[async_trait::async_trait]
pub trait CheckpointInspect: Send + Sync + 'static {
async fn get_job_checkpoint(&self, id: &str) -> Result<Option<Checkpoint>, StoreError>;
}
pub trait Telemetry: Send + Sync + 'static {
fn on_event(&self, ev: Event<'_>);
}
#[non_exhaustive]
pub enum Event<'a> {
Admitted {
queue: &'a str,
count: usize,
},
Rejected {
queue: &'a str,
policy: &'a str,
count: usize,
},
Completed {
kind: &'a str,
ms: u64,
},
Quarantined {
fingerprint: &'a str,
crashes: u32,
},
Evicted {
queue: &'a str,
count: u64,
},
JobSpan {
job_id: &'a str,
kind: &'a str,
queue: &'a str,
attempt: u32,
outcome: &'a str,
started_at_ms: i64,
ms: u64,
trace: Option<&'a TraceContext>,
},
WorkerSaturation {
worker: &'a str,
inflight: u32,
capacity: u32,
utilization: f64,
empty_poll_ratio: f64,
polls: u64,
empty_polls: u64,
},
WorkerMemory {
worker: &'a str,
used_bytes: u64,
limit_bytes: u64,
restart_requested: bool,
},
}
pub struct NoopTelemetry;
impl Telemetry for NoopTelemetry {
fn on_event(&self, _: Event<'_>) {}
}
pub trait Clock: Send + Sync + 'static {
fn now_ms(&self) -> i64;
}
pub trait IsFailure: Send + Sync + 'static {
fn is_failure(&self, err: &(dyn std::error::Error + 'static)) -> bool;
}
pub struct AllErrorsAreFailures;
impl IsFailure for AllErrorsAreFailures {
fn is_failure(&self, _: &(dyn std::error::Error + 'static)) -> bool {
true
}
}
pub trait IdGen: Send + Sync + 'static {
fn new_id(&self) -> String;
}
pub fn check_kind_collisions(kinds: &[(&str, &[&str])]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for (ty, aliases) in kinds {
for k in std::iter::once(ty).chain(aliases.iter()) {
validate_kind(k)?;
if !seen.insert(*k) {
return Err(format!("kind `{k}` is registered more than once"));
}
}
}
Ok(())
}
pub fn validate_kind(kind: &str) -> Result<(), String> {
const RULE: &str =
"1-128 characters, first [A-Za-z0-9_], rest [A-Za-z0-9_] or one of -[]<>/.:+";
const EXTRA: &str = "-[]<>/.:+";
fn word(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
let ok = !kind.is_empty()
&& kind.len() <= 128
&& kind.starts_with(word)
&& kind.chars().skip(1).all(|c| word(c) || EXTRA.contains(c));
if ok {
Ok(())
} else {
Err(format!("invalid kind `{kind}`: {RULE}"))
}
}
pub fn enqueue_queue(e: &Envelope) -> &str {
headgate_shared::effective_queue(&e.queue)
}
pub fn same_job_content(e: &Envelope, kind: &str, fingerprint: &str, queue: &str) -> bool {
e.kind == kind && e.fingerprint == fingerprint && enqueue_queue(e) == queue
}
pub const MAX_ENQUEUE_BATCH_SIZE: usize = 1_000;
pub const MAX_JOB_PAYLOAD_BYTES: usize = 1 << 20;
pub const MAX_JOB_HEADERS_BYTES: usize = 64 << 10;
pub const MAX_JOB_HEADER_COUNT: usize = 128;
pub const MAX_JOB_IDENTIFIER_LEN: usize = 255;
pub const MAX_UNIQUE_KEY_BYTES: usize = 1 << 10;
pub const MAX_ENQUEUE_BYTES: usize = 16 << 20;
pub fn validate_enqueue(batch: &[Envelope]) -> Result<(), StoreError> {
if batch.len() > MAX_ENQUEUE_BATCH_SIZE {
return Err(StoreError::Invalid(
"enqueue batch must contain at most 1000 jobs".into(),
));
}
let mut seen = std::collections::HashSet::with_capacity(batch.len());
let mut total_bytes = 0usize;
for e in batch {
if e.id.is_empty() {
return Err(StoreError::Invalid("envelope id must not be empty".into()));
}
validate_kind(&e.kind).map_err(StoreError::Invalid)?;
for (name, value) in [
("envelope id", e.id.as_str()),
("queue", e.queue.as_str()),
("partition_key", e.partition_key.as_str()),
("rate_class", e.rate_class.as_str()),
("fingerprint", e.fingerprint.as_str()),
("periodic_schedule_id", e.periodic_schedule_id.as_str()),
] {
if value.len() > MAX_JOB_IDENTIFIER_LEN {
return Err(StoreError::Invalid(format!(
"{name} must be at most 255 bytes"
)));
}
}
if e.payload.len() > MAX_JOB_PAYLOAD_BYTES {
return Err(StoreError::Invalid(
"payload must be at most 1048576 bytes".into(),
));
}
if e.unique_key.as_ref().map_or(0, Vec::len) > MAX_UNIQUE_KEY_BYTES {
return Err(StoreError::Invalid(
"unique_key must be at most 1024 bytes".into(),
));
}
if e.headers.len() > MAX_JOB_HEADER_COUNT {
return Err(StoreError::Invalid(
"headers must contain at most 128 values".into(),
));
}
let header_bytes = e
.headers
.iter()
.map(|(key, value)| key.len() + value.len())
.sum::<usize>();
if header_bytes > MAX_JOB_HEADERS_BYTES {
return Err(StoreError::Invalid(
"headers must total at most 65536 bytes".into(),
));
}
total_bytes = total_bytes.saturating_add(
e.payload.len()
+ header_bytes
+ e.id.len()
+ e.kind.len()
+ e.queue.len()
+ e.partition_key.len()
+ e.rate_class.len()
+ e.fingerprint.len()
+ e.unique_key.as_ref().map_or(0, Vec::len),
);
if total_bytes > MAX_ENQUEUE_BYTES {
return Err(StoreError::Invalid(
"enqueue batch data must total at most 16777216 bytes".into(),
));
}
if e.timeout_ms < 0 {
return Err(StoreError::Invalid("timeout_ms must be >= 0".into()));
}
if e.deadline_ms < 0 {
return Err(StoreError::Invalid("deadline_ms must be >= 0".into()));
}
if e.retention_ms < 0 {
return Err(StoreError::Invalid("retention_ms must be >= 0".into()));
}
if e.unique_window_ms < 0 {
return Err(StoreError::Invalid("unique_window_ms must be >= 0".into()));
}
if e.unique_debounce_ms < 0 {
return Err(StoreError::Invalid(
"unique_debounce_ms must be >= 0".into(),
));
}
if e.unique_debounce_ms > 0
&& (e.unique_key.as_ref().is_none_or(Vec::is_empty) || e.unique_window_ms > 0)
{
return Err(StoreError::Invalid(
"unique_debounce_ms requires lifecycle unique_key".into(),
));
}
if e.unique_replace & !UNIQUE_REPLACE_ALL != 0 {
return Err(StoreError::Invalid(
"unique_replace contains unknown fields".into(),
));
}
if e.unique_replace != 0 && e.unique_key.as_ref().is_none_or(Vec::is_empty) {
return Err(StoreError::Invalid(
"unique_replace requires unique_key".into(),
));
}
if e.tags.len() > 32 {
return Err(StoreError::Invalid(
"tags must contain at most 32 values".into(),
));
}
let mut tags = std::collections::HashSet::with_capacity(e.tags.len());
for tag in &e.tags {
if tag.is_empty() || tag.len() > 64 || !tag.is_ascii() {
return Err(StoreError::Invalid(
"each tag must be 1-64 ASCII bytes".into(),
));
}
if !tags.insert(tag) {
return Err(StoreError::Invalid(
"tags must not contain duplicates".into(),
));
}
total_bytes = total_bytes.saturating_add(tag.len());
}
total_bytes = total_bytes
.saturating_add(e.sticky_worker.len())
.saturating_add(e.periodic_schedule_id.len());
if total_bytes > MAX_ENQUEUE_BYTES {
return Err(StoreError::Invalid(
"enqueue batch data must total at most 16777216 bytes".into(),
));
}
if e.pending && e.scheduled_at_ms != 0 {
return Err(StoreError::Invalid(
"pending jobs cannot also set scheduled_at_ms".into(),
));
}
if !e.sticky_worker.is_empty()
&& (e.sticky_worker.len() > 255 || !e.sticky_worker.is_ascii())
{
return Err(StoreError::Invalid(
"sticky_worker must be at most 255 ASCII bytes".into(),
));
}
if e.periodic_schedule_id.is_empty() != (e.periodic_tick_ms == 0) || e.periodic_tick_ms < 0
{
return Err(StoreError::Invalid(
"periodic_schedule_id and positive periodic_tick_ms must be set together".into(),
));
}
if !seen.insert(e.id.as_str()) {
return Err(StoreError::IdConflict {
job_id: e.id.clone(),
});
}
}
if batch.len() != 1
&& batch
.iter()
.any(|e| e.unique_replace != 0 || e.unique_debounce_ms > 0)
{
return Err(StoreError::Invalid(
"unique replacement and debounce require a single-job enqueue".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(a: u32, ma: u32, c: u32, cl: u32) -> TransitionCtx {
TransitionCtx {
attempt: a,
max_attempts: ma,
crash_attempt: c,
crash_limit: cl,
retention_ms: 86_400_000,
}
}
#[test]
fn abort_is_honored_not_retried() {
assert_eq!(
transition(State::Running, Outcome::Skip, &ctx(0, 25, 0, 3)),
State::Archived
);
}
#[test]
fn fingerprint_matches_the_spec_vectors() {
for (kind, payload, want) in [
(
"email:welcome",
b"".as_slice(),
"bed0eecb39af02d79d5cdc8026a9b817",
),
("", b"".as_slice(), "af5570f5a1810b7af78caf4bc70a660f"),
("a", b"bc".as_slice(), "47ea6f805c5b663e33012cd34184e139"),
("ab", b"c".as_slice(), "60014a36d7b05b0730e42a8b96faa1ff"),
(
"charge",
[0u8, 1, 2].as_slice(),
"295e280cea51e7f3978bc3195d8fd4ae",
),
(
"résumé:parse",
b"{}".as_slice(),
"a9b8c5d03aa1a0710129091fa3dc0a1d",
),
] {
assert_eq!(
fingerprint(kind, payload),
want,
"vector ({kind:?}, {payload:?})"
);
}
assert_ne!(fingerprint("a", b"bc"), fingerprint("ab", b"c"));
}
#[test]
fn success_respects_retention() {
assert_eq!(
transition(State::Running, Outcome::Success, &ctx(0, 25, 0, 3)),
State::Completed
);
let ephemeral = TransitionCtx {
retention_ms: 0,
..ctx(0, 25, 0, 3)
};
assert_eq!(
transition(State::Running, Outcome::Success, &ephemeral),
State::Deleted
);
}
#[test]
fn revoke_drops_entirely() {
assert_eq!(
transition(State::Running, Outcome::Revoke, &ctx(0, 25, 0, 3)),
State::Deleted
);
}
#[test]
fn crash_is_not_a_retry() {
assert_eq!(
transition(State::Running, Outcome::LeaseLost, &ctx(0, 25, 0, 3)),
State::Retryable
);
assert_eq!(
transition(State::Running, Outcome::LeaseLost, &ctx(0, 25, 2, 3)),
State::Quarantined
);
assert_eq!(
transition(State::Running, Outcome::Retry, &ctx(0, 25, 2, 3)),
State::Retryable
);
}
#[test]
fn undecodable_never_retries() {
assert_eq!(
transition(State::Running, Outcome::Undecodable, &ctx(0, 25, 0, 3)),
State::Undecodable
);
}
#[test]
fn snooze_does_not_consume_an_attempt() {
assert_eq!(
transition(State::Running, Outcome::Snooze, &ctx(0, 25, 0, 3)),
State::Scheduled
);
}
#[test]
fn rate_limited_is_not_a_failure() {
assert_eq!(
transition(State::Running, Outcome::RateLimited, &ctx(3, 25, 0, 3)),
State::Available
);
}
#[test]
fn changed_step_set_never_silently_restarts() {
let cp = Checkpoint {
last_completed_step: Some("transcode".into()),
schema_version: 1,
step_set_hash: "abc".into(),
..Default::default()
};
assert_eq!(cp.resumability(1, "abc"), Resume::Continue);
assert_eq!(cp.resumability(2, "xyz"), Resume::Remapped);
assert_eq!(cp.resumability(1, "xyz"), Resume::Undecodable);
}
#[test]
fn no_steps_means_always_resumable() {
assert_eq!(
Checkpoint::default().resumability(1, "anything"),
Resume::Continue
);
}
#[test]
fn aliases_let_a_task_be_renamed() {
struct Renamed;
impl Task for Renamed {
const TYPE: &'static str = "notify:welcome";
const ALIASES: &'static [&'static str] = &["email:welcome"];
fn encode(&self) -> Result<Vec<u8>, CodecError> {
Ok(vec![])
}
fn decode(_: &[u8]) -> Result<Self, CodecError> {
Ok(Renamed)
}
}
assert_eq!(Renamed::TYPE, "notify:welcome");
assert!(Renamed::ALIASES.contains(&"email:welcome"));
}
#[test]
fn colliding_kinds_are_rejected_at_startup() {
assert!(check_kind_collisions(&[("a", &[]), ("b", &[])]).is_ok());
assert!(check_kind_collisions(&[("a", &[]), ("b", &["a"])]).is_err());
assert!(check_kind_collisions(&[("a", &["bad kind"])]).is_err());
}
#[test]
fn kind_format_rule_is_exactly_one_rule() {
for k in [
"w",
"k",
"_",
"0",
"email:welcome",
"notify:welcome",
"a-b",
"a.b",
"a/b",
"a+b",
"a<b>",
"a[b]",
"Job_1",
&"x".repeat(128),
] {
assert_eq!(validate_kind(k), Ok(()), "should accept {k:?}");
}
for k in [
"",
&"x".repeat(129),
"-lead",
".lead",
":lead",
"+lead",
"[lead",
"a b",
" a",
"a\t",
"a\n",
"a\u{0}",
"a!",
"a#b",
"a,b",
"a(b)",
"a*",
"résumé:parse",
"a·b",
"a%b",
"a\"b",
] {
assert!(validate_kind(k).is_err(), "should reject {k:?}");
}
assert_eq!(
validate_kind("a b").unwrap_err(),
"invalid kind `a b`: 1-128 characters, first [A-Za-z0-9_], \
rest [A-Za-z0-9_] or one of -[]<>/.:+"
);
}
#[test]
fn enqueue_validation_is_one_function_for_every_backend() {
let ok = Envelope {
id: "a".into(),
kind: "w".into(),
..Default::default()
};
assert!(validate_enqueue(std::slice::from_ref(&ok)).is_ok());
assert!(
validate_enqueue(&[Envelope {
sticky_worker: "w".repeat(255),
..ok.clone()
}])
.is_ok()
);
for sticky_worker in ["é".to_string(), "w".repeat(256)] {
assert!(matches!(
validate_enqueue(&[Envelope {
sticky_worker,
..ok.clone()
}]),
Err(StoreError::Invalid(_))
));
}
let no_id = Envelope {
id: String::new(),
..ok.clone()
};
assert!(matches!(
validate_enqueue(&[no_id]),
Err(StoreError::Invalid(_))
));
let bad_kind = Envelope {
kind: "bad kind".into(),
..ok.clone()
};
assert!(matches!(
validate_enqueue(&[bad_kind]),
Err(StoreError::Invalid(_))
));
let neg = Envelope {
unique_window_ms: -1,
..ok.clone()
};
assert!(matches!(
validate_enqueue(&[neg]),
Err(StoreError::Invalid(_))
));
match validate_enqueue(&[ok.clone(), ok.clone()]) {
Err(StoreError::IdConflict { job_id }) => assert_eq!(job_id, "a"),
other => panic!("want IdConflict, got {other:?}"),
}
let replace_without_key = Envelope {
unique_replace: UNIQUE_REPLACE_PRIORITY,
..ok.clone()
};
assert!(matches!(
validate_enqueue(&[replace_without_key]),
Err(StoreError::Invalid(_))
));
let replace_unknown = Envelope {
unique_key: Some(b"k".to_vec()),
unique_replace: UNIQUE_REPLACE_ALL | (1 << 8),
..ok.clone()
};
assert!(matches!(
validate_enqueue(&[replace_unknown]),
Err(StoreError::Invalid(_))
));
let replace = Envelope {
unique_key: Some(b"k".to_vec()),
unique_replace: UNIQUE_REPLACE_PRIORITY,
..ok.clone()
};
assert!(validate_enqueue(std::slice::from_ref(&replace)).is_ok());
let second = Envelope {
id: "b".into(),
..ok
};
assert!(matches!(
validate_enqueue(&[replace, second]),
Err(StoreError::Invalid(_))
));
for invalid in [
Envelope {
id: "payload".into(),
kind: "w".into(),
payload: vec![0; MAX_JOB_PAYLOAD_BYTES + 1],
..Default::default()
},
Envelope {
id: "timeout".into(),
kind: "w".into(),
timeout_ms: -1,
..Default::default()
},
Envelope {
id: "deadline".into(),
kind: "w".into(),
deadline_ms: -1,
..Default::default()
},
Envelope {
id: "retention".into(),
kind: "w".into(),
retention_ms: -1,
..Default::default()
},
] {
assert!(matches!(
validate_enqueue(&[invalid]),
Err(StoreError::Invalid(_))
));
}
let oversized = (0..=MAX_ENQUEUE_BATCH_SIZE)
.map(|index| Envelope {
id: format!("job-{index}"),
kind: "w".into(),
..Default::default()
})
.collect::<Vec<_>>();
assert!(matches!(
validate_enqueue(&oversized),
Err(StoreError::Invalid(_))
));
}
#[test]
fn omitted_envelope_weight_normalizes_to_one_without_erasing_real_costs() {
assert_eq!(effective_weight(0), 1);
assert_eq!(effective_weight(1), 1);
assert_eq!(effective_weight(7), 7);
}
#[test]
fn id_conflict_compares_kind_fingerprint_and_queue() {
let e = Envelope {
id: "a".into(),
kind: "w".into(),
fingerprint: fingerprint("w", b"{}"),
payload: b"{}".to_vec(),
..Default::default()
};
assert_eq!(enqueue_queue(&e), "default");
assert!(same_job_content(
&e,
"w",
&fingerprint("w", b"{}"),
"default"
));
assert!(!same_job_content(
&e,
"w",
&fingerprint("w", b"{\"a\":1}"),
"default"
));
assert!(!same_job_content(
&e,
"v",
&fingerprint("w", b"{}"),
"default"
));
assert!(!same_job_content(
&e,
"w",
&fingerprint("w", b"{}"),
"other"
));
}
#[test]
fn id_conflict_message_is_the_uniform_one() {
assert_eq!(
StoreError::IdConflict {
job_id: "c1".into()
}
.to_string(),
"id conflict: job c1"
);
}
#[test]
fn traceparent_parses_exactly_the_w3c_shape() {
let tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
let tc = parse_traceparent(tp).expect("the canonical W3C example must parse");
assert_eq!(tc.trace_id, "4bf92f3577b34da6a3ce929d0e0e4736");
assert_eq!(tc.span_id, "00f067aa0ba902b7");
assert_eq!(tc.trace_flags, 1);
assert!(tc.sampled());
assert_eq!(tc.to_traceparent(), tp);
let un = parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")
.expect("unsampled is still a valid parent");
assert!(!un.sampled());
assert_eq!(un.trace_flags, 0);
}
#[test]
fn an_invalid_traceparent_is_absent_never_an_error() {
for bad in [
"", "garbage", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b-01", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1", "00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-zz", " 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", ] {
assert_eq!(parse_traceparent(bad), None, "must read as ABSENT: {bad:?}");
}
}
#[test]
fn trace_context_reads_the_two_reserved_headers() {
let mut h = std::collections::BTreeMap::new();
assert_eq!(trace_context(&h), None); h.insert(
TRACEPARENT.into(),
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
);
h.insert(TRACESTATE.into(), "vendor=opaque,other=1".to_string());
let tc = trace_context(&h).expect("valid parent");
assert_eq!(tc.trace_state, "vendor=opaque,other=1");
h.insert(TRACEPARENT.into(), "nonsense".to_string());
assert_eq!(trace_context(&h), None);
h.remove(TRACEPARENT);
h.insert(
"Traceparent".into(),
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
);
assert_eq!(trace_context(&h), None);
}
#[test]
fn worker_saturation_never_divides_by_zero() {
let idle = WorkerMeta {
concurrency: 0,
inflight: 0,
polls: 0,
..Default::default()
};
assert_eq!(idle.utilization(), 0.0);
assert_eq!(idle.empty_poll_ratio(), 0.0);
let busy = WorkerMeta {
concurrency: 8,
inflight: 6,
polls: 10,
empty_polls: 4,
..Default::default()
};
assert_eq!(busy.utilization(), 0.75);
assert_eq!(busy.empty_poll_ratio(), 0.4);
}
#[test]
fn quiet_group_noise_detection_is_skew_based_and_work_conserving() {
let loads = |xs: &[(&str, i64)]| {
xs.iter()
.map(|(k, n)| ((*k).to_string(), *n))
.collect::<Vec<_>>()
};
assert!(
noisy_partition_keys(&loads(&[("only", 500)])).is_empty(),
"a lone partition has nobody to disturb and must stay visible"
);
assert!(
noisy_partition_keys(&loads(&[("a", 1), ("b", 0)])).is_empty(),
"one claim is not enough evidence to call a tenant noisy"
);
assert!(
noisy_partition_keys(&loads(&[("a", 4), ("b", 2)])).is_empty(),
"exactly twice the peer mean is the boundary, not over it"
);
let got = noisy_partition_keys(&loads(&[("flood", 9), ("quiet-a", 1), ("quiet-b", 2)]));
assert_eq!(got.into_iter().collect::<Vec<_>>(), vec!["flood"]);
assert!(
noisy_partition_keys(&loads(&[("a", 3), ("b", 3), ("c", 3)])).is_empty(),
"balanced busy tenants are not noisy neighbours"
);
let got = noisy_partition_keys(&loads(&[("negative", -7), ("flood", 2)]));
assert!(
got.contains("flood") && !got.contains("negative"),
"a corrupt negative counter is treated as zero, never inverted"
);
}
#[test]
fn saturation_strategy_spellings_are_one_cross_backend_contract() {
for (raw, want) in [
("queue", SaturationStrategy::Queue),
("discard", SaturationStrategy::Discard),
("cancel_running", SaturationStrategy::CancelRunning),
("cancel_incoming", SaturationStrategy::CancelIncoming),
] {
let got = SaturationStrategy::try_from(raw).unwrap();
assert_eq!(got, want);
assert_eq!(got.as_str(), raw);
}
assert!(matches!(
SaturationStrategy::try_from("cancel_newest"),
Err(StoreError::Invalid(msg)) if msg == "unknown saturation strategy `cancel_newest`"
));
}
#[test]
fn terminal_states_are_terminal() {
for s in [
State::Completed,
State::Archived,
State::Cancelled,
State::Quarantined,
State::Undecodable,
State::Deleted,
] {
assert!(s.is_terminal());
assert_eq!(transition(s, Outcome::Retry, &ctx(0, 25, 0, 3)), s);
for ev in [
LifecycleEvent::ScheduleDue,
LifecycleEvent::Admitted,
LifecycleEvent::BackoffDue,
LifecycleEvent::CheckpointStale,
] {
assert_eq!(
lifecycle_transition(s, ev),
None,
"{s:?} must never auto-transition"
);
}
}
}
#[test]
fn yaml_and_code_agree_row_for_row() {
let yaml = include_str!("../../../conformance/state_machine.yaml");
let mut rows = 0usize;
for line in yaml.lines() {
let line = line.trim();
let Some(body) = line.strip_prefix("- {").and_then(|r| r.split('}').next()) else {
continue;
};
let mut from = "";
let mut on = "";
let mut to = "";
let mut when = "";
for field in split_top_level(body) {
let (k, v) = field.split_once(':').expect("field");
let v = v.trim().trim_matches('"');
match k.trim() {
"from" => from = v,
"on" => on = v,
"to" => to = v,
"when" => when = v,
"note" => {}
other => panic!("unknown key `{other}` in state_machine.yaml"),
}
}
rows += 1;
check_row(from, on, to, when);
}
assert_eq!(
rows, 22,
"state_machine.yaml row count changed; update the table AND its scenarios"
);
}
fn split_top_level(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth_quote = false;
let mut start = 0;
for (i, c) in s.char_indices() {
match c {
'"' => depth_quote = !depth_quote,
',' if !depth_quote => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&s[start..]);
out
}
fn state(name: &str) -> State {
match name {
"pending" => State::Pending,
"scheduled" => State::Scheduled,
"available" => State::Available,
"running" => State::Running,
"retryable" => State::Retryable,
"completed" => State::Completed,
"archived" => State::Archived,
"cancelled" => State::Cancelled,
"quarantined" => State::Quarantined,
"undecodable" => State::Undecodable,
"deleted" => State::Deleted,
other => panic!("unknown state `{other}` in state_machine.yaml"),
}
}
fn ctx_for(when: &str) -> TransitionCtx {
let mut c = TransitionCtx {
attempt: 0,
max_attempts: 25,
crash_attempt: 0,
crash_limit: 3,
retention_ms: 86_400_000,
};
match when {
"" => {}
"retention_ms > 0" => c.retention_ms = 1,
"retention_ms == 0" => c.retention_ms = 0,
"attempt + 1 < max_attempts" => {
c.attempt = 0;
c.max_attempts = 25
}
"attempt + 1 >= max_attempts" => {
c.attempt = 24;
c.max_attempts = 25
}
"crash_attempt + 1 < crash_limit" => {
c.crash_attempt = 0;
c.crash_limit = 3
}
"crash_attempt + 1 >= crash_limit" => {
c.crash_attempt = 2;
c.crash_limit = 3
}
other => {
panic!("unknown guard `{other}` in state_machine.yaml — teach ctx_for about it")
}
}
c
}
fn check_row(from: &str, on: &str, to: &str, when: &str) {
let from = state(from);
let want = state(to);
let outcome = match on {
"success" => Some(Outcome::Success),
"retry" => Some(Outcome::Retry),
"skip" => Some(Outcome::Skip),
"revoke" => Some(Outcome::Revoke),
"snooze" => Some(Outcome::Snooze),
"undecodable" => Some(Outcome::Undecodable),
"rate_limited" => Some(Outcome::RateLimited),
"lease_lost" => Some(Outcome::LeaseLost),
_ => None,
};
if let Some(o) = outcome {
assert_eq!(
transition(from, o, &ctx_for(when)),
want,
"yaml row ({from:?}, {on}, when: `{when}`) disagrees with transition()"
);
return;
}
let ev = match on {
"operator_promote" => LifecycleEvent::OperatorPromote,
"schedule_due" => LifecycleEvent::ScheduleDue,
"admitted" => LifecycleEvent::Admitted,
"backoff_due" => LifecycleEvent::BackoffDue,
"checkpoint_stale" => LifecycleEvent::CheckpointStale,
"operator_retry" => LifecycleEvent::OperatorRetry,
"operator_release" => LifecycleEvent::OperatorRelease,
"operator_cancel" => LifecycleEvent::OperatorCancel,
other => panic!("unknown event `{other}` in state_machine.yaml"),
};
assert_eq!(
lifecycle_transition(from, ev),
Some(want),
"yaml row ({from:?}, {on}) disagrees with lifecycle_transition()"
);
}
#[test]
fn admission_units_group_same_kind_and_respect_bound() {
let claims = [
("a1", "mail"),
("b1", "index"),
("a2", "mail"),
("a3", "mail"),
]
.into_iter()
.map(|(id, kind)| Claim {
envelope: Envelope {
id: id.into(),
kind: kind.into(),
..Envelope::default()
},
lease_id: "lease".into(),
fence: 1,
expires_at_ms: 1,
checkpoint: Checkpoint::default(),
})
.collect();
let units = group_admission_claims(claims, 2);
let ids: Vec<Vec<&str>> = units
.iter()
.map(|unit| {
unit.claims
.iter()
.map(|claim| claim.envelope.id.as_str())
.collect()
})
.collect();
assert_eq!(ids, vec![vec!["a1", "a2"], vec!["b1"], vec!["a3"]]);
assert!(units.iter().all(|unit| unit.size() <= 2));
}
}