Skip to main content

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}