1#![allow(dead_code)]
6use std::time::Duration;
7
8pub use headgate_shared::{Checkpoint, MissedPolicy, Outcome, Resume};
9
10pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
11
12pub trait Task: Sized + Send + Sync + 'static {
16 const TYPE: &'static str;
17 const VERSION: u32 = 1;
19 const ALIASES: &'static [&'static str] = &[];
23
24 fn encode(&self) -> Result<Vec<u8>, CodecError>;
25 fn decode(bytes: &[u8]) -> Result<Self, CodecError>;
26
27 fn upcast(version: u32, bytes: &[u8]) -> Result<Self, CodecError> {
31 if version == Self::VERSION {
32 Self::decode(bytes)
33 } else {
34 Err(CodecError::UnknownVersion(version))
35 }
36 }
37
38 fn options() -> TaskOptions {
39 TaskOptions::default()
40 }
41}
42
43pub fn fingerprint(kind: &str, payload: &[u8]) -> String {
49 use sha2::{Digest, Sha256};
50 let mut h = Sha256::new();
51 h.update((kind.len() as u32).to_le_bytes());
52 h.update(kind.as_bytes());
53 h.update((payload.len() as u32).to_le_bytes());
54 h.update(payload);
55 let digest = h.finalize();
56 let mut out = String::with_capacity(32);
57 for b in &digest[..16] {
58 out.push_str(&format!("{b:02x}"));
59 }
60 out
61}
62
63#[derive(Debug)]
64pub enum CodecError {
65 Malformed(String),
66 UnknownVersion(u32),
67}
68impl std::fmt::Display for CodecError {
69 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
70 match self {
71 CodecError::Malformed(m) => write!(f, "malformed payload: {m}"),
72 CodecError::UnknownVersion(v) => write!(f, "no upcast path for schema version {v}"),
73 }
74 }
75}
76impl std::error::Error for CodecError {}
77
78#[derive(Default, Clone)]
79pub struct TaskOptions {
80 pub queue: Option<String>,
81 pub max_attempts: Option<u32>,
82 pub priority: Option<i32>,
83 pub timeout: Option<Duration>,
84 pub deadline: Option<Duration>,
85 pub unique_ttl: Option<Duration>,
86 pub retention: Option<Duration>,
87 pub partition_key: Option<String>,
89 pub rate_class: Option<String>,
91 pub weight: Option<u32>,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct JobResult {
104 pub schema_version: u32,
105 pub bytes: Vec<u8>,
106}
107
108pub const MAX_OPAQUE_SCHEMA_VERSION: u32 = headgate_shared::MAX_OPAQUE_SCHEMA_VERSION;
110pub const MAX_OPAQUE_BYTES: usize = 32 * 1024 * 1024;
111
112pub fn validate_opaque_value(subject: &str, value: &JobResult) -> Result<(), StoreError> {
113 use headgate_shared::OpaqueSchemaValidation;
114
115 match headgate_shared::validate_opaque_schema(value.schema_version) {
116 OpaqueSchemaValidation::Zero => Err(StoreError::Invalid(format!(
117 "{subject} schema_version must be greater than zero"
118 ))),
119 OpaqueSchemaValidation::TooLarge => Err(StoreError::Invalid(format!(
120 "{subject} schema_version exceeds the portable signed-integer limit"
121 ))),
122 OpaqueSchemaValidation::Valid if value.bytes.len() > MAX_OPAQUE_BYTES => Err(
123 StoreError::Invalid(format!("{subject} bytes exceed the 32 MiB limit")),
124 ),
125 OpaqueSchemaValidation::Valid => Ok(()),
126 }
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
133pub struct JobOutput {
134 pub schema_version: u32,
135 pub bytes: Vec<u8>,
136 pub fence: u64,
137 pub updated_at_ms: i64,
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct ProgressUpdate {
146 pub current: u64,
147 pub total: u64,
148 pub message: Option<String>,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct JobProgress {
155 pub current: u64,
156 pub total: u64,
157 pub message: Option<String>,
158 pub fence: u64,
159 pub updated_at_ms: i64,
160}
161
162pub const MAX_PROGRESS_VALUE: u64 = 9_007_199_254_740_991;
165pub const MAX_PROGRESS_MESSAGE_BYTES: usize = 512;
166
167pub fn validate_progress(update: &ProgressUpdate) -> Result<(), StoreError> {
168 if update.total == 0 {
169 return Err(StoreError::Invalid(
170 "progress total must be greater than zero".into(),
171 ));
172 }
173 if update.current > update.total {
174 return Err(StoreError::Invalid(
175 "progress current must not exceed total".into(),
176 ));
177 }
178 if update.total > MAX_PROGRESS_VALUE {
179 return Err(StoreError::Invalid(
180 "progress total exceeds the portable JSON safe-integer limit".into(),
181 ));
182 }
183 if let Some(message) = &update.message {
184 if message.len() > MAX_PROGRESS_MESSAGE_BYTES {
185 return Err(StoreError::Invalid(
186 "progress message exceeds the 512-byte limit".into(),
187 ));
188 }
189 if message.contains('\0') {
190 return Err(StoreError::Invalid(
191 "progress message must not contain NUL".into(),
192 ));
193 }
194 }
195 Ok(())
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum State {
200 Pending,
201 Scheduled,
202 Available,
203 Running,
204 Retryable,
205 Completed,
206 Archived,
207 Cancelled,
208 Quarantined,
209 Undecodable,
210 Deleted,
213}
214
215impl State {
216 pub const fn is_terminal(self) -> bool {
217 matches!(
218 self,
219 State::Completed
220 | State::Archived
221 | State::Cancelled
222 | State::Quarantined
223 | State::Undecodable
224 | State::Deleted
225 )
226 }
227}
228
229pub fn transition(from: State, on: Outcome, ctx: &TransitionCtx) -> State {
235 match (from, on) {
236 (State::Running, Outcome::Success) => {
237 if ctx.retention_ms > 0 {
238 State::Completed
239 } else {
240 State::Deleted
241 }
242 }
243 (State::Running, Outcome::Skip) => State::Archived,
244 (State::Running, Outcome::Revoke) => State::Deleted, (State::Running, Outcome::Snooze) => State::Scheduled,
246 (State::Running, Outcome::Undecodable) => State::Undecodable,
247 (State::Running, Outcome::RateLimited) => State::Available, (State::Running, Outcome::Retry) => {
249 if ctx.attempt + 1 < ctx.max_attempts {
250 State::Retryable
251 } else {
252 State::Archived
253 }
254 }
255 (State::Running, Outcome::LeaseLost) => {
257 if ctx.crash_attempt + 1 < ctx.crash_limit {
258 State::Retryable
259 } else {
260 State::Quarantined
261 }
262 }
263 (s, _) => s,
264 }
265}
266
267pub struct TransitionCtx {
268 pub attempt: u32,
269 pub max_attempts: u32,
270 pub crash_attempt: u32,
271 pub crash_limit: u32,
272 pub retention_ms: i64,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum LifecycleEvent {
281 OperatorPromote,
282 ScheduleDue,
283 Admitted,
284 BackoffDue,
285 CheckpointStale,
286 OperatorRetry,
287 OperatorRelease,
288 OperatorCancel,
289}
290
291pub fn lifecycle_transition(from: State, ev: LifecycleEvent) -> Option<State> {
294 match (from, ev) {
295 (State::Pending, LifecycleEvent::OperatorPromote) => Some(State::Available),
296 (State::Scheduled, LifecycleEvent::ScheduleDue) => Some(State::Available),
297 (State::Available, LifecycleEvent::Admitted) => Some(State::Running),
298 (State::Retryable, LifecycleEvent::BackoffDue) => Some(State::Available),
299 (State::Running, LifecycleEvent::CheckpointStale) => Some(State::Undecodable),
301 (State::Archived, LifecycleEvent::OperatorRetry) => Some(State::Available),
302 (State::Quarantined, LifecycleEvent::OperatorRelease) => Some(State::Available),
303 (
304 State::Pending | State::Available | State::Scheduled | State::Running,
305 LifecycleEvent::OperatorCancel,
306 ) => Some(State::Cancelled),
307 _ => None,
308 }
309}
310
311pub const UNIQUE_REPLACE_PAYLOAD: u32 = 1 << 0;
314pub const UNIQUE_REPLACE_SCHEDULED_AT: u32 = 1 << 1;
315pub const UNIQUE_REPLACE_PRIORITY: u32 = 1 << 2;
316pub const UNIQUE_REPLACE_MAX_ATTEMPTS: u32 = 1 << 3;
317pub const UNIQUE_REPLACE_ALL: u32 = UNIQUE_REPLACE_PAYLOAD
318 | UNIQUE_REPLACE_SCHEDULED_AT
319 | UNIQUE_REPLACE_PRIORITY
320 | UNIQUE_REPLACE_MAX_ATTEMPTS;
321
322#[derive(Clone, Debug, Default)]
323pub struct Envelope {
324 pub id: String,
325 pub kind: String,
326 pub schema_version: u32,
327 pub payload: Vec<u8>,
328 pub queue: String,
329 pub partition_key: String,
330 pub rate_class: String,
331 pub weight: u32,
335 pub fingerprint: String,
336 pub priority: i32,
337 pub attempt: u32,
338 pub crash_attempt: u32,
339 pub max_attempts: u32,
340 pub scheduled_at_ms: i64,
341 pub timeout_ms: i64,
342 pub deadline_ms: i64,
343 pub unique_key: Option<Vec<u8>>,
345 pub unique_states: u32,
347 pub unique_window_ms: i64,
352 pub unique_replace: u32,
357 pub unique_debounce_ms: i64,
360 pub unique_exclude_kind: bool,
363 pub retention_ms: i64,
365 pub periodic_schedule_id: String,
368 pub periodic_tick_ms: i64,
369 pub headers: std::collections::BTreeMap<String, String>,
375 pub tags: Vec<String>,
377 pub pending: bool,
379 pub sticky_worker: String,
383}
384
385pub const fn effective_weight(weight: u32) -> u32 {
391 headgate_shared::effective_weight(weight)
392}
393
394pub const fn effective_schema_version(version: u32) -> u32 {
395 headgate_shared::effective_schema_version(version)
396}
397
398pub const fn effective_max_attempts(max_attempts: u32) -> u32 {
399 headgate_shared::effective_max_attempts(max_attempts)
400}
401
402pub fn effective_unique_key(e: &Envelope) -> Option<Vec<u8>> {
406 let raw = e.unique_key.as_ref()?;
407 let mut out = Vec::with_capacity(raw.len() + e.kind.len() + 7);
408 out.push(1);
409 if e.unique_exclude_kind {
410 out.push(b'G');
411 } else {
412 out.push(b'K');
413 out.extend_from_slice(&(e.kind.len() as u32).to_be_bytes());
414 out.extend_from_slice(e.kind.as_bytes());
415 }
416 out.extend_from_slice(raw);
417 Some(out)
418}
419
420pub fn canonical_tags(tags: &[String]) -> Vec<String> {
422 let mut out = tags.to_vec();
423 out.sort_unstable();
424 out.dedup();
425 out
426}
427
428pub const TRACEPARENT: &str = "traceparent";
438pub const TRACESTATE: &str = "tracestate";
441
442#[derive(Clone, Debug, Default, PartialEq, Eq)]
448pub struct TraceContext {
449 pub trace_id: String,
451 pub span_id: String,
454 pub trace_flags: u8,
456 pub trace_state: String,
458}
459
460impl TraceContext {
461 pub const fn sampled(&self) -> bool {
463 self.trace_flags & 1 != 0
464 }
465
466 pub fn to_traceparent(&self) -> String {
470 format!(
471 "00-{}-{}-{:02x}",
472 self.trace_id, self.span_id, self.trace_flags
473 )
474 }
475}
476
477fn is_lower_hex(s: &str, len: usize) -> bool {
478 s.len() == len
479 && s.bytes()
480 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
481}
482
483pub fn parse_traceparent(value: &str) -> Option<TraceContext> {
500 let mut parts = value.split('-');
501 let (version, trace_id, span_id, flags) =
502 (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
503 if parts.next().is_some() {
504 return None; }
506 if version != "00"
507 || !is_lower_hex(trace_id, 32)
508 || !is_lower_hex(span_id, 16)
509 || !is_lower_hex(flags, 2)
510 {
511 return None;
512 }
513 if trace_id.bytes().all(|b| b == b'0') || span_id.bytes().all(|b| b == b'0') {
514 return None; }
516 Some(TraceContext {
517 trace_id: trace_id.to_string(),
518 span_id: span_id.to_string(),
519 trace_flags: u8::from_str_radix(flags, 16).ok()?,
520 trace_state: String::new(),
521 })
522}
523
524pub fn trace_context(headers: &std::collections::BTreeMap<String, String>) -> Option<TraceContext> {
528 let mut tc = parse_traceparent(headers.get(TRACEPARENT)?)?;
529 tc.trace_state = headers.get(TRACESTATE).cloned().unwrap_or_default();
532 Some(tc)
533}
534
535pub struct AdmitRequest {
536 pub worker: String,
537 pub lease_id: String,
538 pub queues: Vec<String>,
539 pub capacity: u32,
540 pub lease: Duration,
541 pub quantum: i64,
542}
543
544pub fn normalize_admit_request(mut req: AdmitRequest) -> Result<(AdmitRequest, i64), StoreError> {
545 req.queues = headgate_shared::normalize_queues(req.queues);
546 let lease_ms = headgate_shared::duration_millis(req.lease)
547 .ok_or_else(|| StoreError::Invalid("lease must be >= 1ms".into()))?;
548 Ok((req, lease_ms))
549}
550
551pub fn validate_ack_request(outcome: Outcome, delay_ms: Option<i64>) -> Result<(), StoreError> {
552 match headgate_shared::validate_ack(outcome, delay_ms) {
553 headgate_shared::AckValidation::LeaseLost => Err(StoreError::Invalid(
554 "lease_lost is applied by the reclaimer, not acked".into(),
555 )),
556 headgate_shared::AckValidation::SnoozeDelayRequired => {
557 Err(StoreError::Invalid("snooze requires delay_ms > 0".into()))
558 }
559 headgate_shared::AckValidation::Valid => Ok(()),
560 }
561}
562
563pub struct Claim {
564 pub envelope: Envelope,
565 pub lease_id: String,
566 pub fence: u64,
567 pub expires_at_ms: i64,
568 pub checkpoint: Checkpoint,
570}
571
572impl Claim {
573 pub fn lease_ref(&self) -> LeaseRef {
574 LeaseRef {
575 job_id: self.envelope.id.clone(),
576 lease_id: self.lease_id.clone(),
577 fence: self.fence,
578 }
579 }
580}
581
582pub struct AdmissionUnit {
589 pub claims: Vec<Claim>,
590}
591
592impl AdmissionUnit {
593 pub fn size(&self) -> usize {
594 self.claims.len()
595 }
596}
597
598pub fn group_admission_claims(claims: Vec<Claim>, max_unit_size: u32) -> Vec<AdmissionUnit> {
602 let max = max_unit_size.max(1) as usize;
603 let mut units: Vec<AdmissionUnit> = Vec::new();
604 for claim in claims {
605 if let Some(unit) = units.iter_mut().rev().find(|unit| {
606 unit.claims.len() < max
607 && unit
608 .claims
609 .first()
610 .is_some_and(|first| first.envelope.kind == claim.envelope.kind)
611 }) {
612 unit.claims.push(claim);
613 } else {
614 units.push(AdmissionUnit {
615 claims: vec![claim],
616 });
617 }
618 }
619 units
620}
621
622#[derive(Clone, Debug, PartialEq, Eq)]
628pub struct LeaseRef {
629 pub job_id: String,
630 pub lease_id: String,
631 pub fence: u64,
632}
633
634#[derive(Debug)]
635pub enum StoreError {
636 Duplicate {
639 existing_id: String,
640 replaced: bool,
644 },
645 IdConflict {
653 job_id: String,
654 },
655 Quarantined {
657 fingerprint: String,
658 },
659 Backpressure {
663 queue: String,
664 limit: u64,
665 current: u64,
666 incoming: u64,
667 },
668 LeaseRejected {
671 job_id: String,
672 },
673 Unavailable(String),
676 NotFound(String),
678 Invalid(String),
680 Backend(String),
681}
682
683impl std::fmt::Display for StoreError {
684 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
685 match self {
686 StoreError::Duplicate { existing_id, .. } => {
687 write!(f, "duplicate unique key; existing job {existing_id}")
688 }
689 StoreError::IdConflict { job_id } => write!(f, "id conflict: job {job_id}"),
690 StoreError::Quarantined { fingerprint } => {
691 write!(f, "fingerprint {fingerprint} is quarantined")
692 }
693 StoreError::Backpressure {
694 queue,
695 limit,
696 current,
697 incoming,
698 } => write!(
699 f,
700 "enqueue backpressure: queue {queue} has {current} unfinished jobs, limit {limit}, incoming {incoming}"
701 ),
702 StoreError::LeaseRejected { job_id } => write!(
703 f,
704 "lease no longer held for job {job_id}; stop work immediately"
705 ),
706 StoreError::Unavailable(m) => write!(f, "store unavailable: {m}"),
707 StoreError::NotFound(m) => write!(f, "not found: {m}"),
708 StoreError::Invalid(m) => write!(f, "invalid request: {m}"),
709 StoreError::Backend(m) => write!(f, "{m}"),
710 }
711 }
712}
713impl std::error::Error for StoreError {}
714
715#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
716pub struct Caps(pub u32);
717impl Caps {
718 pub const TRANSACTIONAL: Caps = Caps(1);
719 pub const NOTIFYING: Caps = Caps(2);
720 pub const INSPECT: Caps = Caps(4);
721 pub const fn has(self, c: Caps) -> bool {
722 self.0 & c.0 != 0
723 }
724}
725
726#[async_trait::async_trait]
733pub trait Store: Send + Sync + 'static {
734 async fn admit(&self, req: AdmitRequest) -> Result<Vec<AdmissionUnit>, StoreError>;
736 async fn ack(
742 &self,
743 lease: &LeaseRef,
744 outcome: Outcome,
745 err: Option<&str>,
746 delay_ms: Option<i64>,
747 ) -> Result<(), StoreError> {
748 self.ack_attempt_with_actual_weight(lease, outcome, err, delay_ms, &[], None)
749 .await
750 }
751 async fn ack_attempt(
758 &self,
759 lease: &LeaseRef,
760 outcome: Outcome,
761 err: Option<&str>,
762 delay_ms: Option<i64>,
763 logs: &[String],
764 ) -> Result<(), StoreError> {
765 self.ack_attempt_with_actual_weight(lease, outcome, err, delay_ms, logs, None)
766 .await
767 }
768 async fn ack_attempt_with_actual_weight(
774 &self,
775 lease: &LeaseRef,
776 outcome: Outcome,
777 err: Option<&str>,
778 delay_ms: Option<i64>,
779 logs: &[String],
780 actual_weight: Option<u32>,
781 ) -> Result<(), StoreError>;
782 fn as_result_store(&self) -> Option<&dyn ResultStore> {
785 None
786 }
787 fn as_output_store(&self) -> Option<&dyn OutputStore> {
791 None
792 }
793 fn as_progress_store(&self) -> Option<&dyn ProgressStore> {
796 None
797 }
798 async fn renew(&self, leases: &[LeaseRef], lease: Duration) -> Result<Vec<String>, StoreError>;
802 async fn enqueue(&self, batch: &[Envelope]) -> Result<(), StoreError>;
803
804 async fn checkpoint(&self, lease: &LeaseRef, cp: &Checkpoint) -> Result<(), StoreError>;
809
810 async fn reclaim_expired(&self, limit: i64) -> Result<Vec<Reclaimed>, StoreError>;
815
816 async fn promote_due(&self, limit: i64) -> Result<u64, StoreError>;
819
820 async fn evict_retained(&self, limit: i64) -> Result<u64, StoreError>;
826
827 async fn claim_duty(
831 &self,
832 name: &str,
833 holder: &str,
834 lease: Duration,
835 ) -> Result<bool, StoreError>;
836
837 async fn release_duty(&self, name: &str, holder: &str) -> Result<(), StoreError>;
840
841 fn caps(&self) -> Caps;
842 fn as_transactional(&self) -> Option<&dyn Transactional> {
845 None
846 }
847 fn as_inspect(&self) -> Option<&dyn Inspect> {
849 None
850 }
851 fn as_notifying(&self) -> Option<&dyn Notifying> {
854 None
855 }
856}
857
858#[async_trait::async_trait]
859pub trait ResultStore: Send + Sync + 'static {
860 async fn ack_success_with_result(
861 &self,
862 lease: &LeaseRef,
863 logs: &[String],
864 actual_weight: Option<u32>,
865 result: &JobResult,
866 ) -> Result<(), StoreError>;
867}
868
869#[async_trait::async_trait]
870pub trait OutputStore: Send + Sync + 'static {
871 async fn write_job_output(
872 &self,
873 lease: &LeaseRef,
874 output: &JobResult,
875 ) -> Result<JobOutput, StoreError>;
876}
877
878#[async_trait::async_trait]
879pub trait ProgressStore: Send + Sync + 'static {
880 async fn write_job_progress(
881 &self,
882 lease: &LeaseRef,
883 update: &ProgressUpdate,
884 ) -> Result<JobProgress, StoreError>;
885}
886
887#[async_trait::async_trait]
891pub trait Notifying: Store {
892 async fn wait_wakeup(
897 &self,
898 queues: &[String],
899 timeout: Duration,
900 ) -> Result<Option<String>, StoreError>;
901}
902
903#[derive(Clone, Debug)]
906pub struct Reclaimed {
907 pub job_id: String,
908 pub fingerprint: String,
909 pub crash_attempt: u32,
910 pub quarantined: bool,
911}
912
913pub trait TxHandle: Send {
917 fn as_any(&mut self) -> &mut (dyn std::any::Any + Send);
918 fn into_any(self: Box<Self>) -> Box<dyn std::any::Any + Send>;
920}
921
922#[async_trait::async_trait]
923pub trait Transactional: Store {
924 async fn begin_tx(&self) -> Result<Box<dyn TxHandle>, StoreError>;
928 async fn commit_tx(&self, tx: Box<dyn TxHandle>) -> Result<(), StoreError>;
929 async fn rollback_tx(&self, tx: Box<dyn TxHandle>) -> Result<(), StoreError>;
930 async fn enqueue_tx(&self, tx: &mut dyn TxHandle, batch: &[Envelope])
931 -> Result<(), StoreError>;
932 async fn complete_tx(&self, tx: &mut dyn TxHandle, lease: &LeaseRef) -> Result<(), StoreError> {
933 self.complete_tx_with_actual_weight(tx, lease, None).await
934 }
935 async fn complete_tx_with_actual_weight(
939 &self,
940 tx: &mut dyn TxHandle,
941 lease: &LeaseRef,
942 actual_weight: Option<u32>,
943 ) -> Result<(), StoreError>;
944 async fn claim_effect(&self, tx: &mut dyn TxHandle, key: &str) -> Result<bool, StoreError>;
949 async fn checkpoint_tx(
954 &self,
955 tx: &mut dyn TxHandle,
956 lease: &LeaseRef,
957 cp: &Checkpoint,
958 ) -> Result<(), StoreError>;
959}
960
961#[derive(Clone, Debug)]
964pub struct JobSummary {
965 pub id: String,
966 pub kind: String,
967 pub queue: String,
968 pub state: String,
969 pub schema_version: u32,
970 pub priority: i32,
971 pub attempt: u32,
972 pub crash_attempt: u32,
973 pub max_attempts: u32,
974 pub partition_key: String,
975 pub rate_class: String,
976 pub sticky_worker: String,
977 pub weight: u32,
978 pub fingerprint: String,
979 pub enqueued_at_ms: i64,
980 pub scheduled_at_ms: i64,
981 pub claimed_at_ms: Option<i64>,
983 pub periodic_schedule_id: String,
984 pub periodic_tick_ms: i64,
985 pub finalized_at_ms: Option<i64>,
986 pub payload: Option<Vec<u8>>,
989 pub headers: std::collections::BTreeMap<String, String>,
992 pub errors_json: String,
994 pub tags: Vec<String>,
995}
996
997impl JobSummary {
998 pub fn is_orphaned(&self) -> bool {
1001 self.crash_attempt > 0
1002 }
1003}
1004
1005#[derive(Clone, Debug, Default)]
1006pub struct JobFilter {
1007 pub queue: Option<String>,
1008 pub state: Option<String>,
1009 pub kind: Option<String>,
1010 pub kind_prefix: Option<String>,
1012 pub partition_key: Option<String>,
1013 pub id: Option<String>,
1014 pub fingerprint: Option<String>,
1015 pub rate_class: Option<String>,
1016 pub priority: Option<i32>,
1017 pub tags_all: Vec<String>,
1019 pub tags_any: Vec<String>,
1021}
1022
1023pub struct JobPage {
1024 pub jobs: Vec<JobSummary>,
1025 pub next_cursor: Option<String>,
1026}
1027
1028pub struct StateCounts {
1031 pub counts: Vec<(String, i64)>,
1032 pub approximate: bool,
1033}
1034
1035pub struct QueueStats {
1036 pub queue: String,
1037 pub weight: u32,
1040 pub unfinished_jobs: u64,
1043 pub max_unfinished_jobs: Option<u64>,
1046 pub by_state: Vec<(String, i64)>,
1047 pub counts_approximate: bool,
1048 pub arrival_rate: f64,
1050 pub drain_rate: f64,
1051 pub time_to_drain_ms: Option<i64>,
1053 pub oldest_available_ms: Option<i64>,
1057 pub quiet_groups: QuietGroupMetrics,
1061 pub paused: bool,
1062 pub memory_bytes: Option<u64>,
1065}
1066
1067#[derive(Clone, Debug, Default)]
1068pub struct QuietGroupMetrics {
1069 pub arrival_rate: f64,
1070 pub drain_rate: f64,
1071 pub time_to_drain_ms: Option<i64>,
1072 pub oldest_available_ms: Option<i64>,
1073 pub noisy_partitions: u32,
1075 pub approximate: bool,
1077}
1078
1079pub use headgate_shared::inspection::{age_ms, time_to_drain_ms};
1080
1081pub fn noisy_partition_keys(loads: &[(String, i64)]) -> std::collections::BTreeSet<String> {
1088 let mut out = std::collections::BTreeSet::new();
1089 if loads.len() < 2 {
1090 return out;
1091 }
1092 for (i, (key, raw_n)) in loads.iter().enumerate() {
1093 let n = (*raw_n).max(0) as u128;
1094 if n < 2 {
1095 continue;
1096 }
1097 let others: u128 = loads
1098 .iter()
1099 .enumerate()
1100 .filter(|(j, _)| *j != i)
1101 .map(|(_, (_, v))| (*v).max(0) as u128)
1102 .sum();
1103 if n * (loads.len() as u128 - 1) > 2 * others {
1104 out.insert(key.clone());
1105 }
1106 }
1107 out
1108}
1109
1110#[derive(Clone, Debug)]
1111pub struct RateClassConfig {
1112 pub name: String,
1113 pub limit: i64,
1114 pub window_ms: i64,
1115 pub burst: i64,
1116 pub paused: bool,
1118}
1119
1120pub fn validate_rate_class_config(cfg: &RateClassConfig) -> Result<(), StoreError> {
1121 if cfg.window_ms < 1 {
1122 return Err(StoreError::Invalid("window_ms must be >= 1".into()));
1123 }
1124 if cfg.limit < 0 || cfg.burst < 1 {
1125 return Err(StoreError::Invalid(
1126 "limit must be >= 0 and burst >= 1".into(),
1127 ));
1128 }
1129 Ok(())
1130}
1131
1132#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1135pub enum SaturationStrategy {
1136 #[default]
1137 Queue,
1138 Discard,
1139 CancelRunning,
1140 CancelIncoming,
1141}
1142
1143impl SaturationStrategy {
1144 pub fn as_str(self) -> &'static str {
1145 match self {
1146 Self::Queue => "queue",
1147 Self::Discard => "discard",
1148 Self::CancelRunning => "cancel_running",
1149 Self::CancelIncoming => "cancel_incoming",
1150 }
1151 }
1152}
1153
1154impl TryFrom<&str> for SaturationStrategy {
1155 type Error = StoreError;
1156
1157 fn try_from(value: &str) -> Result<Self, Self::Error> {
1158 match value {
1159 "queue" => Ok(Self::Queue),
1160 "discard" => Ok(Self::Discard),
1161 "cancel_running" => Ok(Self::CancelRunning),
1162 "cancel_incoming" => Ok(Self::CancelIncoming),
1163 _ => Err(StoreError::Invalid(format!(
1164 "unknown saturation strategy `{value}`"
1165 ))),
1166 }
1167 }
1168}
1169
1170#[derive(Clone, Debug, Eq, PartialEq)]
1171pub struct ConcurrencyLimitConfig {
1172 pub name: String,
1173 pub queue: String,
1174 pub max_concurrent: u64,
1175 pub on_saturated: SaturationStrategy,
1176}
1177
1178pub fn validate_concurrency_limit(cfg: &ConcurrencyLimitConfig) -> Result<i64, StoreError> {
1179 if cfg.name.is_empty() || cfg.queue.is_empty() {
1180 return Err(StoreError::Invalid(
1181 "name and queue must not be empty".into(),
1182 ));
1183 }
1184 if cfg.max_concurrent == 0 {
1185 return Err(StoreError::Invalid("max_concurrent must be >= 1".into()));
1186 }
1187 i64::try_from(cfg.max_concurrent)
1188 .map_err(|_| StoreError::Invalid("max_concurrent is too large".into()))
1189}
1190
1191pub fn validate_schedule_event_limit(limit: u32) -> Result<(), StoreError> {
1192 if limit == 0 || limit > SCHEDULE_EVENT_LIMIT {
1193 Err(StoreError::Invalid(
1194 "schedule event limit must be between 1 and 100".into(),
1195 ))
1196 } else {
1197 Ok(())
1198 }
1199}
1200
1201pub struct RateClassState {
1202 pub name: String,
1203 pub tokens_available: i64,
1204 pub burst: i64,
1205 pub limit_per_window: i64,
1206 pub window_ms: i64,
1207 pub jobs_waiting: i64,
1208 pub paused: bool,
1209}
1210
1211pub struct PartitionState {
1212 pub partition_key: String,
1213 pub deficit: i64,
1214 pub waiting: i64,
1215}
1216
1217pub struct QuarantineEntry {
1218 pub fingerprint: String,
1219 pub kind: String,
1220 pub crash_count: i64,
1221 pub quarantined_at_ms: i64,
1222 pub reason: String,
1223}
1224
1225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1226pub enum BlockedBy {
1227 RateClass,
1228 ConcurrencyLimit,
1229 Fairness,
1230 Quarantine,
1231 Schedule,
1232 QueuePaused,
1233}
1234
1235impl BlockedBy {
1236 pub const fn as_str(self) -> &'static str {
1237 match self {
1238 BlockedBy::RateClass => "rate_class",
1239 BlockedBy::ConcurrencyLimit => "concurrency_limit",
1240 BlockedBy::Fairness => "fairness",
1241 BlockedBy::Quarantine => "quarantine",
1242 BlockedBy::Schedule => "schedule",
1243 BlockedBy::QueuePaused => "queue_paused",
1244 }
1245 }
1246}
1247
1248pub struct AdmissionExplain {
1251 pub state: String,
1252 pub admissible: bool,
1253 pub blocked_by: Option<BlockedBy>,
1254 pub detail: Vec<(String, String)>,
1256 pub estimated_admission_ms: Option<i64>,
1258}
1259
1260pub fn evaluate_admission(facts: &headgate_shared::AdmissionFacts) -> AdmissionExplain {
1261 let evaluation = headgate_shared::evaluate_admission(facts);
1262 AdmissionExplain {
1263 state: facts.state.clone(),
1264 admissible: evaluation.admissible,
1265 blocked_by: evaluation.blocked_by.map(|blocked| match blocked {
1266 "rate_class" => BlockedBy::RateClass,
1267 "concurrency_limit" => BlockedBy::ConcurrencyLimit,
1268 "fairness" => BlockedBy::Fairness,
1269 "quarantine" => BlockedBy::Quarantine,
1270 "schedule" => BlockedBy::Schedule,
1271 "queue_paused" => BlockedBy::QueuePaused,
1272 _ => unreachable!("shared evaluator returned an unknown admission block"),
1273 }),
1274 detail: evaluation.detail,
1275 estimated_admission_ms: evaluation.estimated_admission_ms,
1276 }
1277}
1278
1279#[derive(Clone, Debug)]
1280pub struct HistoryBucket {
1281 pub at_ms: i64,
1282 pub arrived: i64,
1283 pub completed: i64,
1284}
1285
1286#[derive(Clone, Debug)]
1292pub struct Schedule {
1293 pub id: String,
1294 pub kind: String,
1295 pub payload: Vec<u8>,
1296 pub queue: String,
1297 pub partition_key: String,
1298 pub rate_class: String,
1299 pub priority: i32,
1300 pub max_attempts: u32,
1301 pub retention_ms: i64,
1302 pub spec: String,
1304 pub next_run_ms: i64,
1307 pub last_enqueued_ms: Option<i64>,
1308 pub on_missed: MissedPolicy,
1309 pub backfill_limit: u32,
1310 pub paused: bool,
1311}
1312
1313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1316pub enum ScheduleEventOutcome {
1317 Enqueued,
1318 Deduplicated,
1319 Failed,
1320 Skipped,
1321}
1322
1323impl ScheduleEventOutcome {
1324 pub fn as_str(self) -> &'static str {
1325 match self {
1326 Self::Enqueued => "enqueued",
1327 Self::Deduplicated => "deduplicated",
1328 Self::Failed => "failed",
1329 Self::Skipped => "skipped",
1330 }
1331 }
1332
1333 pub fn parse(value: &str) -> Option<Self> {
1334 match value {
1335 "enqueued" => Some(Self::Enqueued),
1336 "deduplicated" => Some(Self::Deduplicated),
1337 "failed" => Some(Self::Failed),
1338 "skipped" => Some(Self::Skipped),
1339 _ => None,
1340 }
1341 }
1342}
1343
1344pub const SCHEDULE_EVENT_LIMIT: u32 = 100;
1345
1346#[derive(Clone, Debug, Eq, PartialEq)]
1347pub struct ScheduleEvent {
1348 pub event_id: u64,
1350 pub schedule_id: String,
1351 pub tick_ms: i64,
1352 pub job_id: String,
1353 pub outcome: ScheduleEventOutcome,
1354 pub reason: String,
1356 pub recorded_at_ms: i64,
1358}
1359
1360#[derive(Clone, Debug, Default)]
1361pub struct WorkerMeta {
1362 pub worker_id: String,
1363 pub host: String,
1364 pub pid: i32,
1365 pub queues: Vec<String>,
1366 pub concurrency: u32,
1368 pub started_at_ms: i64,
1369 pub heartbeat_at_ms: i64,
1370 pub inflight: u32,
1377 pub polls: u64,
1379 pub empty_polls: u64,
1383 pub status: String,
1385 pub duties_active: bool,
1387 pub pending_command: Option<String>,
1389}
1390
1391impl WorkerMeta {
1392 pub fn utilization(&self) -> f64 {
1395 if self.concurrency == 0 {
1396 0.0
1397 } else {
1398 self.inflight as f64 / self.concurrency as f64
1399 }
1400 }
1401 pub fn empty_poll_ratio(&self) -> f64 {
1405 if self.polls == 0 {
1406 0.0
1407 } else {
1408 self.empty_polls as f64 / self.polls as f64
1409 }
1410 }
1411}
1412
1413#[derive(Clone, Debug)]
1416pub struct BulkRequest {
1417 pub id: String,
1418 pub action: String,
1419 pub queue: Option<String>,
1420 pub state: Option<String>,
1421 pub kind: Option<String>,
1422 pub partition_key: Option<String>,
1423 pub older_than_ms: Option<i64>,
1424 pub dry_run: bool,
1425}
1426
1427impl BulkRequest {
1428 pub fn has_selector(&self) -> bool {
1429 self.queue.is_some()
1430 || self.state.is_some()
1431 || self.kind.is_some()
1432 || self.partition_key.is_some()
1433 || self.older_than_ms.is_some()
1434 }
1435}
1436
1437pub fn bulk_action_states(action: &str) -> Option<&'static [&'static str]> {
1438 headgate_shared::bulk_action_states(action)
1439}
1440
1441pub fn valid_worker_command(command: &str) -> bool {
1442 headgate_shared::valid_worker_command(command)
1443}
1444
1445pub fn format_generated_id(now_ms: u64, process_id: u32, sequence: u64) -> String {
1446 headgate_shared::format_generated_id(now_ms, process_id, sequence)
1447}
1448
1449#[derive(Clone, Debug)]
1450pub struct OperationStatus {
1451 pub id: String,
1452 pub status: String,
1453 pub affected: i64,
1454 pub total_estimated: i64,
1455 pub dry_run: bool,
1456 pub error: Option<String>,
1457}
1458
1459#[async_trait::async_trait]
1463pub trait Inspect: Store {
1464 fn as_result_inspect(&self) -> Option<&dyn ResultInspect> {
1467 None
1468 }
1469 fn as_output_inspect(&self) -> Option<&dyn OutputInspect> {
1472 None
1473 }
1474 fn as_progress_inspect(&self) -> Option<&dyn ProgressInspect> {
1477 None
1478 }
1479 fn as_checkpoint_inspect(&self) -> Option<&dyn CheckpointInspect> {
1482 None
1483 }
1484 async fn get_job(
1485 &self,
1486 id: &str,
1487 include_payload: bool,
1488 ) -> Result<Option<JobSummary>, StoreError>;
1489 async fn list_jobs(
1490 &self,
1491 filter: &JobFilter,
1492 cursor: Option<&str>,
1493 limit: u32,
1494 ) -> Result<JobPage, StoreError>;
1495 async fn counts(&self, queue: Option<&str>) -> Result<StateCounts, StoreError>;
1496 async fn queue_stats(&self) -> Result<Vec<QueueStats>, StoreError>;
1497 async fn set_queue_paused(&self, queue: &str, paused: bool) -> Result<(), StoreError>;
1498 async fn set_queue_weight(&self, queue: &str, weight: u32) -> Result<(), StoreError>;
1501 async fn set_enqueue_limit(
1504 &self,
1505 queue: &str,
1506 max_unfinished_jobs: Option<u64>,
1507 ) -> Result<(), StoreError>;
1508 async fn rate_classes(&self) -> Result<Vec<RateClassState>, StoreError>;
1509 async fn upsert_rate_class(&self, cfg: &RateClassConfig) -> Result<(), StoreError>;
1512 async fn concurrency_limits(&self) -> Result<Vec<ConcurrencyLimitConfig>, StoreError>;
1513 async fn upsert_concurrency_limit(
1514 &self,
1515 cfg: &ConcurrencyLimitConfig,
1516 ) -> Result<(), StoreError>;
1517 async fn partitions(&self, queue: &str) -> Result<Vec<PartitionState>, StoreError>;
1518 async fn quarantine_list(&self) -> Result<Vec<QuarantineEntry>, StoreError>;
1519 async fn quarantine_release(&self, fingerprint: &str) -> Result<u64, StoreError>;
1523 async fn operator_retry(&self, id: &str) -> Result<(), StoreError>;
1526 async fn operator_cancel(&self, id: &str) -> Result<(), StoreError>;
1530 async fn promote_job(&self, id: &str) -> Result<(), StoreError>;
1532 async fn delete_job(&self, id: &str) -> Result<(), StoreError>;
1534 async fn explain_admission(&self, id: &str) -> Result<Option<AdmissionExplain>, StoreError>;
1535 async fn history(
1537 &self,
1538 queue: &str,
1539 since_ms: i64,
1540 bucket_ms: i64,
1541 ) -> Result<Vec<HistoryBucket>, StoreError>;
1542
1543 async fn quarantine_sweep(&self, limit: i64) -> Result<u64, StoreError>;
1548
1549 async fn reschedule_job(&self, id: &str, at_ms: i64) -> Result<(), StoreError>;
1552 async fn edit_payload(
1555 &self,
1556 id: &str,
1557 payload: &[u8],
1558 schema_version: u32,
1559 fingerprint: &str,
1560 ) -> Result<(), StoreError>;
1561
1562 async fn upsert_schedule(&self, s: &Schedule) -> Result<(), StoreError>;
1568 async fn delete_schedule(&self, id: &str) -> Result<(), StoreError>;
1569 async fn list_schedules(&self) -> Result<Vec<Schedule>, StoreError>;
1570 async fn due_schedules(&self, limit: i64) -> Result<(Vec<Schedule>, i64), StoreError>;
1572 async fn advance_schedule(
1575 &self,
1576 id: &str,
1577 from_next_run_ms: i64,
1578 to_next_run_ms: i64,
1579 ) -> Result<bool, StoreError>;
1580 async fn record_schedule_event(&self, event: &ScheduleEvent) -> Result<(), StoreError>;
1582 async fn list_schedule_events(
1584 &self,
1585 schedule_id: &str,
1586 before_event_id: Option<u64>,
1587 limit: u32,
1588 ) -> Result<Vec<ScheduleEvent>, StoreError>;
1589
1590 async fn heartbeat_worker(&self, w: &WorkerMeta) -> Result<Option<String>, StoreError>;
1598 async fn list_workers(&self, stale_after_ms: i64) -> Result<Vec<WorkerMeta>, StoreError>;
1600 async fn signal_worker(&self, worker_id: &str, command: Option<&str>)
1603 -> Result<(), StoreError>;
1604 async fn distinct_kinds(&self, limit: i64) -> Result<Vec<String>, StoreError>;
1607
1608 async fn create_operation(&self, req: &BulkRequest) -> Result<(), StoreError>;
1611 async fn get_operation(&self, id: &str) -> Result<Option<OperationStatus>, StoreError>;
1612 async fn run_pending_operations(&self, batch: i64) -> Result<u64, StoreError>;
1616
1617 async fn delete_queue(&self, queue: &str, force: bool) -> Result<Option<String>, StoreError>;
1620
1621 async fn sample_queue_memory(&self, limit: u32) -> Result<u32, StoreError>;
1624}
1625
1626#[async_trait::async_trait]
1627pub trait ResultInspect: Send + Sync + 'static {
1628 async fn get_job_result(&self, id: &str) -> Result<Option<JobResult>, StoreError>;
1631}
1632
1633#[async_trait::async_trait]
1634pub trait OutputInspect: Send + Sync + 'static {
1635 async fn get_job_output(&self, id: &str) -> Result<Option<JobOutput>, StoreError>;
1638}
1639
1640#[async_trait::async_trait]
1641pub trait ProgressInspect: Send + Sync + 'static {
1642 async fn get_job_progress(&self, id: &str) -> Result<Option<JobProgress>, StoreError>;
1645}
1646
1647#[async_trait::async_trait]
1648pub trait CheckpointInspect: Send + Sync + 'static {
1649 async fn get_job_checkpoint(&self, id: &str) -> Result<Option<Checkpoint>, StoreError>;
1652}
1653
1654pub trait Telemetry: Send + Sync + 'static {
1659 fn on_event(&self, ev: Event<'_>);
1660}
1661
1662#[non_exhaustive]
1672pub enum Event<'a> {
1673 Admitted {
1674 queue: &'a str,
1675 count: usize,
1676 },
1677 Rejected {
1678 queue: &'a str,
1679 policy: &'a str,
1680 count: usize,
1681 },
1682 Completed {
1683 kind: &'a str,
1684 ms: u64,
1685 },
1686 Quarantined {
1687 fingerprint: &'a str,
1688 crashes: u32,
1689 },
1690 Evicted {
1692 queue: &'a str,
1693 count: u64,
1694 },
1695 JobSpan {
1709 job_id: &'a str,
1710 kind: &'a str,
1711 queue: &'a str,
1712 attempt: u32,
1713 outcome: &'a str,
1716 started_at_ms: i64,
1717 ms: u64,
1718 trace: Option<&'a TraceContext>,
1719 },
1720 WorkerSaturation {
1735 worker: &'a str,
1736 inflight: u32,
1737 capacity: u32,
1738 utilization: f64,
1739 empty_poll_ratio: f64,
1740 polls: u64,
1742 empty_polls: u64,
1743 },
1744 WorkerMemory {
1747 worker: &'a str,
1748 used_bytes: u64,
1749 limit_bytes: u64,
1750 restart_requested: bool,
1751 },
1752}
1753
1754pub struct NoopTelemetry;
1755impl Telemetry for NoopTelemetry {
1756 fn on_event(&self, _: Event<'_>) {}
1757}
1758
1759pub trait Clock: Send + Sync + 'static {
1760 fn now_ms(&self) -> i64;
1761}
1762
1763pub trait IsFailure: Send + Sync + 'static {
1769 fn is_failure(&self, err: &(dyn std::error::Error + 'static)) -> bool;
1770}
1771
1772pub struct AllErrorsAreFailures;
1774impl IsFailure for AllErrorsAreFailures {
1775 fn is_failure(&self, _: &(dyn std::error::Error + 'static)) -> bool {
1776 true
1777 }
1778}
1779pub trait IdGen: Send + Sync + 'static {
1780 fn new_id(&self) -> String;
1781}
1782
1783pub fn check_kind_collisions(kinds: &[(&str, &[&str])]) -> Result<(), String> {
1789 let mut seen = std::collections::HashSet::new();
1790 for (ty, aliases) in kinds {
1791 for k in std::iter::once(ty).chain(aliases.iter()) {
1792 validate_kind(k)?;
1793 if !seen.insert(*k) {
1794 return Err(format!("kind `{k}` is registered more than once"));
1795 }
1796 }
1797 }
1798 Ok(())
1799}
1800
1801pub fn validate_kind(kind: &str) -> Result<(), String> {
1820 const RULE: &str =
1821 "1-128 characters, first [A-Za-z0-9_], rest [A-Za-z0-9_] or one of -[]<>/.:+";
1822 const EXTRA: &str = "-[]<>/.:+";
1823 fn word(c: char) -> bool {
1824 c.is_ascii_alphanumeric() || c == '_'
1825 }
1826 let ok = !kind.is_empty()
1827 && kind.len() <= 128
1828 && kind.starts_with(word)
1829 && kind.chars().skip(1).all(|c| word(c) || EXTRA.contains(c));
1830 if ok {
1831 Ok(())
1832 } else {
1833 Err(format!("invalid kind `{kind}`: {RULE}"))
1834 }
1835}
1836
1837pub fn enqueue_queue(e: &Envelope) -> &str {
1841 headgate_shared::effective_queue(&e.queue)
1842}
1843
1844pub fn same_job_content(e: &Envelope, kind: &str, fingerprint: &str, queue: &str) -> bool {
1854 e.kind == kind && e.fingerprint == fingerprint && enqueue_queue(e) == queue
1855}
1856
1857pub const MAX_ENQUEUE_BATCH_SIZE: usize = 1_000;
1858pub const MAX_JOB_PAYLOAD_BYTES: usize = 1 << 20;
1859pub const MAX_JOB_HEADERS_BYTES: usize = 64 << 10;
1860pub const MAX_JOB_HEADER_COUNT: usize = 128;
1861pub const MAX_JOB_IDENTIFIER_LEN: usize = 255;
1862pub const MAX_UNIQUE_KEY_BYTES: usize = 1 << 10;
1863pub const MAX_ENQUEUE_BYTES: usize = 16 << 20;
1864
1865pub fn validate_enqueue(batch: &[Envelope]) -> Result<(), StoreError> {
1871 if batch.len() > MAX_ENQUEUE_BATCH_SIZE {
1872 return Err(StoreError::Invalid(
1873 "enqueue batch must contain at most 1000 jobs".into(),
1874 ));
1875 }
1876 let mut seen = std::collections::HashSet::with_capacity(batch.len());
1877 let mut total_bytes = 0usize;
1878 for e in batch {
1879 if e.id.is_empty() {
1880 return Err(StoreError::Invalid("envelope id must not be empty".into()));
1881 }
1882 validate_kind(&e.kind).map_err(StoreError::Invalid)?;
1883 for (name, value) in [
1884 ("envelope id", e.id.as_str()),
1885 ("queue", e.queue.as_str()),
1886 ("partition_key", e.partition_key.as_str()),
1887 ("rate_class", e.rate_class.as_str()),
1888 ("fingerprint", e.fingerprint.as_str()),
1889 ("periodic_schedule_id", e.periodic_schedule_id.as_str()),
1890 ] {
1891 if value.len() > MAX_JOB_IDENTIFIER_LEN {
1892 return Err(StoreError::Invalid(format!(
1893 "{name} must be at most 255 bytes"
1894 )));
1895 }
1896 }
1897 if e.payload.len() > MAX_JOB_PAYLOAD_BYTES {
1898 return Err(StoreError::Invalid(
1899 "payload must be at most 1048576 bytes".into(),
1900 ));
1901 }
1902 if e.unique_key.as_ref().map_or(0, Vec::len) > MAX_UNIQUE_KEY_BYTES {
1903 return Err(StoreError::Invalid(
1904 "unique_key must be at most 1024 bytes".into(),
1905 ));
1906 }
1907 if e.headers.len() > MAX_JOB_HEADER_COUNT {
1908 return Err(StoreError::Invalid(
1909 "headers must contain at most 128 values".into(),
1910 ));
1911 }
1912 let header_bytes = e
1913 .headers
1914 .iter()
1915 .map(|(key, value)| key.len() + value.len())
1916 .sum::<usize>();
1917 if header_bytes > MAX_JOB_HEADERS_BYTES {
1918 return Err(StoreError::Invalid(
1919 "headers must total at most 65536 bytes".into(),
1920 ));
1921 }
1922 total_bytes = total_bytes.saturating_add(
1923 e.payload.len()
1924 + header_bytes
1925 + e.id.len()
1926 + e.kind.len()
1927 + e.queue.len()
1928 + e.partition_key.len()
1929 + e.rate_class.len()
1930 + e.fingerprint.len()
1931 + e.unique_key.as_ref().map_or(0, Vec::len),
1932 );
1933 if total_bytes > MAX_ENQUEUE_BYTES {
1934 return Err(StoreError::Invalid(
1935 "enqueue batch data must total at most 16777216 bytes".into(),
1936 ));
1937 }
1938 if e.timeout_ms < 0 {
1939 return Err(StoreError::Invalid("timeout_ms must be >= 0".into()));
1940 }
1941 if e.deadline_ms < 0 {
1942 return Err(StoreError::Invalid("deadline_ms must be >= 0".into()));
1943 }
1944 if e.retention_ms < 0 {
1945 return Err(StoreError::Invalid("retention_ms must be >= 0".into()));
1946 }
1947 if e.unique_window_ms < 0 {
1948 return Err(StoreError::Invalid("unique_window_ms must be >= 0".into()));
1949 }
1950 if e.unique_debounce_ms < 0 {
1951 return Err(StoreError::Invalid(
1952 "unique_debounce_ms must be >= 0".into(),
1953 ));
1954 }
1955 if e.unique_debounce_ms > 0
1956 && (e.unique_key.as_ref().is_none_or(Vec::is_empty) || e.unique_window_ms > 0)
1957 {
1958 return Err(StoreError::Invalid(
1959 "unique_debounce_ms requires lifecycle unique_key".into(),
1960 ));
1961 }
1962 if e.unique_replace & !UNIQUE_REPLACE_ALL != 0 {
1963 return Err(StoreError::Invalid(
1964 "unique_replace contains unknown fields".into(),
1965 ));
1966 }
1967 if e.unique_replace != 0 && e.unique_key.as_ref().is_none_or(Vec::is_empty) {
1968 return Err(StoreError::Invalid(
1969 "unique_replace requires unique_key".into(),
1970 ));
1971 }
1972 if e.tags.len() > 32 {
1973 return Err(StoreError::Invalid(
1974 "tags must contain at most 32 values".into(),
1975 ));
1976 }
1977 let mut tags = std::collections::HashSet::with_capacity(e.tags.len());
1978 for tag in &e.tags {
1979 if tag.is_empty() || tag.len() > 64 || !tag.is_ascii() {
1980 return Err(StoreError::Invalid(
1981 "each tag must be 1-64 ASCII bytes".into(),
1982 ));
1983 }
1984 if !tags.insert(tag) {
1985 return Err(StoreError::Invalid(
1986 "tags must not contain duplicates".into(),
1987 ));
1988 }
1989 total_bytes = total_bytes.saturating_add(tag.len());
1990 }
1991 total_bytes = total_bytes
1992 .saturating_add(e.sticky_worker.len())
1993 .saturating_add(e.periodic_schedule_id.len());
1994 if total_bytes > MAX_ENQUEUE_BYTES {
1995 return Err(StoreError::Invalid(
1996 "enqueue batch data must total at most 16777216 bytes".into(),
1997 ));
1998 }
1999 if e.pending && e.scheduled_at_ms != 0 {
2000 return Err(StoreError::Invalid(
2001 "pending jobs cannot also set scheduled_at_ms".into(),
2002 ));
2003 }
2004 if !e.sticky_worker.is_empty()
2005 && (e.sticky_worker.len() > 255 || !e.sticky_worker.is_ascii())
2006 {
2007 return Err(StoreError::Invalid(
2008 "sticky_worker must be at most 255 ASCII bytes".into(),
2009 ));
2010 }
2011 if e.periodic_schedule_id.is_empty() != (e.periodic_tick_ms == 0) || e.periodic_tick_ms < 0
2012 {
2013 return Err(StoreError::Invalid(
2014 "periodic_schedule_id and positive periodic_tick_ms must be set together".into(),
2015 ));
2016 }
2017 if !seen.insert(e.id.as_str()) {
2018 return Err(StoreError::IdConflict {
2019 job_id: e.id.clone(),
2020 });
2021 }
2022 }
2023 if batch.len() != 1
2024 && batch
2025 .iter()
2026 .any(|e| e.unique_replace != 0 || e.unique_debounce_ms > 0)
2027 {
2028 return Err(StoreError::Invalid(
2029 "unique replacement and debounce require a single-job enqueue".into(),
2030 ));
2031 }
2032 Ok(())
2033}
2034
2035#[cfg(test)]
2036mod tests {
2037 use super::*;
2038 fn ctx(a: u32, ma: u32, c: u32, cl: u32) -> TransitionCtx {
2039 TransitionCtx {
2040 attempt: a,
2041 max_attempts: ma,
2042 crash_attempt: c,
2043 crash_limit: cl,
2044 retention_ms: 86_400_000,
2045 }
2046 }
2047
2048 #[test]
2049 fn abort_is_honored_not_retried() {
2050 assert_eq!(
2052 transition(State::Running, Outcome::Skip, &ctx(0, 25, 0, 3)),
2053 State::Archived
2054 );
2055 }
2056
2057 #[test]
2058 fn fingerprint_matches_the_spec_vectors() {
2059 for (kind, payload, want) in [
2063 (
2064 "email:welcome",
2065 b"".as_slice(),
2066 "bed0eecb39af02d79d5cdc8026a9b817",
2067 ),
2068 ("", b"".as_slice(), "af5570f5a1810b7af78caf4bc70a660f"),
2069 ("a", b"bc".as_slice(), "47ea6f805c5b663e33012cd34184e139"),
2070 ("ab", b"c".as_slice(), "60014a36d7b05b0730e42a8b96faa1ff"),
2071 (
2072 "charge",
2073 [0u8, 1, 2].as_slice(),
2074 "295e280cea51e7f3978bc3195d8fd4ae",
2075 ),
2076 (
2077 "résumé:parse",
2078 b"{}".as_slice(),
2079 "a9b8c5d03aa1a0710129091fa3dc0a1d",
2080 ),
2081 ] {
2082 assert_eq!(
2083 fingerprint(kind, payload),
2084 want,
2085 "vector ({kind:?}, {payload:?})"
2086 );
2087 }
2088 assert_ne!(fingerprint("a", b"bc"), fingerprint("ab", b"c"));
2090 }
2091
2092 #[test]
2093 fn success_respects_retention() {
2094 assert_eq!(
2096 transition(State::Running, Outcome::Success, &ctx(0, 25, 0, 3)),
2097 State::Completed
2098 );
2099 let ephemeral = TransitionCtx {
2100 retention_ms: 0,
2101 ..ctx(0, 25, 0, 3)
2102 };
2103 assert_eq!(
2104 transition(State::Running, Outcome::Success, &ephemeral),
2105 State::Deleted
2106 );
2107 }
2108
2109 #[test]
2110 fn revoke_drops_entirely() {
2111 assert_eq!(
2112 transition(State::Running, Outcome::Revoke, &ctx(0, 25, 0, 3)),
2113 State::Deleted
2114 );
2115 }
2116
2117 #[test]
2118 fn crash_is_not_a_retry() {
2119 assert_eq!(
2121 transition(State::Running, Outcome::LeaseLost, &ctx(0, 25, 0, 3)),
2122 State::Retryable
2123 );
2124 assert_eq!(
2125 transition(State::Running, Outcome::LeaseLost, &ctx(0, 25, 2, 3)),
2126 State::Quarantined
2127 );
2128 assert_eq!(
2129 transition(State::Running, Outcome::Retry, &ctx(0, 25, 2, 3)),
2130 State::Retryable
2131 );
2132 }
2133
2134 #[test]
2135 fn undecodable_never_retries() {
2136 assert_eq!(
2137 transition(State::Running, Outcome::Undecodable, &ctx(0, 25, 0, 3)),
2138 State::Undecodable
2139 );
2140 }
2141
2142 #[test]
2143 fn snooze_does_not_consume_an_attempt() {
2144 assert_eq!(
2145 transition(State::Running, Outcome::Snooze, &ctx(0, 25, 0, 3)),
2146 State::Scheduled
2147 );
2148 }
2149
2150 #[test]
2151 fn rate_limited_is_not_a_failure() {
2152 assert_eq!(
2154 transition(State::Running, Outcome::RateLimited, &ctx(3, 25, 0, 3)),
2155 State::Available
2156 );
2157 }
2158
2159 #[test]
2160 fn changed_step_set_never_silently_restarts() {
2161 let cp = Checkpoint {
2164 last_completed_step: Some("transcode".into()),
2165 schema_version: 1,
2166 step_set_hash: "abc".into(),
2167 ..Default::default()
2168 };
2169 assert_eq!(cp.resumability(1, "abc"), Resume::Continue);
2170 assert_eq!(cp.resumability(2, "xyz"), Resume::Remapped);
2171 assert_eq!(cp.resumability(1, "xyz"), Resume::Undecodable);
2172 }
2173
2174 #[test]
2175 fn no_steps_means_always_resumable() {
2176 assert_eq!(
2177 Checkpoint::default().resumability(1, "anything"),
2178 Resume::Continue
2179 );
2180 }
2181
2182 #[test]
2183 fn aliases_let_a_task_be_renamed() {
2184 struct Renamed;
2185 impl Task for Renamed {
2186 const TYPE: &'static str = "notify:welcome";
2187 const ALIASES: &'static [&'static str] = &["email:welcome"];
2188 fn encode(&self) -> Result<Vec<u8>, CodecError> {
2189 Ok(vec![])
2190 }
2191 fn decode(_: &[u8]) -> Result<Self, CodecError> {
2192 Ok(Renamed)
2193 }
2194 }
2195 assert_eq!(Renamed::TYPE, "notify:welcome");
2197 assert!(Renamed::ALIASES.contains(&"email:welcome"));
2198 }
2199
2200 #[test]
2201 fn colliding_kinds_are_rejected_at_startup() {
2202 assert!(check_kind_collisions(&[("a", &[]), ("b", &[])]).is_ok());
2203 assert!(check_kind_collisions(&[("a", &[]), ("b", &["a"])]).is_err());
2205 assert!(check_kind_collisions(&[("a", &["bad kind"])]).is_err());
2208 }
2209
2210 #[test]
2211 fn kind_format_rule_is_exactly_one_rule() {
2212 for k in [
2214 "w",
2215 "k",
2216 "_",
2217 "0",
2218 "email:welcome",
2219 "notify:welcome",
2220 "a-b",
2221 "a.b",
2222 "a/b",
2223 "a+b",
2224 "a<b>",
2225 "a[b]",
2226 "Job_1",
2227 &"x".repeat(128),
2228 ] {
2229 assert_eq!(validate_kind(k), Ok(()), "should accept {k:?}");
2230 }
2231 for k in [
2233 "",
2234 &"x".repeat(129),
2235 "-lead",
2236 ".lead",
2237 ":lead",
2238 "+lead",
2239 "[lead",
2240 "a b",
2241 " a",
2242 "a\t",
2243 "a\n",
2244 "a\u{0}",
2245 "a!",
2246 "a#b",
2247 "a,b",
2248 "a(b)",
2249 "a*",
2250 "résumé:parse",
2251 "a·b",
2252 "a%b",
2253 "a\"b",
2254 ] {
2255 assert!(validate_kind(k).is_err(), "should reject {k:?}");
2256 }
2257 assert_eq!(
2259 validate_kind("a b").unwrap_err(),
2260 "invalid kind `a b`: 1-128 characters, first [A-Za-z0-9_], \
2261 rest [A-Za-z0-9_] or one of -[]<>/.:+"
2262 );
2263 }
2264
2265 #[test]
2266 fn enqueue_validation_is_one_function_for_every_backend() {
2267 let ok = Envelope {
2268 id: "a".into(),
2269 kind: "w".into(),
2270 ..Default::default()
2271 };
2272 assert!(validate_enqueue(std::slice::from_ref(&ok)).is_ok());
2273 assert!(
2274 validate_enqueue(&[Envelope {
2275 sticky_worker: "w".repeat(255),
2276 ..ok.clone()
2277 }])
2278 .is_ok()
2279 );
2280 for sticky_worker in ["é".to_string(), "w".repeat(256)] {
2281 assert!(matches!(
2282 validate_enqueue(&[Envelope {
2283 sticky_worker,
2284 ..ok.clone()
2285 }]),
2286 Err(StoreError::Invalid(_))
2287 ));
2288 }
2289 let no_id = Envelope {
2290 id: String::new(),
2291 ..ok.clone()
2292 };
2293 assert!(matches!(
2294 validate_enqueue(&[no_id]),
2295 Err(StoreError::Invalid(_))
2296 ));
2297 let bad_kind = Envelope {
2298 kind: "bad kind".into(),
2299 ..ok.clone()
2300 };
2301 assert!(matches!(
2302 validate_enqueue(&[bad_kind]),
2303 Err(StoreError::Invalid(_))
2304 ));
2305 let neg = Envelope {
2306 unique_window_ms: -1,
2307 ..ok.clone()
2308 };
2309 assert!(matches!(
2310 validate_enqueue(&[neg]),
2311 Err(StoreError::Invalid(_))
2312 ));
2313 match validate_enqueue(&[ok.clone(), ok.clone()]) {
2315 Err(StoreError::IdConflict { job_id }) => assert_eq!(job_id, "a"),
2316 other => panic!("want IdConflict, got {other:?}"),
2317 }
2318
2319 let replace_without_key = Envelope {
2320 unique_replace: UNIQUE_REPLACE_PRIORITY,
2321 ..ok.clone()
2322 };
2323 assert!(matches!(
2324 validate_enqueue(&[replace_without_key]),
2325 Err(StoreError::Invalid(_))
2326 ));
2327 let replace_unknown = Envelope {
2328 unique_key: Some(b"k".to_vec()),
2329 unique_replace: UNIQUE_REPLACE_ALL | (1 << 8),
2330 ..ok.clone()
2331 };
2332 assert!(matches!(
2333 validate_enqueue(&[replace_unknown]),
2334 Err(StoreError::Invalid(_))
2335 ));
2336 let replace = Envelope {
2337 unique_key: Some(b"k".to_vec()),
2338 unique_replace: UNIQUE_REPLACE_PRIORITY,
2339 ..ok.clone()
2340 };
2341 assert!(validate_enqueue(std::slice::from_ref(&replace)).is_ok());
2342 let second = Envelope {
2343 id: "b".into(),
2344 ..ok
2345 };
2346 assert!(matches!(
2347 validate_enqueue(&[replace, second]),
2348 Err(StoreError::Invalid(_))
2349 ));
2350
2351 for invalid in [
2352 Envelope {
2353 id: "payload".into(),
2354 kind: "w".into(),
2355 payload: vec![0; MAX_JOB_PAYLOAD_BYTES + 1],
2356 ..Default::default()
2357 },
2358 Envelope {
2359 id: "timeout".into(),
2360 kind: "w".into(),
2361 timeout_ms: -1,
2362 ..Default::default()
2363 },
2364 Envelope {
2365 id: "deadline".into(),
2366 kind: "w".into(),
2367 deadline_ms: -1,
2368 ..Default::default()
2369 },
2370 Envelope {
2371 id: "retention".into(),
2372 kind: "w".into(),
2373 retention_ms: -1,
2374 ..Default::default()
2375 },
2376 ] {
2377 assert!(matches!(
2378 validate_enqueue(&[invalid]),
2379 Err(StoreError::Invalid(_))
2380 ));
2381 }
2382 let oversized = (0..=MAX_ENQUEUE_BATCH_SIZE)
2383 .map(|index| Envelope {
2384 id: format!("job-{index}"),
2385 kind: "w".into(),
2386 ..Default::default()
2387 })
2388 .collect::<Vec<_>>();
2389 assert!(matches!(
2390 validate_enqueue(&oversized),
2391 Err(StoreError::Invalid(_))
2392 ));
2393 }
2394
2395 #[test]
2396 fn omitted_envelope_weight_normalizes_to_one_without_erasing_real_costs() {
2397 assert_eq!(effective_weight(0), 1);
2401 assert_eq!(effective_weight(1), 1);
2402 assert_eq!(effective_weight(7), 7);
2403 }
2404
2405 #[test]
2406 fn id_conflict_compares_kind_fingerprint_and_queue() {
2407 let e = Envelope {
2409 id: "a".into(),
2410 kind: "w".into(),
2411 fingerprint: fingerprint("w", b"{}"),
2412 payload: b"{}".to_vec(),
2413 ..Default::default()
2414 };
2415 assert_eq!(enqueue_queue(&e), "default");
2417 assert!(same_job_content(
2418 &e,
2419 "w",
2420 &fingerprint("w", b"{}"),
2421 "default"
2422 ));
2423 assert!(!same_job_content(
2424 &e,
2425 "w",
2426 &fingerprint("w", b"{\"a\":1}"),
2427 "default"
2428 ));
2429 assert!(!same_job_content(
2430 &e,
2431 "v",
2432 &fingerprint("w", b"{}"),
2433 "default"
2434 ));
2435 assert!(!same_job_content(
2436 &e,
2437 "w",
2438 &fingerprint("w", b"{}"),
2439 "other"
2440 ));
2441 }
2442
2443 #[test]
2444 fn id_conflict_message_is_the_uniform_one() {
2445 assert_eq!(
2446 StoreError::IdConflict {
2447 job_id: "c1".into()
2448 }
2449 .to_string(),
2450 "id conflict: job c1"
2451 );
2452 }
2453
2454 #[test]
2460 fn traceparent_parses_exactly_the_w3c_shape() {
2461 let tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
2462 let tc = parse_traceparent(tp).expect("the canonical W3C example must parse");
2463 assert_eq!(tc.trace_id, "4bf92f3577b34da6a3ce929d0e0e4736");
2464 assert_eq!(tc.span_id, "00f067aa0ba902b7");
2465 assert_eq!(tc.trace_flags, 1);
2466 assert!(tc.sampled());
2467 assert_eq!(tc.to_traceparent(), tp);
2469 let un = parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")
2471 .expect("unsampled is still a valid parent");
2472 assert!(!un.sampled());
2473 assert_eq!(un.trace_flags, 0);
2474 }
2475
2476 #[test]
2477 fn an_invalid_traceparent_is_absent_never_an_error() {
2478 for bad in [
2481 "", "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", ] {
2495 assert_eq!(parse_traceparent(bad), None, "must read as ABSENT: {bad:?}");
2496 }
2497 }
2498
2499 #[test]
2500 fn trace_context_reads_the_two_reserved_headers() {
2501 let mut h = std::collections::BTreeMap::new();
2502 assert_eq!(trace_context(&h), None); h.insert(
2504 TRACEPARENT.into(),
2505 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
2506 );
2507 h.insert(TRACESTATE.into(), "vendor=opaque,other=1".to_string());
2508 let tc = trace_context(&h).expect("valid parent");
2509 assert_eq!(tc.trace_state, "vendor=opaque,other=1");
2511 h.insert(TRACEPARENT.into(), "nonsense".to_string());
2514 assert_eq!(trace_context(&h), None);
2515 h.remove(TRACEPARENT);
2518 h.insert(
2519 "Traceparent".into(),
2520 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
2521 );
2522 assert_eq!(trace_context(&h), None);
2523 }
2524
2525 #[test]
2526 fn worker_saturation_never_divides_by_zero() {
2527 let idle = WorkerMeta {
2530 concurrency: 0,
2531 inflight: 0,
2532 polls: 0,
2533 ..Default::default()
2534 };
2535 assert_eq!(idle.utilization(), 0.0);
2536 assert_eq!(idle.empty_poll_ratio(), 0.0);
2537 let busy = WorkerMeta {
2538 concurrency: 8,
2539 inflight: 6,
2540 polls: 10,
2541 empty_polls: 4,
2542 ..Default::default()
2543 };
2544 assert_eq!(busy.utilization(), 0.75);
2545 assert_eq!(busy.empty_poll_ratio(), 0.4);
2546 }
2547
2548 #[test]
2549 fn quiet_group_noise_detection_is_skew_based_and_work_conserving() {
2550 let loads = |xs: &[(&str, i64)]| {
2551 xs.iter()
2552 .map(|(k, n)| ((*k).to_string(), *n))
2553 .collect::<Vec<_>>()
2554 };
2555 assert!(
2556 noisy_partition_keys(&loads(&[("only", 500)])).is_empty(),
2557 "a lone partition has nobody to disturb and must stay visible"
2558 );
2559 assert!(
2560 noisy_partition_keys(&loads(&[("a", 1), ("b", 0)])).is_empty(),
2561 "one claim is not enough evidence to call a tenant noisy"
2562 );
2563 assert!(
2564 noisy_partition_keys(&loads(&[("a", 4), ("b", 2)])).is_empty(),
2565 "exactly twice the peer mean is the boundary, not over it"
2566 );
2567 let got = noisy_partition_keys(&loads(&[("flood", 9), ("quiet-a", 1), ("quiet-b", 2)]));
2568 assert_eq!(got.into_iter().collect::<Vec<_>>(), vec!["flood"]);
2569 assert!(
2570 noisy_partition_keys(&loads(&[("a", 3), ("b", 3), ("c", 3)])).is_empty(),
2571 "balanced busy tenants are not noisy neighbours"
2572 );
2573 let got = noisy_partition_keys(&loads(&[("negative", -7), ("flood", 2)]));
2574 assert!(
2575 got.contains("flood") && !got.contains("negative"),
2576 "a corrupt negative counter is treated as zero, never inverted"
2577 );
2578 }
2579
2580 #[test]
2581 fn saturation_strategy_spellings_are_one_cross_backend_contract() {
2582 for (raw, want) in [
2583 ("queue", SaturationStrategy::Queue),
2584 ("discard", SaturationStrategy::Discard),
2585 ("cancel_running", SaturationStrategy::CancelRunning),
2586 ("cancel_incoming", SaturationStrategy::CancelIncoming),
2587 ] {
2588 let got = SaturationStrategy::try_from(raw).unwrap();
2589 assert_eq!(got, want);
2590 assert_eq!(got.as_str(), raw);
2591 }
2592 assert!(matches!(
2593 SaturationStrategy::try_from("cancel_newest"),
2594 Err(StoreError::Invalid(msg)) if msg == "unknown saturation strategy `cancel_newest`"
2595 ));
2596 }
2597
2598 #[test]
2599 fn terminal_states_are_terminal() {
2600 for s in [
2601 State::Completed,
2602 State::Archived,
2603 State::Cancelled,
2604 State::Quarantined,
2605 State::Undecodable,
2606 State::Deleted,
2607 ] {
2608 assert!(s.is_terminal());
2609 assert_eq!(transition(s, Outcome::Retry, &ctx(0, 25, 0, 3)), s);
2610 for ev in [
2611 LifecycleEvent::ScheduleDue,
2612 LifecycleEvent::Admitted,
2613 LifecycleEvent::BackoffDue,
2614 LifecycleEvent::CheckpointStale,
2615 ] {
2616 assert_eq!(
2617 lifecycle_transition(s, ev),
2618 None,
2619 "{s:?} must never auto-transition"
2620 );
2621 }
2622 }
2623 }
2624
2625 #[test]
2632 fn yaml_and_code_agree_row_for_row() {
2633 let yaml = include_str!("../../../conformance/state_machine.yaml");
2634 let mut rows = 0usize;
2635 for line in yaml.lines() {
2636 let line = line.trim();
2637 let Some(body) = line.strip_prefix("- {").and_then(|r| r.split('}').next()) else {
2638 continue;
2639 };
2640 let mut from = "";
2641 let mut on = "";
2642 let mut to = "";
2643 let mut when = "";
2644 for field in split_top_level(body) {
2645 let (k, v) = field.split_once(':').expect("field");
2646 let v = v.trim().trim_matches('"');
2647 match k.trim() {
2648 "from" => from = v,
2649 "on" => on = v,
2650 "to" => to = v,
2651 "when" => when = v,
2652 "note" => {}
2653 other => panic!("unknown key `{other}` in state_machine.yaml"),
2654 }
2655 }
2656 rows += 1;
2657 check_row(from, on, to, when);
2658 }
2659 assert_eq!(
2662 rows, 22,
2663 "state_machine.yaml row count changed; update the table AND its scenarios"
2664 );
2665 }
2666
2667 fn split_top_level(s: &str) -> Vec<&str> {
2669 let mut out = Vec::new();
2670 let mut depth_quote = false;
2671 let mut start = 0;
2672 for (i, c) in s.char_indices() {
2673 match c {
2674 '"' => depth_quote = !depth_quote,
2675 ',' if !depth_quote => {
2676 out.push(&s[start..i]);
2677 start = i + 1;
2678 }
2679 _ => {}
2680 }
2681 }
2682 out.push(&s[start..]);
2683 out
2684 }
2685
2686 fn state(name: &str) -> State {
2687 match name {
2688 "pending" => State::Pending,
2689 "scheduled" => State::Scheduled,
2690 "available" => State::Available,
2691 "running" => State::Running,
2692 "retryable" => State::Retryable,
2693 "completed" => State::Completed,
2694 "archived" => State::Archived,
2695 "cancelled" => State::Cancelled,
2696 "quarantined" => State::Quarantined,
2697 "undecodable" => State::Undecodable,
2698 "deleted" => State::Deleted,
2699 other => panic!("unknown state `{other}` in state_machine.yaml"),
2700 }
2701 }
2702
2703 fn ctx_for(when: &str) -> TransitionCtx {
2705 let mut c = TransitionCtx {
2706 attempt: 0,
2707 max_attempts: 25,
2708 crash_attempt: 0,
2709 crash_limit: 3,
2710 retention_ms: 86_400_000,
2711 };
2712 match when {
2713 "" => {}
2714 "retention_ms > 0" => c.retention_ms = 1,
2715 "retention_ms == 0" => c.retention_ms = 0,
2716 "attempt + 1 < max_attempts" => {
2717 c.attempt = 0;
2718 c.max_attempts = 25
2719 }
2720 "attempt + 1 >= max_attempts" => {
2721 c.attempt = 24;
2722 c.max_attempts = 25
2723 }
2724 "crash_attempt + 1 < crash_limit" => {
2725 c.crash_attempt = 0;
2726 c.crash_limit = 3
2727 }
2728 "crash_attempt + 1 >= crash_limit" => {
2729 c.crash_attempt = 2;
2730 c.crash_limit = 3
2731 }
2732 other => {
2733 panic!("unknown guard `{other}` in state_machine.yaml — teach ctx_for about it")
2734 }
2735 }
2736 c
2737 }
2738
2739 fn check_row(from: &str, on: &str, to: &str, when: &str) {
2740 let from = state(from);
2741 let want = state(to);
2742 let outcome = match on {
2743 "success" => Some(Outcome::Success),
2744 "retry" => Some(Outcome::Retry),
2745 "skip" => Some(Outcome::Skip),
2746 "revoke" => Some(Outcome::Revoke),
2747 "snooze" => Some(Outcome::Snooze),
2748 "undecodable" => Some(Outcome::Undecodable),
2749 "rate_limited" => Some(Outcome::RateLimited),
2750 "lease_lost" => Some(Outcome::LeaseLost),
2751 _ => None,
2752 };
2753 if let Some(o) = outcome {
2754 assert_eq!(
2755 transition(from, o, &ctx_for(when)),
2756 want,
2757 "yaml row ({from:?}, {on}, when: `{when}`) disagrees with transition()"
2758 );
2759 return;
2760 }
2761 let ev = match on {
2762 "operator_promote" => LifecycleEvent::OperatorPromote,
2763 "schedule_due" => LifecycleEvent::ScheduleDue,
2764 "admitted" => LifecycleEvent::Admitted,
2765 "backoff_due" => LifecycleEvent::BackoffDue,
2766 "checkpoint_stale" => LifecycleEvent::CheckpointStale,
2767 "operator_retry" => LifecycleEvent::OperatorRetry,
2768 "operator_release" => LifecycleEvent::OperatorRelease,
2769 "operator_cancel" => LifecycleEvent::OperatorCancel,
2770 other => panic!("unknown event `{other}` in state_machine.yaml"),
2771 };
2772 assert_eq!(
2773 lifecycle_transition(from, ev),
2774 Some(want),
2775 "yaml row ({from:?}, {on}) disagrees with lifecycle_transition()"
2776 );
2777 }
2778
2779 #[test]
2780 fn admission_units_group_same_kind_and_respect_bound() {
2781 let claims = [
2782 ("a1", "mail"),
2783 ("b1", "index"),
2784 ("a2", "mail"),
2785 ("a3", "mail"),
2786 ]
2787 .into_iter()
2788 .map(|(id, kind)| Claim {
2789 envelope: Envelope {
2790 id: id.into(),
2791 kind: kind.into(),
2792 ..Envelope::default()
2793 },
2794 lease_id: "lease".into(),
2795 fence: 1,
2796 expires_at_ms: 1,
2797 checkpoint: Checkpoint::default(),
2798 })
2799 .collect();
2800 let units = group_admission_claims(claims, 2);
2801 let ids: Vec<Vec<&str>> = units
2802 .iter()
2803 .map(|unit| {
2804 unit.claims
2805 .iter()
2806 .map(|claim| claim.envelope.id.as_str())
2807 .collect()
2808 })
2809 .collect();
2810 assert_eq!(ids, vec![vec!["a1", "a2"], vec!["b1"], vec!["a3"]]);
2811 assert!(units.iter().all(|unit| unit.size() <= 2));
2812 }
2813}