aion_server/worker/registry/census.rs
1//! The registry's counting reads: the pool census one dispatch address is
2//! judged by, and the excluded-worker headcount a selection refusal quotes.
3//!
4//! Split out of `registry.rs`: both walk the pool under the SAME lock the
5//! selection they explain was taken under, and keeping them beside the
6//! registration and routing surfaces pushed that file past the per-file
7//! length budget.
8
9use std::collections::HashMap;
10use std::time::Duration;
11
12use crate::error::ServerError;
13use crate::worker::heartbeat::DispatchExclusion;
14use crate::worker::queue_service::PoolCensus;
15
16use super::capacity::worker_is_at_capacity;
17use super::reservation::worker_matches_node;
18use super::{ActivityKey, ConnectedWorkerRegistry, PoolAddress};
19
20impl ConnectedWorkerRegistry {
21 /// How many workers that selection COULD have chosen are currently excluded
22 /// from it by the liveness verdict (#197 R3).
23 ///
24 /// `tiers` is the ordered sequence of node filters the selection actually
25 /// walked, and taking it as an argument — rather than re-deriving one here
26 /// — is the whole point of the method. Selection over a `Pinned{L}`
27 /// namespace walks the required labels and NEVER spills to a `None`
28 /// any-node tier; a census taken over the row's own node instead would pass
29 /// `None`, match every worker in the pool, and report an unlabelled worker
30 /// that was never a candidate as the reason the row found nobody. The
31 /// refusal built on that count then tells an operator not to start the
32 /// labelled worker that is the only remedy.
33 ///
34 /// Counted as a UNION over the tiers and over DISTINCT workers: the pool is
35 /// walked once and a worker admissible to more than one tier is counted
36 /// once, so the number is a headcount rather than a sum of overlapping
37 /// matches.
38 ///
39 /// Read under the SAME lock and with the SAME `worker_matches_node` filter
40 /// [`Self::select_and_reserve`] applies, so a refusal quoting this count
41 /// describes the fleet selection actually saw.
42 ///
43 /// # Errors
44 ///
45 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
46 pub fn ineligible_workers_over_tiers(
47 &self,
48 namespace: &str,
49 task_queue: &str,
50 activity_type: &str,
51 tiers: &[Option<String>],
52 ) -> Result<usize, ServerError> {
53 let state = self.state()?;
54 let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
55 Ok(state.by_activity.get(&key).map_or(0, |workers| {
56 workers
57 .values()
58 .filter(|worker| state.dispatch_ineligible.contains_key(&worker.id))
59 .filter(|worker| {
60 tiers
61 .iter()
62 .any(|tier| worker_matches_node(worker, tier.as_deref()))
63 })
64 .count()
65 }))
66 }
67
68 /// Census the live fleet for one dispatch address (R1).
69 ///
70 /// Taken under the SAME lock discipline as [`Self::select_and_reserve`] and read
71 /// immediately after a selection miss, so the taxonomy verdict describes
72 /// the fleet selection actually saw. Three nested counts — the pool, the
73 /// activity coverage within it, the node coverage within that — are what
74 /// separate `NO_LIVE_POLLERS` from `POLLERS_INCOMPATIBLE`.
75 ///
76 /// Two further counts split the compatible workers by dispatch eligibility,
77 /// which is what separates a pool that is genuinely served from one whose
78 /// workers are all excluded, and — within that — an exclusion that clears
79 /// itself from one that does not (`POLLERS_UNREACHABLE`). They are taken
80 /// here, under this same lock, rather than by a second read: eligibility
81 /// can change between two acquisitions, and a verdict assembled from two
82 /// readings would describe a fleet that never existed at one instant.
83 ///
84 /// `last_compatible_poller_age` is zero while a compatible worker is
85 /// connected, the elapsed time since the most recent compatible departure
86 /// when one has left, and `None` when this server has never had one.
87 ///
88 /// # Errors
89 ///
90 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
91 pub fn pool_census(
92 &self,
93 namespace: &str,
94 task_queue: &str,
95 activity_type: &str,
96 node: Option<&str>,
97 ) -> Result<PoolCensus, ServerError> {
98 let state = self.state()?;
99 let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
100 let workers_in_pool = state
101 .workers
102 .values()
103 .filter(|worker| {
104 worker.task_queue == task_queue && worker.namespaces.contains(namespace)
105 })
106 .count();
107 let serving = state.by_activity.get(&key);
108 let workers_serving_activity = serving.map_or(0, HashMap::len);
109 let compatible: Vec<_> = serving.map_or_else(Vec::new, |workers| {
110 workers
111 .values()
112 .filter(|worker| worker_matches_node(worker, node))
113 .collect()
114 });
115 let compatible_workers = compatible.len();
116 // Counted here, under the SAME lock as the selection this census
117 // explains, because eligibility can change between two lock
118 // acquisitions and a verdict assembled from two readings would describe
119 // a fleet that never existed at one instant.
120 let eligible_compatible_workers = compatible
121 .iter()
122 .filter(|worker| !state.dispatch_ineligible.contains_key(&worker.id))
123 .filter(|worker| !worker_is_at_capacity(&state, worker))
124 .count();
125 let compatible_workers_reachability_lost = compatible
126 .iter()
127 .filter(|worker| {
128 matches!(
129 state.dispatch_ineligible.get(&worker.id),
130 Some(&DispatchExclusion::ReachabilityLost)
131 )
132 })
133 .count();
134 // Counted under the SAME lock as `eligible_compatible_workers` above and
135 // for the same reason: a busy pool and an unreachable one are different
136 // conditions with opposite remedies, and a verdict assembled from two
137 // readings would describe a fleet that never existed at one instant.
138 // DISJOINT counts, deliberately. `worker_is_at_capacity` is true of an
139 // unannounced worker as well as a full one — that is what makes
140 // selection fail closed — so counting "at capacity" with that predicate
141 // alone would fold an idle, mute worker into the busy population and the
142 // taxonomy would report a healthy-but-loaded pool for a worker that has
143 // simply never said anything. The census is what the operator sentence
144 // is derived from, so the split has to happen here, not there.
145 let compatible_workers_capacity_unannounced = compatible
146 .iter()
147 .filter(|worker| worker.max_concurrency.is_none())
148 .count();
149 let compatible_workers_at_capacity = compatible
150 .iter()
151 .filter(|worker| worker.max_concurrency.is_some())
152 .filter(|worker| worker_is_at_capacity(&state, worker))
153 .count();
154 let last_compatible_poller_age = if compatible_workers > 0 {
155 Some(Duration::ZERO)
156 } else {
157 state
158 .last_departure
159 .get(&key)
160 .and_then(|by_node| {
161 by_node
162 .iter()
163 .filter(|(departed_node, _)| match node {
164 None => true,
165 Some(node) => departed_node.as_deref() == Some(node),
166 })
167 .map(|(_, departed_at)| *departed_at)
168 .max()
169 })
170 .map(|departed_at| departed_at.elapsed())
171 };
172 Ok(PoolCensus {
173 workers_in_pool,
174 workers_serving_activity,
175 compatible_workers,
176 eligible_compatible_workers,
177 compatible_workers_reachability_lost,
178 compatible_workers_at_capacity,
179 compatible_workers_capacity_unannounced,
180 last_compatible_poller_age,
181 })
182 }
183}