aion_server/worker/queue_service/
taxonomy.rs1use std::fmt;
16use std::time::Duration;
17
18use super::census::PoolCensus;
19
20const TERMINAL_PREFIX: &str = "terminal:";
22const RETRYABLE_PREFIX: &str = "retryable:";
25const UNAVAILABLE_TAG: &str = "WORKER_UNAVAILABLE";
27const TAIL_SEPARATOR: char = '—';
29const ABSENT: &str = "none";
31
32#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub enum QueueServiceReason {
38 NoQueueDeclaration,
42 NoLivePollers,
45 PollersIncompatible,
48 Saturated,
52 PollersUnreachable,
63}
64
65impl QueueServiceReason {
66 pub const ALL: [Self; 5] = [
76 Self::NoQueueDeclaration,
77 Self::NoLivePollers,
78 Self::PollersIncompatible,
79 Self::PollersUnreachable,
80 Self::Saturated,
81 ];
82
83 #[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 #[must_use]
98 pub const fn is_structural(self) -> bool {
99 matches!(self, Self::NoQueueDeclaration)
100 }
101
102 #[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
165pub enum ExpiredClock {
166 ServiceAvailability,
168 ScheduleToStart,
171}
172
173impl ExpiredClock {
174 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct ServiceAddress {
201 pub namespace: String,
203 pub task_queue: String,
205 pub activity_type: String,
207 pub node: Option<String>,
209}
210
211#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct WorkerUnavailable {
216 pub reason: QueueServiceReason,
218 pub clock: Option<ExpiredClock>,
221 pub waited: Duration,
223 pub address: ServiceAddress,
225 pub census: PoolCensus,
227}
228
229impl WorkerUnavailable {
230 #[must_use]
240 pub const fn is_retryable(&self) -> bool {
241 !self.reason.is_structural()
242 }
243
244 #[must_use]
246 pub fn reason_string(&self) -> String {
247 self.to_string()
248 }
249
250 #[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 fn explain(&self) -> String {
320 self.reason.explain(&self.address)
321 }
322}
323
324#[derive(Clone, Copy, Debug, Eq, PartialEq)]
326pub struct UnavailableSummary {
327 pub reason: QueueServiceReason,
329 pub clock: Option<ExpiredClock>,
331 pub waited: Duration,
333 pub last_compatible_poller_age: Option<Duration>,
335}
336
337pub(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 #[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}