Skip to main content

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}