Skip to main content

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}