aion_server/worker/queue_service/census.rs
1//! Live poller census for one pool address, and the classification it feeds.
2//!
3//! The census is taken from the connected-worker registry at the same moment
4//! selection misses, so the taxonomy verdict describes the fleet the selection
5//! actually saw — never a second, later, disagreeing observation.
6
7use std::time::Duration;
8
9use super::declarations::QueueDeclaration;
10use super::taxonomy::QueueServiceReason;
11
12/// What the connected-worker registry holds for one
13/// `(namespace, task_queue, activity_type[, node])` address.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub struct PoolCensus {
16 /// Workers connected for `(namespace, task_queue)`, whatever they serve.
17 pub workers_in_pool: usize,
18 /// Of those, workers advertising this activity type.
19 pub workers_serving_activity: usize,
20 /// Of those, workers that also satisfy the dispatch's node pin. Equal to
21 /// [`Self::workers_serving_activity`] for an unpinned dispatch.
22 ///
23 /// Deliberately NOT filtered by dispatch eligibility, so
24 /// [`classify`] can tell an empty pool from an excluded one (#197 R3).
25 pub compatible_workers: usize,
26 /// Of [`Self::compatible_workers`], those a dispatch could actually be
27 /// handed to right now — the rest are excluded by the liveness verdict.
28 pub eligible_compatible_workers: usize,
29 /// Of the excluded compatible workers, those excluded by
30 /// `DispatchExclusion::ReachabilityLost` rather than by an opening
31 /// probation.
32 ///
33 /// 🔴 The whole point of the distinction. An opening probation clears
34 /// itself in seconds, so parking silently through one is correct and
35 /// announcing it would be noise on every healthy connect. A lost
36 /// reachability does NOT clear on its own, so a dispatch parked on one
37 /// waits indefinitely — and must say so, or it is a silent stall.
38 pub compatible_workers_reachability_lost: usize,
39 /// Of [`Self::compatible_workers`], those already holding every dispatch
40 /// they advertised they would run at once.
41 ///
42 /// 🔴 A THIRD condition, and the reason [`classify`] needs a third answer.
43 /// An at-capacity pool is neither empty nor unreachable: its workers are
44 /// connected, serving this activity, and working. The remedy is the
45 /// OPPOSITE of the unreachable one — starting another worker helps here and
46 /// does not help there — so reporting either existing reason for it would
47 /// send an operator the wrong way.
48 pub compatible_workers_at_capacity: usize,
49 /// Of [`Self::compatible_workers`], those that have registered but have not
50 /// yet announced how much work they can take.
51 ///
52 /// 🔴 DISJOINT from [`Self::compatible_workers_at_capacity`], and counted
53 /// apart from it on purpose. Selection excludes both, by the same test —
54 /// unknown capacity is treated as full — so a census that folded them
55 /// together would be arithmetically fine and operationally a lie: it would
56 /// report a pool of idle, mute workers as a pool of busy ones, and attach
57 /// the advice for a busy pool ("start another worker") to a condition
58 /// another worker of the same build cannot fix.
59 ///
60 /// Only the liminal transport can produce it, and normally only for the one
61 /// round trip between a worker's registration and its capacity
62 /// announcement.
63 pub compatible_workers_capacity_unannounced: usize,
64 /// How long ago a compatible worker was last in service, or `None` when
65 /// none has ever been seen for this address in this server's life.
66 ///
67 /// Zero while a compatible worker is connected right now.
68 pub last_compatible_poller_age: Option<Duration>,
69}
70
71impl PoolCensus {
72 /// Whether the address currently has a compatible worker REGISTERED,
73 /// whatever its dispatch eligibility.
74 ///
75 /// A field-level fact, not a decision. It stays deliberately blind to
76 /// eligibility because [`classify`] needs the blind count to tell an empty
77 /// pool from an excluded one (#197 R3). Callers asking "will a dispatch
78 /// aimed here actually proceed?" want [`Self::will_be_served`].
79 #[must_use]
80 pub const fn is_served(&self) -> bool {
81 self.compatible_workers > 0
82 }
83
84 /// Whether a dispatch aimed here can be expected to proceed without an
85 /// operator doing something first.
86 ///
87 /// 🔴 The distinction [`Self::is_served`] cannot draw. A pool whose workers
88 /// are all serving an opening probation is not dispatchable this instant
89 /// but will be within seconds, so it answers `true` — waiting is the
90 /// correct outcome and the caller should not be warned. A pool whose
91 /// workers have all LOST reachability answers `false`: nothing about it
92 /// improves on its own, so a caller told "served" would be told a run is
93 /// about to proceed when it is about to park indefinitely.
94 ///
95 /// A pool whose workers are all AT CAPACITY answers `true` for the same
96 /// reason as the probation case: it is not dispatchable this instant and
97 /// needs nobody to do anything — a running activity finishes, a slot frees,
98 /// and the parked dispatch is selected. Waiting is the correct outcome.
99 ///
100 /// A pool whose workers have NOT ANNOUNCED their capacity answers `false`,
101 /// and is the reason this is no longer two clauses. Nothing about that pool
102 /// improves on its own: the announcement either already arrived — in which
103 /// case these workers are not in this bucket — or it never will, because the
104 /// worker is a build that does not send one or its publish was lost. Saying
105 /// "will be served" of a fleet that is registered, idle, mute and
106 /// permanently unselectable is the most expensive kind of wrong answer,
107 /// because the whole point of this predicate is to tell a caller whether
108 /// waiting is enough.
109 #[must_use]
110 pub const fn will_be_served(&self) -> bool {
111 if self.eligible_compatible_workers > 0 {
112 return true;
113 }
114 self.compatible_workers > 0
115 && self.compatible_workers_reachability_lost == 0
116 && self.compatible_workers_capacity_unannounced == 0
117 }
118}
119
120/// Classify one selection miss into the taxonomy.
121///
122/// Returns `None` when the caller must simply retry selection rather than
123/// report a state that is no longer true. That covers two cases: the address is
124/// genuinely served because a worker arrived between the two lock
125/// acquisitions, and the address's compatible workers are all serving an
126/// opening probation, which clears on its own within seconds.
127///
128/// 🔴 It does NOT cover a pool whose compatible workers have all LOST
129/// reachability. That does not clear on its own, so returning `None` there
130/// parks the dispatch indefinitely with nothing published about why — the
131/// silent stall this arm exists to prevent.
132///
133/// Ordering is deliberate: a structurally undeclared queue is reported as
134/// [`QueueServiceReason::NoQueueDeclaration`] even when unrelated workers sit
135/// in the pool, because that is the deeper fact and the only one that refuses
136/// unconditionally. [`QueueDeclaration::Unknown`] never manufactures a
137/// structural refusal — the same fail-open discipline worker registration
138/// applies when no catalog is reachable.
139///
140/// [`QueueServiceReason::Saturated`] is never produced here: it is not a
141/// selection miss. It is emitted by the delivery path
142/// ([`super::delivery::deliver_within_schedule_to_start`]) when a compatible
143/// worker is live and refuses intake past the schedule-to-start clock.
144#[must_use]
145pub fn classify(declaration: QueueDeclaration, census: &PoolCensus) -> Option<QueueServiceReason> {
146 if census.eligible_compatible_workers > 0 {
147 return None;
148 }
149 if declaration == QueueDeclaration::NotDeclared {
150 return Some(QueueServiceReason::NoQueueDeclaration);
151 }
152 if census.compatible_workers > 0 {
153 // Every compatible worker is excluded by the liveness verdict. Which
154 // exclusion decides whether this is worth saying: an opening probation
155 // is the ordinary cost of connecting and clears itself, and reporting
156 // it would fire on every healthy worker's first seconds.
157 //
158 // `PollersIncompatible` would be a LIE here and not merely a vague
159 // one — its sentence is "workers are connected but none serves this
160 // activity", and every one of these serves it. The remedy it implies
161 // (deploy a worker that covers the type) is wrong; the real remedy is
162 // to read the liveness verdict for workers already present.
163 if census.compatible_workers_reachability_lost > 0 {
164 return Some(QueueServiceReason::PollersUnreachable);
165 }
166 // Checked BEFORE the at-capacity arm, and the precedence is the point.
167 // Selection excludes an unannounced worker by the same test it excludes
168 // a full one, so both conditions can be true of one pool at once — and
169 // only one reason can be published. The unannounced one wins because it
170 // is the one an operator may have to act on: a busy pool clears itself
171 // as work completes, whereas a worker that has not announced its
172 // capacity past one round trip never will without being read or
173 // replaced. Reporting the self-healing condition and burying the
174 // durable one would be the wrong way round.
175 //
176 // `> 0` rather than `== compatible_workers` for the same reason: one
177 // mute worker in an otherwise busy pool is still a fact worth surfacing,
178 // and its sentence stays true of the pool ("not one of them has
179 // announced" is scoped by the sentence to the workers it describes).
180 if census.compatible_workers_capacity_unannounced > 0 {
181 return Some(QueueServiceReason::PollersCapacityUnannounced);
182 }
183 // Every compatible worker is connected, serving this activity, and
184 // FULL. That is a third condition, not a quieter version of either one
185 // above: the pool is healthy and the work is queued behind real work.
186 //
187 // Reported only when the whole compatible population is at capacity. A
188 // mixed pool — some full, some serving an opening probation — is
189 // dominated by the probation, which clears itself within seconds, and
190 // announcing anything for it would fire on every healthy worker start.
191 if census.compatible_workers_at_capacity == census.compatible_workers {
192 return Some(QueueServiceReason::PollersAtCapacity);
193 }
194 return None;
195 }
196 if census.workers_in_pool > 0 {
197 return Some(QueueServiceReason::PollersIncompatible);
198 }
199 Some(QueueServiceReason::NoLivePollers)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn a_served_address_is_not_classified() {
208 let census = PoolCensus {
209 workers_in_pool: 1,
210 workers_serving_activity: 1,
211 compatible_workers: 1,
212 eligible_compatible_workers: 1,
213 compatible_workers_reachability_lost: 0,
214 last_compatible_poller_age: Some(Duration::ZERO),
215 compatible_workers_at_capacity: 0,
216 compatible_workers_capacity_unannounced: 0,
217 };
218 assert_eq!(classify(QueueDeclaration::Declared, &census), None);
219 assert_eq!(classify(QueueDeclaration::NotDeclared, &census), None);
220 }
221
222 #[test]
223 fn an_undeclared_queue_is_structural() {
224 assert_eq!(
225 classify(QueueDeclaration::NotDeclared, &PoolCensus::default()),
226 Some(QueueServiceReason::NoQueueDeclaration)
227 );
228 }
229
230 #[test]
231 fn an_empty_pool_on_a_declared_queue_has_no_live_pollers() {
232 assert_eq!(
233 classify(QueueDeclaration::Declared, &PoolCensus::default()),
234 Some(QueueServiceReason::NoLivePollers)
235 );
236 }
237
238 #[test]
239 fn an_unknowable_declaration_never_produces_a_structural_refusal() {
240 assert_eq!(
241 classify(QueueDeclaration::Unknown, &PoolCensus::default()),
242 Some(QueueServiceReason::NoLivePollers)
243 );
244 }
245
246 /// A BUSY pool is neither empty nor unreachable, and the ladder must say
247 /// so in its own words.
248 ///
249 /// This is the arm the outage produced and nothing reported: every worker
250 /// connected, serving the activity, and full. Before the arm existed the
251 /// ladder had two ways to describe it and both were false — it fell through
252 /// to `NoLivePollers` ("no worker is connected") once the sweep had
253 /// deregistered the busy worker, and would otherwise have returned `None`
254 /// and parked in silence.
255 #[test]
256 fn a_pool_whose_every_worker_is_full_reports_at_capacity() {
257 let census = PoolCensus {
258 workers_in_pool: 2,
259 workers_serving_activity: 2,
260 compatible_workers: 2,
261 eligible_compatible_workers: 0,
262 compatible_workers_reachability_lost: 0,
263 compatible_workers_at_capacity: 2,
264 compatible_workers_capacity_unannounced: 0,
265 last_compatible_poller_age: Some(Duration::ZERO),
266 };
267 assert_eq!(
268 classify(QueueDeclaration::Declared, &census),
269 Some(QueueServiceReason::PollersAtCapacity)
270 );
271 // The discriminating half: the SAME pool with nobody connected is the
272 // reason this arm exists to stop being confused with.
273 assert_eq!(
274 classify(QueueDeclaration::Declared, &PoolCensus::default()),
275 Some(QueueServiceReason::NoLivePollers)
276 );
277 // And a busy pool is one a dispatch should WAIT on: nothing about it
278 // needs an operator, so the caller must not be told it will not be
279 // served.
280 assert!(census.will_be_served());
281 }
282
283 /// A pool whose workers have not announced their capacity is reported as
284 /// UNANNOUNCED, never as busy.
285 ///
286 /// Selection excludes both by the same test — unknown capacity is treated as
287 /// full — so this is exactly the confusion the separate count exists to
288 /// prevent. Reporting these workers as at-capacity would tell an operator
289 /// their idle, mute fleet was busy, and hand them the advice for a busy pool
290 /// ("start another worker") for a condition another worker of the same build
291 /// reproduces exactly.
292 #[test]
293 fn a_pool_whose_workers_have_not_announced_capacity_is_not_reported_as_busy() {
294 let census = PoolCensus {
295 workers_in_pool: 2,
296 workers_serving_activity: 2,
297 compatible_workers: 2,
298 eligible_compatible_workers: 0,
299 compatible_workers_reachability_lost: 0,
300 compatible_workers_at_capacity: 0,
301 compatible_workers_capacity_unannounced: 2,
302 last_compatible_poller_age: Some(Duration::ZERO),
303 };
304 assert_eq!(
305 classify(QueueDeclaration::Declared, &census),
306 Some(QueueServiceReason::PollersCapacityUnannounced)
307 );
308 assert_ne!(
309 classify(QueueDeclaration::Declared, &census),
310 Some(QueueServiceReason::PollersAtCapacity),
311 "an unannounced pool must never wear the busy pool's name: their remedies differ"
312 );
313 // And it must NOT be reported as "will be served". Nothing about an
314 // unannounced pool improves on its own: either the announcement has
315 // already landed — in which case these workers are not in this bucket —
316 // or it never will. Answering `true` here told a caller that waiting was
317 // enough for a fleet that is registered, idle, and permanently
318 // unselectable.
319 assert!(
320 !census.will_be_served(),
321 "an all-unannounced pool is not going to serve anything by itself"
322 );
323 }
324
325 /// One unannounced worker in an otherwise busy pool still reports
326 /// UNANNOUNCED.
327 ///
328 /// The precedence is the subject. Both conditions are true here and only one
329 /// reason can be published; the unannounced one wins because it is the one
330 /// that may not clear on its own. A busy pool frees a slot as work
331 /// completes; a worker that has not announced past one round trip never will
332 /// without being read or replaced.
333 #[test]
334 fn an_unannounced_worker_outranks_a_busy_one() {
335 let census = PoolCensus {
336 workers_in_pool: 3,
337 workers_serving_activity: 3,
338 compatible_workers: 3,
339 eligible_compatible_workers: 0,
340 compatible_workers_reachability_lost: 0,
341 compatible_workers_at_capacity: 2,
342 compatible_workers_capacity_unannounced: 1,
343 last_compatible_poller_age: Some(Duration::ZERO),
344 };
345 assert_eq!(
346 classify(QueueDeclaration::Declared, &census),
347 Some(QueueServiceReason::PollersCapacityUnannounced)
348 );
349 }
350
351 /// The vacuity control for both tests above: with nobody unannounced, a full
352 /// pool still reports AT CAPACITY.
353 ///
354 /// Without this, a precedence bug that returned `PollersCapacityUnannounced`
355 /// unconditionally would satisfy every assertion above.
356 #[test]
357 fn a_busy_pool_with_nobody_unannounced_still_reports_at_capacity() {
358 let census = PoolCensus {
359 workers_in_pool: 2,
360 workers_serving_activity: 2,
361 compatible_workers: 2,
362 eligible_compatible_workers: 0,
363 compatible_workers_reachability_lost: 0,
364 compatible_workers_at_capacity: 2,
365 compatible_workers_capacity_unannounced: 0,
366 last_compatible_poller_age: Some(Duration::ZERO),
367 };
368 assert_eq!(
369 classify(QueueDeclaration::Declared, &census),
370 Some(QueueServiceReason::PollersAtCapacity)
371 );
372 }
373
374 /// A pool that is full AND has lost reachability reports the incident, not
375 /// the capacity: an unreachable worker is not going to free a slot, so
376 /// "wait for capacity" would be advice to wait for something that is not
377 /// coming.
378 #[test]
379 fn lost_reachability_outranks_capacity() {
380 let census = PoolCensus {
381 workers_in_pool: 2,
382 workers_serving_activity: 2,
383 compatible_workers: 2,
384 eligible_compatible_workers: 0,
385 compatible_workers_reachability_lost: 1,
386 compatible_workers_at_capacity: 2,
387 compatible_workers_capacity_unannounced: 0,
388 last_compatible_poller_age: Some(Duration::ZERO),
389 };
390 assert_eq!(
391 classify(QueueDeclaration::Declared, &census),
392 Some(QueueServiceReason::PollersUnreachable)
393 );
394 }
395
396 /// A MIXED pool — one worker full, one still serving its opening
397 /// probation — is not reported at all. The probation clears itself within
398 /// seconds and is the ordinary cost of connecting, so announcing anything
399 /// here would fire on every healthy worker start.
400 #[test]
401 fn a_pool_only_partly_at_capacity_is_not_reported() {
402 let census = PoolCensus {
403 workers_in_pool: 2,
404 workers_serving_activity: 2,
405 compatible_workers: 2,
406 eligible_compatible_workers: 0,
407 compatible_workers_reachability_lost: 0,
408 compatible_workers_at_capacity: 1,
409 compatible_workers_capacity_unannounced: 0,
410 last_compatible_poller_age: Some(Duration::ZERO),
411 };
412 assert_eq!(classify(QueueDeclaration::Declared, &census), None);
413 }
414
415 /// A pool of `compatible` workers of whom `eligible` can be dispatched to,
416 /// with `lost` of the remainder excluded for lost reachability rather than
417 /// for an opening probation.
418 fn pool(compatible: usize, eligible: usize, lost: usize) -> PoolCensus {
419 PoolCensus {
420 workers_in_pool: compatible,
421 workers_serving_activity: compatible,
422 compatible_workers: compatible,
423 eligible_compatible_workers: eligible,
424 compatible_workers_reachability_lost: lost,
425 last_compatible_poller_age: Some(Duration::ZERO),
426 compatible_workers_at_capacity: 0,
427 compatible_workers_capacity_unannounced: 0,
428 }
429 }
430
431 /// A pool whose workers are all still serving their opening probation is
432 /// NOT reported: the exclusion clears itself within seconds, and reporting
433 /// it would fire on every healthy worker's first moments.
434 #[test]
435 fn a_pool_excluded_only_by_an_opening_probation_is_not_reported() {
436 assert_eq!(classify(QueueDeclaration::Declared, &pool(2, 0, 0)), None);
437 }
438
439 /// The same pool, excluded for lost reachability, IS reported — because
440 /// that exclusion does not clear on its own, so a dispatch parked on it
441 /// waits indefinitely with nothing said.
442 #[test]
443 fn a_pool_that_lost_reachability_is_reported() {
444 assert_eq!(
445 classify(QueueDeclaration::Declared, &pool(2, 0, 2)),
446 Some(QueueServiceReason::PollersUnreachable)
447 );
448 }
449
450 /// 🔴 THE DISCRIMINATOR, and the reason the two tests above are not enough
451 /// on their own.
452 ///
453 /// Each of them asserts one census against one expected answer, and BOTH
454 /// would still pass if `classify` had been written to ignore the cause and
455 /// return a constant — the first passes on a constant `None`, the second
456 /// on a constant `Some(PollersUnreachable)`. Neither witnesses that the
457 /// CAUSE is what decides.
458 ///
459 /// This one does: two censuses identical in every field except the
460 /// exclusion cause must produce different answers. That is the property the
461 /// whole change exists for, and it cannot be satisfied by a constant.
462 #[test]
463 fn the_exclusion_cause_is_what_decides() {
464 let probation = pool(2, 0, 0);
465 let unreachable = pool(2, 0, 2);
466 assert_eq!(
467 probation.workers_in_pool, unreachable.workers_in_pool,
468 "precondition: the two pools differ ONLY in the exclusion cause"
469 );
470 assert_eq!(probation.compatible_workers, unreachable.compatible_workers);
471 assert_eq!(
472 probation.eligible_compatible_workers,
473 unreachable.eligible_compatible_workers
474 );
475
476 assert_ne!(
477 classify(QueueDeclaration::Declared, &probation),
478 classify(QueueDeclaration::Declared, &unreachable),
479 "a pool serving its probation and a pool that lost reachability must not get the \
480 same answer: one clears itself in seconds and the other never does, and the \
481 operator's remedies are different"
482 );
483 }
484
485 /// 🔴 THE CONTROL for the reporting test, and it is not optional.
486 ///
487 /// Without it, a `classify` hard-coded to report `PollersUnreachable`
488 /// whenever nothing is dispatchable would satisfy every test above AND
489 /// misdiagnose a genuinely empty pool — telling an operator that starting
490 /// the worker they need would not help, which is the opposite of the truth.
491 #[test]
492 fn a_genuinely_empty_pool_is_still_reported_as_having_no_pollers() {
493 assert_eq!(
494 classify(QueueDeclaration::Declared, &PoolCensus::default()),
495 Some(QueueServiceReason::NoLivePollers)
496 );
497 }
498
499 /// The second control: workers present that do NOT serve the activity are
500 /// still `PollersIncompatible`. The new arm sits ahead of that one, so a
501 /// mistake in its guard would swallow this case and send an operator to
502 /// read a liveness verdict for a worker whose only problem is that it does
503 /// not cover the activity at all.
504 #[test]
505 fn workers_that_do_not_serve_the_activity_are_still_incompatible_not_unreachable() {
506 let census = PoolCensus {
507 workers_in_pool: 3,
508 workers_serving_activity: 0,
509 compatible_workers: 0,
510 eligible_compatible_workers: 0,
511 compatible_workers_reachability_lost: 0,
512 last_compatible_poller_age: None,
513 compatible_workers_at_capacity: 0,
514 compatible_workers_capacity_unannounced: 0,
515 };
516 assert_eq!(
517 classify(QueueDeclaration::Declared, &census),
518 Some(QueueServiceReason::PollersIncompatible)
519 );
520 }
521
522 /// One eligible worker is enough: the address is served and the caller
523 /// simply retries, whatever the others are excluded for.
524 #[test]
525 fn one_eligible_worker_means_the_address_is_served() {
526 assert_eq!(classify(QueueDeclaration::Declared, &pool(3, 1, 2)), None);
527 }
528
529 #[test]
530 fn pollers_that_do_not_cover_the_activity_are_incompatible() {
531 let census = PoolCensus {
532 workers_in_pool: 2,
533 workers_serving_activity: 0,
534 compatible_workers: 0,
535 eligible_compatible_workers: 0,
536 compatible_workers_reachability_lost: 0,
537 last_compatible_poller_age: None,
538 compatible_workers_at_capacity: 0,
539 compatible_workers_capacity_unannounced: 0,
540 };
541 assert_eq!(
542 classify(QueueDeclaration::Declared, &census),
543 Some(QueueServiceReason::PollersIncompatible)
544 );
545 }
546
547 #[test]
548 fn pollers_off_the_pinned_node_are_incompatible() {
549 let census = PoolCensus {
550 workers_in_pool: 1,
551 workers_serving_activity: 1,
552 compatible_workers: 0,
553 eligible_compatible_workers: 0,
554 compatible_workers_reachability_lost: 0,
555 last_compatible_poller_age: Some(Duration::from_secs(9)),
556 compatible_workers_at_capacity: 0,
557 compatible_workers_capacity_unannounced: 0,
558 };
559 assert_eq!(
560 classify(QueueDeclaration::Declared, &census),
561 Some(QueueServiceReason::PollersIncompatible)
562 );
563 }
564}