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 — four 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
15use std::fmt;
16use std::time::Duration;
17
18use super::census::PoolCensus;
19
20/// Failure-reason prefix classifying a dispatch failure as non-retryable.
21const TERMINAL_PREFIX: &str = "terminal:";
22/// Failure-reason prefix classifying a dispatch failure as retryable, matching
23/// the vocabulary `aion`'s retry executor reads (`is_retryable_reason`).
24const RETRYABLE_PREFIX: &str = "retryable:";
25/// Stable tag identifying a queue-service refusal inside a dispatch failure.
26const UNAVAILABLE_TAG: &str = "WORKER_UNAVAILABLE";
27/// Separator between the machine-readable head and the human tail.
28const TAIL_SEPARATOR: char = '—';
29/// Field value standing in for an absent optional field.
30const ABSENT: &str = "none";
31
32/// Why a `(namespace, task_queue, activity_type)` address is not being served.
33///
34/// The names are R1's, verbatim, and are the strings that reach logs, dispatch
35/// failures, and the queue-service accessor.
36#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub enum QueueServiceReason {
38    /// No deployed `.v4` contract declares this task queue at all. Structural:
39    /// no worker can ever legitimately arrive for it, so it refuses
40    /// unconditionally under every policy.
41    NoQueueDeclaration,
42    /// The queue is declared (or its declaration is unknowable) and no worker
43    /// is connected for the pool at all.
44    NoLivePollers,
45    /// Workers are connected for the pool but none of them covers this
46    /// activity type, or none of them sits on the dispatch's pinned node.
47    PollersIncompatible,
48    /// A compatible worker is connected and its intake is refusing work: the
49    /// task could not be handed over before the schedule-to-start clock
50    /// expired.
51    Saturated,
52    /// Workers are connected and DO serve this activity on this node, but the
53    /// liveness verdict has withdrawn dispatch eligibility from all of them
54    /// after losing reachability.
55    ///
56    /// 🔴 Distinct from [`Self::PollersIncompatible`] because the remedies are
57    /// close to opposite. Incompatible pollers need a worker deployed that
58    /// covers the activity; these workers already cover it, and deploying
59    /// another changes nothing — the fault is on the wire to the ones already
60    /// there. Distinct from an opening probation, which is not reported at all
61    /// because it clears itself within seconds.
62    PollersUnreachable,
63}
64
65impl QueueServiceReason {
66    /// Every variant, and the single source of that set.
67    ///
68    /// 🔴 [`Self::parse`] and the round-trip test both read THIS, so a variant
69    /// missing here is a variant that cannot be parsed back from its own
70    /// spelling — a wire value the server writes and then fails to read. The
71    /// exhaustive `match` in [`Self::as_str`] forces a new variant to be given
72    /// a spelling, but nothing forces it into this list; `all_lists_every_variant`
73    /// is what does, by matching exhaustively so the compiler refuses a variant
74    /// this list has not been told about.
75    pub const ALL: [Self; 5] = [
76        Self::NoQueueDeclaration,
77        Self::NoLivePollers,
78        Self::PollersIncompatible,
79        Self::PollersUnreachable,
80        Self::Saturated,
81    ];
82
83    /// The canonical wire/log spelling.
84    #[must_use]
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::NoQueueDeclaration => "NO_QUEUE_DECLARATION",
88            Self::NoLivePollers => "NO_LIVE_POLLERS",
89            Self::PollersIncompatible => "POLLERS_INCOMPATIBLE",
90            Self::Saturated => "SATURATED",
91            Self::PollersUnreachable => "POLLERS_UNREACHABLE",
92        }
93    }
94
95    /// Whether the unservability is structural — provable from the deployed
96    /// contract records alone, unchangeable by waiting or retrying.
97    #[must_use]
98    pub const fn is_structural(self) -> bool {
99        matches!(self, Self::NoQueueDeclaration)
100    }
101
102    /// The operator-facing sentence for this reason at `address`: what has to
103    /// be fixed, in words.
104    ///
105    /// This is the single source of that sentence. The dispatch refusal's human
106    /// tail ([`WorkerUnavailable::explain`]) and the describe projection's
107    /// `detail` both read it here, so a queue that is unserved cannot be
108    /// explained one way in a refusal and another way in a read.
109    #[must_use]
110    pub fn explain(self, address: &ServiceAddress) -> String {
111        let ServiceAddress {
112            namespace,
113            task_queue,
114            activity_type,
115            node,
116        } = address;
117        let node = node
118            .as_ref()
119            .map_or_else(String::new, |node| format!(" pinned to node `{node}`"));
120        match self {
121            Self::NoQueueDeclaration => format!(
122                "no deployed contract declares task queue `{task_queue}`, \
123                 so activity `{activity_type}` in namespace `{namespace}` \
124                 can never be served{node}"
125            ),
126            Self::NoLivePollers => format!(
127                "no worker is connected for `{namespace}`/`{task_queue}`, \
128                 so activity `{activity_type}` is unserved{node}"
129            ),
130            Self::PollersIncompatible => format!(
131                "workers are connected for `{namespace}`/`{task_queue}` but \
132                 none serves activity `{activity_type}`{node}"
133            ),
134            Self::Saturated => format!(
135                "a worker serving `{namespace}`/`{task_queue}` activity \
136                 `{activity_type}`{node} would not accept the task before the \
137                 schedule-to-start timeout"
138            ),
139            Self::PollersUnreachable => format!(
140                "every worker serving `{namespace}`/`{task_queue}` activity \
141                 `{activity_type}`{node} has lost dispatch eligibility because \
142                 the server cannot reach it. This is not an empty pool and not \
143                 an unserved activity — starting another worker will not help. \
144                 Read the liveness verdict for these workers; the reachability \
145                 lines carry the worker ids and the failure"
146            ),
147        }
148    }
149
150    /// Read a reason back from its canonical spelling.
151    #[must_use]
152    pub fn parse(text: &str) -> Option<Self> {
153        Self::ALL.into_iter().find(|reason| reason.as_str() == text)
154    }
155}
156
157impl fmt::Display for QueueServiceReason {
158    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159        formatter.write_str(self.as_str())
160    }
161}
162
163/// Which of the two never-conflated service clocks expired.
164#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
165pub enum ExpiredClock {
166    /// Time spent waiting for ANY compatible worker to exist.
167    ServiceAvailability,
168    /// Time spent between dispatch and a live compatible worker accepting the
169    /// task into its intake.
170    ScheduleToStart,
171}
172
173impl ExpiredClock {
174    /// The canonical wire/log spelling.
175    #[must_use]
176    pub const fn as_str(self) -> &'static str {
177        match self {
178            Self::ServiceAvailability => "service_availability",
179            Self::ScheduleToStart => "schedule_to_start",
180        }
181    }
182
183    /// Read a clock back from its canonical spelling.
184    #[must_use]
185    pub fn parse(text: &str) -> Option<Self> {
186        [Self::ServiceAvailability, Self::ScheduleToStart]
187            .into_iter()
188            .find(|clock| clock.as_str() == text)
189    }
190}
191
192impl fmt::Display for ExpiredClock {
193    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194        formatter.write_str(self.as_str())
195    }
196}
197
198/// The pool address a dispatch could not be served at.
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct ServiceAddress {
201    /// Correctness/isolation boundary of the dispatch.
202    pub namespace: String,
203    /// Pool selector within the namespace.
204    pub task_queue: String,
205    /// Activity type the dispatch needs served.
206    pub activity_type: String,
207    /// Optional within-pool node pin carried by the dispatch.
208    pub node: Option<String>,
209}
210
211/// A typed dispatch refusal: the taxonomy reason, which clock expired (if
212/// any), how long the dispatch waited, and the live poller census behind the
213/// verdict. Never a generic timeout, never a bare string.
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct WorkerUnavailable {
216    /// Taxonomy state of the queue at refusal time.
217    pub reason: QueueServiceReason,
218    /// The clock whose expiry produced the refusal, or `None` for a structural
219    /// refusal (which needs no clock).
220    pub clock: Option<ExpiredClock>,
221    /// How long this dispatch waited before refusing.
222    pub waited: Duration,
223    /// Pool address that could not be served.
224    pub address: ServiceAddress,
225    /// Live poller census at refusal time.
226    pub census: PoolCensus,
227}
228
229impl WorkerUnavailable {
230    /// Whether the engine's retry executor should treat this failure as
231    /// retryable.
232    ///
233    /// Structural unservability is terminal: no retry can conjure a deployment
234    /// that declares the queue. Every other reason is a transient fleet
235    /// condition (a worker may connect, backpressure may drain), so it carries
236    /// the retryable prefix and the workflow's own declared retry policy — or
237    /// its absence — decides what happens next. No retry budget is invented
238    /// here.
239    #[must_use]
240    pub const fn is_retryable(&self) -> bool {
241        !self.reason.is_structural()
242    }
243
244    /// The failure string handed back at the engine dispatch seam.
245    #[must_use]
246    pub fn reason_string(&self) -> String {
247        self.to_string()
248    }
249
250    /// Read the typed head back out of a dispatch failure string.
251    ///
252    /// Returns `None` for any failure that is not a queue-service refusal, so
253    /// surfacing code can ask "is this a `WorkerUnavailable`?" without string
254    /// sniffing.
255    #[must_use]
256    pub fn parse(failure: &str) -> Option<UnavailableSummary> {
257        let body = failure
258            .strip_prefix(TERMINAL_PREFIX)
259            .or_else(|| failure.strip_prefix(RETRYABLE_PREFIX))?;
260        let body = body.strip_prefix(UNAVAILABLE_TAG)?;
261        let head = body.split(TAIL_SEPARATOR).next().unwrap_or(body);
262        let mut reason = None;
263        let mut clock = None;
264        let mut waited = Duration::ZERO;
265        let mut last_compatible_poller_age = None;
266        for token in head.split_whitespace() {
267            let Some((key, value)) = token.split_once('=') else {
268                continue;
269            };
270            match key {
271                "reason" => reason = QueueServiceReason::parse(value),
272                "clock" => clock = ExpiredClock::parse(value),
273                "waited_ms" => waited = parse_millis(value).unwrap_or_default(),
274                "last_compatible_poller_age_ms" => {
275                    last_compatible_poller_age = parse_millis(value);
276                }
277                _ => {}
278            }
279        }
280        Some(UnavailableSummary {
281            reason: reason?,
282            clock,
283            waited,
284            last_compatible_poller_age,
285        })
286    }
287}
288
289impl fmt::Display for WorkerUnavailable {
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        let prefix = if self.is_retryable() {
292            RETRYABLE_PREFIX
293        } else {
294            TERMINAL_PREFIX
295        };
296        let clock = self.clock.map_or(ABSENT, ExpiredClock::as_str);
297        let age = self
298            .census
299            .last_compatible_poller_age
300            .map_or_else(|| ABSENT.to_owned(), |age| millis(age).to_string());
301        write!(
302            formatter,
303            "{prefix}{UNAVAILABLE_TAG} reason={} clock={clock} waited_ms={} \
304             last_compatible_poller_age_ms={age} workers_in_pool={} \
305             workers_serving_activity={} compatible_workers={} \
306             {TAIL_SEPARATOR} {}",
307            self.reason,
308            millis(self.waited),
309            self.census.workers_in_pool,
310            self.census.workers_serving_activity,
311            self.census.compatible_workers,
312            self.explain(),
313        )
314    }
315}
316
317impl WorkerUnavailable {
318    /// The human tail of the failure string: what an operator has to fix.
319    fn explain(&self) -> String {
320        self.reason.explain(&self.address)
321    }
322}
323
324/// The typed head of a queue-service refusal, recovered from its string form.
325#[derive(Clone, Copy, Debug, Eq, PartialEq)]
326pub struct UnavailableSummary {
327    /// Taxonomy state recorded in the refusal.
328    pub reason: QueueServiceReason,
329    /// Clock that expired, if any.
330    pub clock: Option<ExpiredClock>,
331    /// How long the refused dispatch waited.
332    pub waited: Duration,
333    /// Age of the last compatible poller at refusal time, if one was ever seen.
334    pub last_compatible_poller_age: Option<Duration>,
335}
336
337/// Milliseconds of a duration, saturating rather than wrapping.
338pub(super) fn millis(duration: Duration) -> u64 {
339    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
340}
341
342fn parse_millis(value: &str) -> Option<Duration> {
343    value.parse::<u64>().ok().map(Duration::from_millis)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn address() -> ServiceAddress {
351        ServiceAddress {
352            namespace: "default".to_owned(),
353            task_queue: "general".to_owned(),
354            activity_type: "greet".to_owned(),
355            node: None,
356        }
357    }
358
359    fn census() -> PoolCensus {
360        PoolCensus {
361            workers_in_pool: 0,
362            workers_serving_activity: 0,
363            compatible_workers: 0,
364            eligible_compatible_workers: 0,
365            compatible_workers_reachability_lost: 0,
366            last_compatible_poller_age: Some(Duration::from_millis(1_500)),
367        }
368    }
369
370    #[test]
371    fn structural_refusal_is_terminal_and_carries_no_clock() {
372        let failure = WorkerUnavailable {
373            reason: QueueServiceReason::NoQueueDeclaration,
374            clock: None,
375            waited: Duration::ZERO,
376            address: address(),
377            census: PoolCensus::default(),
378        };
379        assert!(!failure.is_retryable());
380        let rendered = failure.reason_string();
381        assert!(rendered.starts_with("terminal:"), "{rendered}");
382        assert!(
383            rendered.contains("reason=NO_QUEUE_DECLARATION"),
384            "{rendered}"
385        );
386        assert!(rendered.contains("clock=none"), "{rendered}");
387    }
388
389    #[test]
390    fn fleet_refusals_are_retryable_and_name_the_expired_clock() {
391        let failure = WorkerUnavailable {
392            reason: QueueServiceReason::NoLivePollers,
393            clock: Some(ExpiredClock::ServiceAvailability),
394            waited: Duration::from_secs(2),
395            address: address(),
396            census: census(),
397        };
398        assert!(failure.is_retryable());
399        let rendered = failure.reason_string();
400        assert!(rendered.starts_with("retryable:"), "{rendered}");
401        assert!(
402            rendered.contains("clock=service_availability"),
403            "{rendered}"
404        );
405        assert!(rendered.contains("waited_ms=2000"), "{rendered}");
406        assert!(
407            rendered.contains("last_compatible_poller_age_ms=1500"),
408            "{rendered}"
409        );
410    }
411
412    #[test]
413    fn the_two_clocks_render_distinctly() {
414        let availability = ExpiredClock::ServiceAvailability.as_str();
415        let schedule_to_start = ExpiredClock::ScheduleToStart.as_str();
416        assert_ne!(availability, schedule_to_start);
417        assert_eq!(
418            ExpiredClock::parse(availability),
419            Some(ExpiredClock::ServiceAvailability)
420        );
421        assert_eq!(
422            ExpiredClock::parse(schedule_to_start),
423            Some(ExpiredClock::ScheduleToStart)
424        );
425    }
426
427    #[test]
428    fn a_refusal_round_trips_back_to_its_typed_head() {
429        let failure = WorkerUnavailable {
430            reason: QueueServiceReason::Saturated,
431            clock: Some(ExpiredClock::ScheduleToStart),
432            waited: Duration::from_millis(750),
433            address: ServiceAddress {
434                node: Some("n1".to_owned()),
435                ..address()
436            },
437            census: census(),
438        };
439        let parsed = WorkerUnavailable::parse(&failure.reason_string());
440        assert_eq!(
441            parsed,
442            Some(UnavailableSummary {
443                reason: QueueServiceReason::Saturated,
444                clock: Some(ExpiredClock::ScheduleToStart),
445                waited: Duration::from_millis(750),
446                last_compatible_poller_age: Some(Duration::from_millis(1_500)),
447            })
448        );
449    }
450
451    #[test]
452    fn an_ordinary_failure_is_not_read_as_a_queue_service_refusal() {
453        assert_eq!(WorkerUnavailable::parse("lost:worker lost"), None);
454        assert_eq!(WorkerUnavailable::parse("terminal:boom"), None);
455        assert_eq!(WorkerUnavailable::parse("parked:server-draining"), None);
456    }
457
458    #[test]
459    fn every_reason_round_trips_through_its_canonical_spelling() {
460        for reason in QueueServiceReason::ALL {
461            assert_eq!(QueueServiceReason::parse(reason.as_str()), Some(reason));
462        }
463    }
464
465    /// 🔴 The guard that makes the test above mean what its name says.
466    ///
467    /// Iterating `ALL` proves every reason IN THE LIST round-trips; it cannot
468    /// prove the list is the whole enum. This match is exhaustive, so adding a
469    /// variant stops the crate compiling until it is named here — and naming it
470    /// here without adding it to `ALL` then fails the assertion. The previous
471    /// version of the round-trip test spelled its own list inline and had
472    /// neither property: a new variant simply went untested and unparseable.
473    #[test]
474    fn all_lists_every_variant() {
475        fn is_listed(reason: QueueServiceReason) -> bool {
476            match reason {
477                QueueServiceReason::NoQueueDeclaration
478                | QueueServiceReason::NoLivePollers
479                | QueueServiceReason::PollersIncompatible
480                | QueueServiceReason::PollersUnreachable
481                | QueueServiceReason::Saturated => QueueServiceReason::ALL.contains(&reason),
482            }
483        }
484        for reason in QueueServiceReason::ALL {
485            assert!(is_listed(reason), "{reason} is not in ALL");
486        }
487    }
488}