aion_server/worker/queue_service/
taxonomy.rs1use std::fmt;
10use std::time::Duration;
11
12use super::census::PoolCensus;
13
14const TERMINAL_PREFIX: &str = "terminal:";
16const RETRYABLE_PREFIX: &str = "retryable:";
19const UNAVAILABLE_TAG: &str = "WORKER_UNAVAILABLE";
21const TAIL_SEPARATOR: char = '—';
23const ABSENT: &str = "none";
25
26#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub enum QueueServiceReason {
32 NoQueueDeclaration,
36 NoLivePollers,
39 PollersIncompatible,
42 Saturated,
46}
47
48impl QueueServiceReason {
49 #[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 #[must_use]
63 pub const fn is_structural(self) -> bool {
64 matches!(self, Self::NoQueueDeclaration)
65 }
66
67 #[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
129pub enum ExpiredClock {
130 ServiceAvailability,
132 ScheduleToStart,
135}
136
137impl ExpiredClock {
138 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct ServiceAddress {
165 pub namespace: String,
167 pub task_queue: String,
169 pub activity_type: String,
171 pub node: Option<String>,
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct WorkerUnavailable {
180 pub reason: QueueServiceReason,
182 pub clock: Option<ExpiredClock>,
185 pub waited: Duration,
187 pub address: ServiceAddress,
189 pub census: PoolCensus,
191}
192
193impl WorkerUnavailable {
194 #[must_use]
204 pub const fn is_retryable(&self) -> bool {
205 !self.reason.is_structural()
206 }
207
208 #[must_use]
210 pub fn reason_string(&self) -> String {
211 self.to_string()
212 }
213
214 #[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 fn explain(&self) -> String {
284 self.reason.explain(&self.address)
285 }
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
290pub struct UnavailableSummary {
291 pub reason: QueueServiceReason,
293 pub clock: Option<ExpiredClock>,
295 pub waited: Duration,
297 pub last_compatible_poller_age: Option<Duration>,
299}
300
301pub(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}