aion_server/worker/queue_service/taxonomy.rs
1//! The queue-service taxonomy and its typed dispatch failure.
2//!
3//! Every reason in [`QueueServiceReason`] is emitted by a real path at the
4//! bridge seam — six from the selection census ([`super::census::classify`])
5//! and [`QueueServiceReason::Saturated`] from the delivery-backpressure path
6//! ([`super::delivery`]). A variant that could not fire would be a name that
7//! does not mean what it says.
8//!
9//! R1 defined four of these. [`QueueServiceReason::PollersUnreachable`] is the
10//! fifth: a pool whose workers all serve the activity but have all lost
11//! dispatch eligibility used to classify as served — because the census counts
12//! compatible workers without an eligibility filter — and so parked with
13//! nothing published about why.
14//!
15//! [`QueueServiceReason::PollersAtCapacity`] is the sixth, and the only one
16//! that describes a fleet with nothing wrong with it: every compatible worker
17//! is connected, serving the activity, and already running as many activities
18//! as it advertised it would. It exists because the alternative was to report a
19//! busy pool with one of the five sentences above, all of which are false about
20//! it — and one of which advises the opposite of what would actually help.
21//!
22//! [`QueueServiceReason::PollersCapacityUnannounced`] is the seventh, and it
23//! exists for the same reason the sixth does: a state that is real, that no
24//! existing sentence is true of, and that would otherwise be reported as a
25//! busy pool. A liminal worker cannot state its capacity in its registration
26//! frame — `liminal::protocol::WorkerRegistration` is a published wire type
27//! with no such field — so it registers with capacity UNKNOWN and announces the
28//! number a frame later. Selection treats unknown as full, which is the safe
29//! direction and the wrong WORD: a pool of workers that have not yet said what
30//! they can take is not a pool of workers that are busy, and the two have
31//! opposite remedies. Waiting fixes the busy one; only the worker announcing
32//! (or being replaced) fixes this one, and starting another worker of the same
33//! build fixes nothing at all.
34
35use std::fmt;
36use std::time::Duration;
37
38use super::census::PoolCensus;
39
40/// Failure-reason prefix classifying a dispatch failure as non-retryable.
41const TERMINAL_PREFIX: &str = "terminal:";
42/// Failure-reason prefix classifying a dispatch failure as retryable, matching
43/// the vocabulary `aion`'s retry executor reads (`is_retryable_reason`).
44const RETRYABLE_PREFIX: &str = "retryable:";
45/// Stable tag identifying a queue-service refusal inside a dispatch failure.
46const UNAVAILABLE_TAG: &str = "WORKER_UNAVAILABLE";
47/// Separator between the machine-readable head and the human tail.
48const TAIL_SEPARATOR: char = '—';
49/// Field value standing in for an absent optional field.
50const ABSENT: &str = "none";
51
52/// Why a `(namespace, task_queue, activity_type)` address is not being served.
53///
54/// The names are R1's, verbatim, and are the strings that reach logs, dispatch
55/// failures, and the queue-service accessor.
56#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub enum QueueServiceReason {
58 /// No deployed `.v4` contract declares this task queue at all. Structural:
59 /// no worker can ever legitimately arrive for it, so it refuses
60 /// unconditionally under every policy.
61 NoQueueDeclaration,
62 /// The queue is declared (or its declaration is unknowable) and no worker
63 /// is connected for the pool at all.
64 NoLivePollers,
65 /// Workers are connected for the pool but none of them covers this
66 /// activity type, or none of them sits on the dispatch's pinned node.
67 PollersIncompatible,
68 /// A compatible worker is connected and its intake is refusing work: the
69 /// task could not be handed over before the schedule-to-start clock
70 /// expired.
71 Saturated,
72 /// Workers are connected and DO serve this activity on this node, but the
73 /// liveness verdict has withdrawn dispatch eligibility from all of them
74 /// after losing reachability.
75 ///
76 /// 🔴 Distinct from [`Self::PollersIncompatible`] because the remedies are
77 /// close to opposite. Incompatible pollers need a worker deployed that
78 /// covers the activity; these workers already cover it, and deploying
79 /// another changes nothing — the fault is on the wire to the ones already
80 /// there. Distinct from an opening probation, which is not reported at all
81 /// because it clears itself within seconds.
82 PollersUnreachable,
83 /// Workers are connected, DO serve this activity on this node, and every
84 /// one of them is already running every activity it advertised it would run
85 /// at once. The work is queued and starts as capacity frees.
86 ///
87 /// 🔴 The one reason in this taxonomy that describes a HEALTHY fleet, which
88 /// is why it could not be folded into any of the others. Every existing
89 /// sentence here would be a lie about a busy pool: nothing is missing, no
90 /// wire has failed, no deployment is absent. Its remedy is the exact
91 /// opposite of [`Self::PollersUnreachable`]'s — starting another worker
92 /// helps here and does not help there — and two opposite pieces of advice
93 /// cannot share one name. ([`Self::NoLivePollers`] shares the remedy but not
94 /// the diagnosis: there the pool is empty, here it is full and working.)
95 PollersAtCapacity,
96 /// Workers are connected and DO serve this activity on this node, at least
97 /// one of them has not announced how much work it can take, and no worker
98 /// whose capacity IS known has a free slot — so the server has nothing it
99 /// can honestly select against.
100 ///
101 /// Reported on `> 0` rather than on the whole population, and it OUTRANKS
102 /// [`Self::PollersAtCapacity`] in [`super::census::classify`]. Both
103 /// conditions can hold of one pool at once, only one reason can be
104 /// published, and this is the one that may need an operator: a busy pool
105 /// clears itself as work completes, a mute worker does not.
106 ///
107 /// 🔴 NOT a quieter [`Self::PollersAtCapacity`], and the distinction is the
108 /// whole reason this variant exists. Selection excludes an unannounced
109 /// worker by treating unknown capacity as full — the safe direction, since
110 /// the alternative is inventing a number for a process that is about to
111 /// state its own — but reporting that exclusion as "at capacity" would tell
112 /// an operator their fleet was busy when it is idle and mute. The advice
113 /// attached to a busy pool ("start another worker, or raise concurrency")
114 /// is actively wrong here: another worker of the same build will be just as
115 /// silent.
116 ///
117 /// Ordinarily invisible. It is true for exactly one round trip after a
118 /// liminal worker registers, and a dispatch has to miss inside that window
119 /// to see it. Persisting past that is the signal: the worker is an older
120 /// build that does not announce, or its announcement was lost.
121 PollersCapacityUnannounced,
122}
123
124impl QueueServiceReason {
125 /// Every variant, and the single source of that set.
126 ///
127 /// 🔴 [`Self::parse`] and the round-trip test both read THIS, so a variant
128 /// missing here is a variant that cannot be parsed back from its own
129 /// spelling — a wire value the server writes and then fails to read. The
130 /// exhaustive `match` in [`Self::as_str`] forces a new variant to be given
131 /// a spelling, but nothing forces it into this list; `all_lists_every_variant`
132 /// is what does, by matching exhaustively so the compiler refuses a variant
133 /// this list has not been told about.
134 pub const ALL: [Self; 7] = [
135 Self::NoQueueDeclaration,
136 Self::NoLivePollers,
137 Self::PollersIncompatible,
138 Self::PollersUnreachable,
139 Self::PollersAtCapacity,
140 Self::PollersCapacityUnannounced,
141 Self::Saturated,
142 ];
143
144 /// The canonical wire/log spelling.
145 #[must_use]
146 pub const fn as_str(self) -> &'static str {
147 match self {
148 Self::NoQueueDeclaration => "NO_QUEUE_DECLARATION",
149 Self::NoLivePollers => "NO_LIVE_POLLERS",
150 Self::PollersIncompatible => "POLLERS_INCOMPATIBLE",
151 Self::Saturated => "SATURATED",
152 Self::PollersUnreachable => "POLLERS_UNREACHABLE",
153 Self::PollersAtCapacity => "POLLERS_AT_CAPACITY",
154 Self::PollersCapacityUnannounced => "POLLERS_CAPACITY_UNANNOUNCED",
155 }
156 }
157
158 /// Whether the unservability is structural — provable from the deployed
159 /// contract records alone, unchangeable by waiting or retrying.
160 #[must_use]
161 pub const fn is_structural(self) -> bool {
162 matches!(self, Self::NoQueueDeclaration)
163 }
164
165 /// The operator-facing sentence for this reason at `address`: what has to
166 /// be fixed, in words.
167 ///
168 /// This is the single source of that sentence. The dispatch refusal's human
169 /// tail ([`WorkerUnavailable::explain`]) and the describe projection's
170 /// `detail` both read it here, so a queue that is unserved cannot be
171 /// explained one way in a refusal and another way in a read.
172 #[must_use]
173 pub fn explain(self, address: &ServiceAddress) -> String {
174 let ServiceAddress {
175 namespace,
176 task_queue,
177 activity_type,
178 node,
179 } = address;
180 let node = node
181 .as_ref()
182 .map_or_else(String::new, |node| format!(" pinned to node `{node}`"));
183 match self {
184 Self::NoQueueDeclaration => format!(
185 "no deployed contract declares task queue `{task_queue}`, \
186 so activity `{activity_type}` in namespace `{namespace}` \
187 can never be served{node}"
188 ),
189 Self::NoLivePollers => format!(
190 "no worker is connected for `{namespace}`/`{task_queue}`, \
191 so activity `{activity_type}` is unserved{node}"
192 ),
193 Self::PollersIncompatible => format!(
194 "workers are connected for `{namespace}`/`{task_queue}` but \
195 none serves activity `{activity_type}`{node}"
196 ),
197 Self::Saturated => format!(
198 "a worker serving `{namespace}`/`{task_queue}` activity \
199 `{activity_type}`{node} would not accept the task before the \
200 schedule-to-start timeout"
201 ),
202 Self::PollersUnreachable => format!(
203 "every worker serving `{namespace}`/`{task_queue}` activity \
204 `{activity_type}`{node} has lost dispatch eligibility because \
205 the server cannot reach it. This is not an empty pool and not \
206 an unserved activity — starting another worker will not help. \
207 Read the liveness verdict for these workers; the reachability \
208 lines carry the worker ids and the failure"
209 ),
210 Self::PollersAtCapacity => format!(
211 "every worker serving `{namespace}`/`{task_queue}` activity \
212 `{activity_type}`{node} is running every activity it advertised it \
213 would run at once. Nothing has failed: the workers are connected, \
214 they serve this activity, and this dispatch is queued behind real \
215 work. It starts as soon as one of them frees a slot. If the queue \
216 is persistently behind, start another worker for it or raise the \
217 concurrency of the ones already there — which is the opposite of \
218 what an unreachable pool needs"
219 ),
220 Self::PollersCapacityUnannounced => format!(
221 "at least one worker serving `{namespace}`/`{task_queue}` activity \
222 `{activity_type}`{node} is connected and healthy at the transport but \
223 has not announced how much work it can take, and no worker whose \
224 capacity IS known has a free slot. The server is holding this dispatch \
225 rather than guessing a number on a worker's behalf. This is normally \
226 true for a fraction of a second after a worker connects. If it persists \
227 past one round trip, that worker is an older build that does not \
228 announce its capacity, or its announcement was lost — read that \
229 worker's own log, where the failure is at ERROR level. Starting another \
230 worker of the same build will NOT help, and the silent worker is not a \
231 busy one: it is idle"
232 ),
233 }
234 }
235
236 /// Read a reason back from its canonical spelling.
237 #[must_use]
238 pub fn parse(text: &str) -> Option<Self> {
239 Self::ALL.into_iter().find(|reason| reason.as_str() == text)
240 }
241}
242
243impl fmt::Display for QueueServiceReason {
244 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245 formatter.write_str(self.as_str())
246 }
247}
248
249/// Which of the two never-conflated service clocks expired.
250#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
251pub enum ExpiredClock {
252 /// Time spent waiting for ANY compatible worker to exist.
253 ServiceAvailability,
254 /// Time spent between dispatch and a live compatible worker accepting the
255 /// task into its intake.
256 ScheduleToStart,
257}
258
259impl ExpiredClock {
260 /// The canonical wire/log spelling.
261 #[must_use]
262 pub const fn as_str(self) -> &'static str {
263 match self {
264 Self::ServiceAvailability => "service_availability",
265 Self::ScheduleToStart => "schedule_to_start",
266 }
267 }
268
269 /// Read a clock back from its canonical spelling.
270 #[must_use]
271 pub fn parse(text: &str) -> Option<Self> {
272 [Self::ServiceAvailability, Self::ScheduleToStart]
273 .into_iter()
274 .find(|clock| clock.as_str() == text)
275 }
276}
277
278impl fmt::Display for ExpiredClock {
279 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
280 formatter.write_str(self.as_str())
281 }
282}
283
284/// The pool address a dispatch could not be served at.
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct ServiceAddress {
287 /// Correctness/isolation boundary of the dispatch.
288 pub namespace: String,
289 /// Pool selector within the namespace.
290 pub task_queue: String,
291 /// Activity type the dispatch needs served.
292 pub activity_type: String,
293 /// Optional within-pool node pin carried by the dispatch.
294 pub node: Option<String>,
295}
296
297/// A typed dispatch refusal: the taxonomy reason, which clock expired (if
298/// any), how long the dispatch waited, and the live poller census behind the
299/// verdict. Never a generic timeout, never a bare string.
300#[derive(Clone, Debug, Eq, PartialEq)]
301pub struct WorkerUnavailable {
302 /// Taxonomy state of the queue at refusal time.
303 pub reason: QueueServiceReason,
304 /// The clock whose expiry produced the refusal, or `None` for a structural
305 /// refusal (which needs no clock).
306 pub clock: Option<ExpiredClock>,
307 /// How long this dispatch waited before refusing.
308 pub waited: Duration,
309 /// Pool address that could not be served.
310 pub address: ServiceAddress,
311 /// Live poller census at refusal time.
312 pub census: PoolCensus,
313}
314
315impl WorkerUnavailable {
316 /// Whether the engine's retry executor should treat this failure as
317 /// retryable.
318 ///
319 /// Structural unservability is terminal: no retry can conjure a deployment
320 /// that declares the queue. Every other reason is a transient fleet
321 /// condition (a worker may connect, backpressure may drain), so it carries
322 /// the retryable prefix and the workflow's own declared retry policy — or
323 /// its absence — decides what happens next. No retry budget is invented
324 /// here.
325 #[must_use]
326 pub const fn is_retryable(&self) -> bool {
327 !self.reason.is_structural()
328 }
329
330 /// The failure string handed back at the engine dispatch seam.
331 #[must_use]
332 pub fn reason_string(&self) -> String {
333 self.to_string()
334 }
335
336 /// Read the typed head back out of a dispatch failure string.
337 ///
338 /// Returns `None` for any failure that is not a queue-service refusal, so
339 /// surfacing code can ask "is this a `WorkerUnavailable`?" without string
340 /// sniffing.
341 #[must_use]
342 pub fn parse(failure: &str) -> Option<UnavailableSummary> {
343 let body = failure
344 .strip_prefix(TERMINAL_PREFIX)
345 .or_else(|| failure.strip_prefix(RETRYABLE_PREFIX))?;
346 let body = body.strip_prefix(UNAVAILABLE_TAG)?;
347 let head = body.split(TAIL_SEPARATOR).next().unwrap_or(body);
348 let mut reason = None;
349 let mut clock = None;
350 let mut waited = Duration::ZERO;
351 let mut last_compatible_poller_age = None;
352 for token in head.split_whitespace() {
353 let Some((key, value)) = token.split_once('=') else {
354 continue;
355 };
356 match key {
357 "reason" => reason = QueueServiceReason::parse(value),
358 "clock" => clock = ExpiredClock::parse(value),
359 "waited_ms" => waited = parse_millis(value).unwrap_or_default(),
360 "last_compatible_poller_age_ms" => {
361 last_compatible_poller_age = parse_millis(value);
362 }
363 _ => {}
364 }
365 }
366 Some(UnavailableSummary {
367 reason: reason?,
368 clock,
369 waited,
370 last_compatible_poller_age,
371 })
372 }
373}
374
375impl fmt::Display for WorkerUnavailable {
376 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
377 let prefix = if self.is_retryable() {
378 RETRYABLE_PREFIX
379 } else {
380 TERMINAL_PREFIX
381 };
382 let clock = self.clock.map_or(ABSENT, ExpiredClock::as_str);
383 let age = self
384 .census
385 .last_compatible_poller_age
386 .map_or_else(|| ABSENT.to_owned(), |age| millis(age).to_string());
387 write!(
388 formatter,
389 "{prefix}{UNAVAILABLE_TAG} reason={} clock={clock} waited_ms={} \
390 last_compatible_poller_age_ms={age} workers_in_pool={} \
391 workers_serving_activity={} compatible_workers={} \
392 {TAIL_SEPARATOR} {}",
393 self.reason,
394 millis(self.waited),
395 self.census.workers_in_pool,
396 self.census.workers_serving_activity,
397 self.census.compatible_workers,
398 self.explain(),
399 )
400 }
401}
402
403impl WorkerUnavailable {
404 /// The human tail of the failure string: what an operator has to fix.
405 fn explain(&self) -> String {
406 self.reason.explain(&self.address)
407 }
408}
409
410/// The typed head of a queue-service refusal, recovered from its string form.
411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
412pub struct UnavailableSummary {
413 /// Taxonomy state recorded in the refusal.
414 pub reason: QueueServiceReason,
415 /// Clock that expired, if any.
416 pub clock: Option<ExpiredClock>,
417 /// How long the refused dispatch waited.
418 pub waited: Duration,
419 /// Age of the last compatible poller at refusal time, if one was ever seen.
420 pub last_compatible_poller_age: Option<Duration>,
421}
422
423/// Milliseconds of a duration, saturating rather than wrapping.
424pub(super) fn millis(duration: Duration) -> u64 {
425 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
426}
427
428fn parse_millis(value: &str) -> Option<Duration> {
429 value.parse::<u64>().ok().map(Duration::from_millis)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn address() -> ServiceAddress {
437 ServiceAddress {
438 namespace: "default".to_owned(),
439 task_queue: "general".to_owned(),
440 activity_type: "greet".to_owned(),
441 node: None,
442 }
443 }
444
445 fn census() -> PoolCensus {
446 PoolCensus {
447 workers_in_pool: 0,
448 workers_serving_activity: 0,
449 compatible_workers: 0,
450 eligible_compatible_workers: 0,
451 compatible_workers_reachability_lost: 0,
452 last_compatible_poller_age: Some(Duration::from_millis(1_500)),
453 compatible_workers_at_capacity: 0,
454 compatible_workers_capacity_unannounced: 0,
455 }
456 }
457
458 #[test]
459 fn structural_refusal_is_terminal_and_carries_no_clock() {
460 let failure = WorkerUnavailable {
461 reason: QueueServiceReason::NoQueueDeclaration,
462 clock: None,
463 waited: Duration::ZERO,
464 address: address(),
465 census: PoolCensus::default(),
466 };
467 assert!(!failure.is_retryable());
468 let rendered = failure.reason_string();
469 assert!(rendered.starts_with("terminal:"), "{rendered}");
470 assert!(
471 rendered.contains("reason=NO_QUEUE_DECLARATION"),
472 "{rendered}"
473 );
474 assert!(rendered.contains("clock=none"), "{rendered}");
475 }
476
477 #[test]
478 fn fleet_refusals_are_retryable_and_name_the_expired_clock() {
479 let failure = WorkerUnavailable {
480 reason: QueueServiceReason::NoLivePollers,
481 clock: Some(ExpiredClock::ServiceAvailability),
482 waited: Duration::from_secs(2),
483 address: address(),
484 census: census(),
485 };
486 assert!(failure.is_retryable());
487 let rendered = failure.reason_string();
488 assert!(rendered.starts_with("retryable:"), "{rendered}");
489 assert!(
490 rendered.contains("clock=service_availability"),
491 "{rendered}"
492 );
493 assert!(rendered.contains("waited_ms=2000"), "{rendered}");
494 assert!(
495 rendered.contains("last_compatible_poller_age_ms=1500"),
496 "{rendered}"
497 );
498 }
499
500 #[test]
501 fn the_two_clocks_render_distinctly() {
502 let availability = ExpiredClock::ServiceAvailability.as_str();
503 let schedule_to_start = ExpiredClock::ScheduleToStart.as_str();
504 assert_ne!(availability, schedule_to_start);
505 assert_eq!(
506 ExpiredClock::parse(availability),
507 Some(ExpiredClock::ServiceAvailability)
508 );
509 assert_eq!(
510 ExpiredClock::parse(schedule_to_start),
511 Some(ExpiredClock::ScheduleToStart)
512 );
513 }
514
515 #[test]
516 fn a_refusal_round_trips_back_to_its_typed_head() {
517 let failure = WorkerUnavailable {
518 reason: QueueServiceReason::Saturated,
519 clock: Some(ExpiredClock::ScheduleToStart),
520 waited: Duration::from_millis(750),
521 address: ServiceAddress {
522 node: Some("n1".to_owned()),
523 ..address()
524 },
525 census: census(),
526 };
527 let parsed = WorkerUnavailable::parse(&failure.reason_string());
528 assert_eq!(
529 parsed,
530 Some(UnavailableSummary {
531 reason: QueueServiceReason::Saturated,
532 clock: Some(ExpiredClock::ScheduleToStart),
533 waited: Duration::from_millis(750),
534 last_compatible_poller_age: Some(Duration::from_millis(1_500)),
535 })
536 );
537 }
538
539 #[test]
540 fn an_ordinary_failure_is_not_read_as_a_queue_service_refusal() {
541 assert_eq!(WorkerUnavailable::parse("lost:worker lost"), None);
542 assert_eq!(WorkerUnavailable::parse("terminal:boom"), None);
543 assert_eq!(WorkerUnavailable::parse("parked:server-draining"), None);
544 }
545
546 #[test]
547 fn every_reason_round_trips_through_its_canonical_spelling() {
548 for reason in QueueServiceReason::ALL {
549 assert_eq!(QueueServiceReason::parse(reason.as_str()), Some(reason));
550 }
551 }
552
553 /// 🔴 The guard that makes the test above mean what its name says.
554 ///
555 /// Iterating `ALL` proves every reason IN THE LIST round-trips; it cannot
556 /// prove the list is the whole enum. This match is exhaustive, so adding a
557 /// variant stops the crate compiling until it is named here — and naming it
558 /// here without adding it to `ALL` then fails the assertion. The previous
559 /// version of the round-trip test spelled its own list inline and had
560 /// neither property: a new variant simply went untested and unparseable.
561 #[test]
562 fn all_lists_every_variant() {
563 fn is_listed(reason: QueueServiceReason) -> bool {
564 match reason {
565 QueueServiceReason::NoQueueDeclaration
566 | QueueServiceReason::NoLivePollers
567 | QueueServiceReason::PollersIncompatible
568 | QueueServiceReason::PollersUnreachable
569 | QueueServiceReason::PollersAtCapacity
570 | QueueServiceReason::PollersCapacityUnannounced
571 | QueueServiceReason::Saturated => QueueServiceReason::ALL.contains(&reason),
572 }
573 }
574 for reason in QueueServiceReason::ALL {
575 assert!(is_listed(reason), "{reason} is not in ALL");
576 }
577 }
578}