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 /// How long ago a compatible worker was last in service, or `None` when
40 /// none has ever been seen for this address in this server's life.
41 ///
42 /// Zero while a compatible worker is connected right now.
43 pub last_compatible_poller_age: Option<Duration>,
44}
45
46impl PoolCensus {
47 /// Whether the address currently has a compatible worker REGISTERED,
48 /// whatever its dispatch eligibility.
49 ///
50 /// A field-level fact, not a decision. It stays deliberately blind to
51 /// eligibility because [`classify`] needs the blind count to tell an empty
52 /// pool from an excluded one (#197 R3). Callers asking "will a dispatch
53 /// aimed here actually proceed?" want [`Self::will_be_served`].
54 #[must_use]
55 pub const fn is_served(&self) -> bool {
56 self.compatible_workers > 0
57 }
58
59 /// Whether a dispatch aimed here can be expected to proceed without an
60 /// operator doing something first.
61 ///
62 /// 🔴 The distinction [`Self::is_served`] cannot draw. A pool whose workers
63 /// are all serving an opening probation is not dispatchable this instant
64 /// but will be within seconds, so it answers `true` — waiting is the
65 /// correct outcome and the caller should not be warned. A pool whose
66 /// workers have all LOST reachability answers `false`: nothing about it
67 /// improves on its own, so a caller told "served" would be told a run is
68 /// about to proceed when it is about to park indefinitely.
69 #[must_use]
70 pub const fn will_be_served(&self) -> bool {
71 self.eligible_compatible_workers > 0
72 || (self.compatible_workers > 0 && self.compatible_workers_reachability_lost == 0)
73 }
74}
75
76/// Classify one selection miss into the taxonomy.
77///
78/// Returns `None` when the caller must simply retry selection rather than
79/// report a state that is no longer true. That covers two cases: the address is
80/// genuinely served because a worker arrived between the two lock
81/// acquisitions, and the address's compatible workers are all serving an
82/// opening probation, which clears on its own within seconds.
83///
84/// 🔴 It does NOT cover a pool whose compatible workers have all LOST
85/// reachability. That does not clear on its own, so returning `None` there
86/// parks the dispatch indefinitely with nothing published about why — the
87/// silent stall this arm exists to prevent.
88///
89/// Ordering is deliberate: a structurally undeclared queue is reported as
90/// [`QueueServiceReason::NoQueueDeclaration`] even when unrelated workers sit
91/// in the pool, because that is the deeper fact and the only one that refuses
92/// unconditionally. [`QueueDeclaration::Unknown`] never manufactures a
93/// structural refusal — the same fail-open discipline worker registration
94/// applies when no catalog is reachable.
95///
96/// [`QueueServiceReason::Saturated`] is never produced here: it is not a
97/// selection miss. It is emitted by the delivery path
98/// ([`super::delivery::deliver_within_schedule_to_start`]) when a compatible
99/// worker is live and refuses intake past the schedule-to-start clock.
100#[must_use]
101pub fn classify(declaration: QueueDeclaration, census: &PoolCensus) -> Option<QueueServiceReason> {
102 if census.eligible_compatible_workers > 0 {
103 return None;
104 }
105 if declaration == QueueDeclaration::NotDeclared {
106 return Some(QueueServiceReason::NoQueueDeclaration);
107 }
108 if census.compatible_workers > 0 {
109 // Every compatible worker is excluded by the liveness verdict. Which
110 // exclusion decides whether this is worth saying: an opening probation
111 // is the ordinary cost of connecting and clears itself, and reporting
112 // it would fire on every healthy worker's first seconds.
113 //
114 // `PollersIncompatible` would be a LIE here and not merely a vague
115 // one — its sentence is "workers are connected but none serves this
116 // activity", and every one of these serves it. The remedy it implies
117 // (deploy a worker that covers the type) is wrong; the real remedy is
118 // to read the liveness verdict for workers already present.
119 if census.compatible_workers_reachability_lost > 0 {
120 return Some(QueueServiceReason::PollersUnreachable);
121 }
122 return None;
123 }
124 if census.workers_in_pool > 0 {
125 return Some(QueueServiceReason::PollersIncompatible);
126 }
127 Some(QueueServiceReason::NoLivePollers)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn a_served_address_is_not_classified() {
136 let census = PoolCensus {
137 workers_in_pool: 1,
138 workers_serving_activity: 1,
139 compatible_workers: 1,
140 eligible_compatible_workers: 1,
141 compatible_workers_reachability_lost: 0,
142 last_compatible_poller_age: Some(Duration::ZERO),
143 };
144 assert_eq!(classify(QueueDeclaration::Declared, &census), None);
145 assert_eq!(classify(QueueDeclaration::NotDeclared, &census), None);
146 }
147
148 #[test]
149 fn an_undeclared_queue_is_structural() {
150 assert_eq!(
151 classify(QueueDeclaration::NotDeclared, &PoolCensus::default()),
152 Some(QueueServiceReason::NoQueueDeclaration)
153 );
154 }
155
156 #[test]
157 fn an_empty_pool_on_a_declared_queue_has_no_live_pollers() {
158 assert_eq!(
159 classify(QueueDeclaration::Declared, &PoolCensus::default()),
160 Some(QueueServiceReason::NoLivePollers)
161 );
162 }
163
164 #[test]
165 fn an_unknowable_declaration_never_produces_a_structural_refusal() {
166 assert_eq!(
167 classify(QueueDeclaration::Unknown, &PoolCensus::default()),
168 Some(QueueServiceReason::NoLivePollers)
169 );
170 }
171
172 /// A pool of `compatible` workers of whom `eligible` can be dispatched to,
173 /// with `lost` of the remainder excluded for lost reachability rather than
174 /// for an opening probation.
175 fn pool(compatible: usize, eligible: usize, lost: usize) -> PoolCensus {
176 PoolCensus {
177 workers_in_pool: compatible,
178 workers_serving_activity: compatible,
179 compatible_workers: compatible,
180 eligible_compatible_workers: eligible,
181 compatible_workers_reachability_lost: lost,
182 last_compatible_poller_age: Some(Duration::ZERO),
183 }
184 }
185
186 /// A pool whose workers are all still serving their opening probation is
187 /// NOT reported: the exclusion clears itself within seconds, and reporting
188 /// it would fire on every healthy worker's first moments.
189 #[test]
190 fn a_pool_excluded_only_by_an_opening_probation_is_not_reported() {
191 assert_eq!(classify(QueueDeclaration::Declared, &pool(2, 0, 0)), None);
192 }
193
194 /// The same pool, excluded for lost reachability, IS reported — because
195 /// that exclusion does not clear on its own, so a dispatch parked on it
196 /// waits indefinitely with nothing said.
197 #[test]
198 fn a_pool_that_lost_reachability_is_reported() {
199 assert_eq!(
200 classify(QueueDeclaration::Declared, &pool(2, 0, 2)),
201 Some(QueueServiceReason::PollersUnreachable)
202 );
203 }
204
205 /// 🔴 THE DISCRIMINATOR, and the reason the two tests above are not enough
206 /// on their own.
207 ///
208 /// Each of them asserts one census against one expected answer, and BOTH
209 /// would still pass if `classify` had been written to ignore the cause and
210 /// return a constant — the first passes on a constant `None`, the second
211 /// on a constant `Some(PollersUnreachable)`. Neither witnesses that the
212 /// CAUSE is what decides.
213 ///
214 /// This one does: two censuses identical in every field except the
215 /// exclusion cause must produce different answers. That is the property the
216 /// whole change exists for, and it cannot be satisfied by a constant.
217 #[test]
218 fn the_exclusion_cause_is_what_decides() {
219 let probation = pool(2, 0, 0);
220 let unreachable = pool(2, 0, 2);
221 assert_eq!(
222 probation.workers_in_pool, unreachable.workers_in_pool,
223 "precondition: the two pools differ ONLY in the exclusion cause"
224 );
225 assert_eq!(probation.compatible_workers, unreachable.compatible_workers);
226 assert_eq!(
227 probation.eligible_compatible_workers,
228 unreachable.eligible_compatible_workers
229 );
230
231 assert_ne!(
232 classify(QueueDeclaration::Declared, &probation),
233 classify(QueueDeclaration::Declared, &unreachable),
234 "a pool serving its probation and a pool that lost reachability must not get the \
235 same answer: one clears itself in seconds and the other never does, and the \
236 operator's remedies are different"
237 );
238 }
239
240 /// 🔴 THE CONTROL for the reporting test, and it is not optional.
241 ///
242 /// Without it, a `classify` hard-coded to report `PollersUnreachable`
243 /// whenever nothing is dispatchable would satisfy every test above AND
244 /// misdiagnose a genuinely empty pool — telling an operator that starting
245 /// the worker they need would not help, which is the opposite of the truth.
246 #[test]
247 fn a_genuinely_empty_pool_is_still_reported_as_having_no_pollers() {
248 assert_eq!(
249 classify(QueueDeclaration::Declared, &PoolCensus::default()),
250 Some(QueueServiceReason::NoLivePollers)
251 );
252 }
253
254 /// The second control: workers present that do NOT serve the activity are
255 /// still `PollersIncompatible`. The new arm sits ahead of that one, so a
256 /// mistake in its guard would swallow this case and send an operator to
257 /// read a liveness verdict for a worker whose only problem is that it does
258 /// not cover the activity at all.
259 #[test]
260 fn workers_that_do_not_serve_the_activity_are_still_incompatible_not_unreachable() {
261 let census = PoolCensus {
262 workers_in_pool: 3,
263 workers_serving_activity: 0,
264 compatible_workers: 0,
265 eligible_compatible_workers: 0,
266 compatible_workers_reachability_lost: 0,
267 last_compatible_poller_age: None,
268 };
269 assert_eq!(
270 classify(QueueDeclaration::Declared, &census),
271 Some(QueueServiceReason::PollersIncompatible)
272 );
273 }
274
275 /// One eligible worker is enough: the address is served and the caller
276 /// simply retries, whatever the others are excluded for.
277 #[test]
278 fn one_eligible_worker_means_the_address_is_served() {
279 assert_eq!(classify(QueueDeclaration::Declared, &pool(3, 1, 2)), None);
280 }
281
282 #[test]
283 fn pollers_that_do_not_cover_the_activity_are_incompatible() {
284 let census = PoolCensus {
285 workers_in_pool: 2,
286 workers_serving_activity: 0,
287 compatible_workers: 0,
288 eligible_compatible_workers: 0,
289 compatible_workers_reachability_lost: 0,
290 last_compatible_poller_age: None,
291 };
292 assert_eq!(
293 classify(QueueDeclaration::Declared, &census),
294 Some(QueueServiceReason::PollersIncompatible)
295 );
296 }
297
298 #[test]
299 fn pollers_off_the_pinned_node_are_incompatible() {
300 let census = PoolCensus {
301 workers_in_pool: 1,
302 workers_serving_activity: 1,
303 compatible_workers: 0,
304 eligible_compatible_workers: 0,
305 compatible_workers_reachability_lost: 0,
306 last_compatible_poller_age: Some(Duration::from_secs(9)),
307 };
308 assert_eq!(
309 classify(QueueDeclaration::Declared, &census),
310 Some(QueueServiceReason::PollersIncompatible)
311 );
312 }
313}