Skip to main content

aion_server/worker/registry/
capacity.rs

1//! Worker capacity accounting: what a registration advertises, what the
2//! server records a worker as announcing later, and how many dispatches it is
3//! currently holding.
4//!
5//! Split out of `registry.rs`: capacity is one ledger with one invariant —
6//! UNKNOWN counts as full, and a worker at its advertised number is never
7//! selected — and keeping it beside the registration and routing surfaces
8//! pushed that file past the per-file length budget.
9
10use std::num::NonZeroU32;
11
12use aion_core::WorkerTransport;
13use aion_proto::ProtoRegisterWorker;
14
15use crate::error::ServerError;
16
17use super::{ConnectedWorkerRegistry, RegistryState, WorkerHandle, WorkerId};
18
19impl ConnectedWorkerRegistry {
20    /// Record the capacity a worker announced after registering, and WAKE the
21    /// selection waits.
22    ///
23    /// The liminal counterpart of the gRPC `RegisterWorker.max_concurrency`
24    /// field. That transport's registration frame is a published wire type with
25    /// no capacity field, so a liminal worker states its configured
26    /// `max_concurrency` in the announcement it publishes on the reserved
27    /// capabilities channel one frame later, and this applies it to the live
28    /// handle selection reads.
29    ///
30    /// Until this lands, the worker's capacity is `None` and selection treats it
31    /// as full — so this call is what makes a liminal worker dispatchable at
32    /// all, which is why it wakes the parked selections the way a completion
33    /// does. A worker parked on could otherwise sit until an unrelated registry
34    /// change happened to fire.
35    ///
36    /// Returns `false` when the worker is no longer registered (a disconnect
37    /// racing the announcement — benign).
38    ///
39    /// # Errors
40    ///
41    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned,
42    /// or [`ServerError::Wire`] if the worker announced a capacity of zero —
43    /// refused by name here exactly as the registration funnel refuses it, so
44    /// the later channel cannot admit what the earlier one rejects.
45    pub fn set_advertised_capacity(
46        &self,
47        worker_id: WorkerId,
48        max_concurrency: u32,
49    ) -> Result<bool, ServerError> {
50        let advertised = NonZeroU32::new(max_concurrency).ok_or_else(|| ServerError::Wire {
51            wire: aion_proto::WireError::backend(
52                "worker announced max_concurrency 0; a worker that runs no activities could \
53                 never be selected for a dispatch",
54            ),
55        })?;
56        {
57            let mut state = self.state()?;
58            if !state.workers.contains_key(&worker_id) {
59                return Ok(false);
60            }
61            if let Some(handle) = state.workers.get_mut(&worker_id) {
62                handle.max_concurrency = Some(advertised);
63            }
64            // The selection index holds handle CLONES and is what
65            // `worker_is_at_capacity` actually reads. Updating only the primary
66            // map would leave the worker permanently unknown-capacity — that is,
67            // permanently unselectable — while the console reported it fine.
68            for workers in state.by_activity.values_mut() {
69                if let Some(handle) = workers.get_mut(&worker_id) {
70                    handle.max_concurrency = Some(advertised);
71                }
72            }
73        }
74        self.worker_arrived.notify_waiters();
75        Ok(true)
76    }
77
78    /// Record that `worker_id` has taken one more dispatch.
79    ///
80    /// Called by [`HeartbeatTracker::track_task`](crate::worker::heartbeat::HeartbeatTracker::track_task)
81    /// and by nothing else: the tracker takes this registry as an argument so
82    /// the liveness record and the selection projection are written by one
83    /// call and cannot be updated apart. A worker that is no longer registered
84    /// records nothing — its entry would be unreachable capacity nobody frees.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
89    pub(in crate::worker) fn record_dispatch_started(
90        &self,
91        worker_id: WorkerId,
92    ) -> Result<(), ServerError> {
93        let mut state = self.state()?;
94        if !state.workers.contains_key(&worker_id) {
95            return Ok(());
96        }
97        let held = state.in_flight.entry(worker_id).or_insert(0);
98        *held = held.saturating_add(1);
99        Ok(())
100    }
101
102    /// Record that `worker_id` has released one dispatch, and WAKE the
103    /// selection waits.
104    ///
105    /// The wake is the half that is easy to forget. A dispatch parked because
106    /// every compatible worker is at capacity is blocked on exactly this event
107    /// and on nothing else — no worker is going to register, and no reachability
108    /// verdict is going to change — so a completion that corrected the count
109    /// without waking would leave that dispatch asleep until some unrelated
110    /// registry change happened to fire. It is unconditional for the same
111    /// reason [`Self::set_dispatch_ineligible`]'s is: this method cannot see
112    /// which parked dispatch the freed slot serves, and a wake with nobody
113    /// parked costs one waiterless `notify_waiters`.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
118    pub(in crate::worker) fn record_dispatch_finished(
119        &self,
120        worker_id: WorkerId,
121    ) -> Result<(), ServerError> {
122        {
123            let mut state = self.state()?;
124            if let Some(held) = state.in_flight.get_mut(&worker_id) {
125                *held = held.saturating_sub(1);
126                if *held == 0 {
127                    state.in_flight.remove(&worker_id);
128                }
129            }
130        }
131        self.worker_arrived.notify_waiters();
132        // A FREED SLOT IS NEWS TO THE OUTBOX LOOP TOO. The selection waits are
133        // woken by `worker_arrived`; a row answered "busy" and parked is waiting
134        // on exactly this event and has no other way to hear it — its durable
135        // re-offer rides the FAILURE backoff, which is the wrong clock for a
136        // condition that clears in the time one activity takes.
137        //
138        // `notify_one` is what makes the non-racing case safe: a pulse with
139        // nobody currently waiting STORES a permit, so a dispatcher that is
140        // mid-sweep when a slot frees still finds the wake when it next selects.
141        // A burst collapses into one permit, and so into one sweep. The racing
142        // case — a pulse landing while the busy arm is still writing its durable
143        // re-arm — is closed on the dispatcher side, by parking the row before
144        // that write.
145        if let Some(wake) = &self.capacity_wake {
146            wake.notify_one();
147        }
148        Ok(())
149    }
150
151    /// How many dispatches `worker_id` is currently holding, as selection sees
152    /// it.
153    ///
154    /// Exposed so a test can hold this against the heartbeat tracker's own
155    /// per-worker count: the two are written by one call, and a divergence
156    /// between them is the drift this projection would otherwise be able to
157    /// hide.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
162    pub fn in_flight_for_worker(&self, worker_id: WorkerId) -> Result<u32, ServerError> {
163        Ok(self
164            .state()?
165            .in_flight
166            .get(&worker_id)
167            .copied()
168            .unwrap_or(0))
169    }
170}
171
172/// The capacity a registration advertises, or a typed refusal naming the field.
173///
174/// This half answers the WIRE question — did the worker say anything at all —
175/// and it is separate from the zero check in
176/// [`ConnectedWorkerRegistry::register_delivery`] because the two are different
177/// findings that deserve different words. An ABSENT value is a worker built
178/// against a contract it does not implement; a ZERO one is a worker claiming it
179/// will run nothing, which every registration path can produce and which is
180/// therefore checked at the funnel they share.
181///
182/// Neither is defaulted. `max_concurrency` decides how much work a process is
183/// handed, and a server that supplies its own answer for it is guessing about
184/// the one party that knows.
185///
186/// # Errors
187///
188/// Returns [`ServerError::Wire`] naming `max_concurrency`.
189pub(super) fn advertised_capacity(
190    registration: &ProtoRegisterWorker,
191    transport: WorkerTransport,
192) -> Result<Option<u32>, ServerError> {
193    if let Some(advertised) = registration.max_concurrency {
194        return Ok(Some(advertised));
195    }
196    match transport {
197        // The gRPC registration frame HAS the field, so its absence is a worker
198        // that declined to state its capacity, and the server will not invent
199        // one for it.
200        WorkerTransport::Grpc => Err(ServerError::Wire {
201            wire: aion_proto::WireError::backend(
202                "worker registration carries no max_concurrency; every worker advertises the \
203                 number of activities it runs at once, and the server will not invent one for it",
204            ),
205        }),
206        // The liminal registration frame has no such field to carry — the
207        // worker is not withholding anything, the wire cannot express it. Its
208        // capacity arrives on the capabilities channel one frame later; until
209        // then the worker is registered with capacity UNKNOWN and selection
210        // passes it over.
211        //
212        // NOT `#[cfg]`-gated: `WorkerTransport` lives in `aion-core` and always
213        // has both variants, whatever this crate's features say. Gating the arm
214        // would make this match non-exhaustive in a default build.
215        WorkerTransport::Liminal => Ok(None),
216    }
217}
218
219/// Whether a worker is already holding every dispatch it advertised it would
220/// run at once.
221///
222/// Capacity is a dispatch PRECONDITION for the same reason reachability is, and
223/// it is enforced at selection for the same reason: a worker chosen beyond its
224/// own admission produces a dispatch that cannot start. Before this filter, the
225/// only backpressure was the worker's stream channel filling — which the
226/// dispatch arm reads as "merely busy" and the liveness arm reads as
227/// "unreachable", so the same saturated worker was simultaneously pushed to and
228/// judged unreachable, then reaped.
229///
230/// `>=` rather than `==`: a count that has drifted above the advertised number
231/// (a redelivery joining an outstanding generation) must still exclude the
232/// worker rather than fall through an equality test.
233pub(super) fn worker_is_at_capacity(state: &RegistryState, worker: &WorkerHandle) -> bool {
234    // UNKNOWN counts as full. A worker whose capacity has not been advertised
235    // yet is one the server cannot dispatch to without inventing a number for
236    // it, so it is passed over until it says. This is the safe direction: the
237    // condition clears itself one frame later, whereas a guess that runs high
238    // pushes a worker past its own admission and a guess that runs low
239    // serializes a worker that fans.
240    let Some(advertised) = worker.max_concurrency else {
241        return true;
242    };
243    state
244        .in_flight
245        .get(&worker.id)
246        .is_some_and(|held| *held >= advertised.get())
247}