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}