Skip to main content

aion_server/worker/queue_service/
taxonomy.rs

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