aion_server/worker/registry/reservation.rs
1//! Claiming a worker's capacity slot for one dispatch, and the RAII holder
2//! that gives it back.
3//!
4//! Split out of `registry.rs`: choosing a worker and claiming the slot that
5//! justifies the next choice is one critical section with one owner, and
6//! keeping it beside the registration and routing surfaces pushed that file
7//! past the per-file length budget.
8
9use crate::error::ServerError;
10
11use super::capacity::worker_is_at_capacity;
12use super::{
13 ActivityKey, ConnectedWorkerRegistry, PoolAddress, RegistryState, WorkerHandle, WorkerId,
14};
15
16impl ConnectedWorkerRegistry {
17 /// Select a worker for a dispatch AND hold one of its capacity slots, both
18 /// under one acquisition of the registry lock.
19 ///
20 /// # Why selection alone was not enough
21 ///
22 /// Selection filters out a worker already at its advertised
23 /// concurrency, but the increment that makes a worker *reach* that
24 /// concurrency used to happen much later — at
25 /// [`HeartbeatTracker::track_task`](crate::worker::heartbeat::HeartbeatTracker::track_task),
26 /// once the pending entry, the superseded-attempt release and the lease
27 /// record were all done. Everything between was a window in which the
28 /// projection still read zero. A fan of N simultaneous dispatches onto one
29 /// worker therefore had all N threads read the same pre-dispatch count, all
30 /// N pass the capacity filter, and all N push — the filter was real but it
31 /// was reading a number nobody had claimed yet. The worker then refused the
32 /// surplus, and a refusal resolves in the TRANSPORT domain, whose ledger is
33 /// deliberately bounded: routine over-dispatch would spend that budget on a
34 /// worker that was merely busy and dead-letter the surplus. The saturation
35 /// the fix exists to survive would have produced the outage the fix exists
36 /// to prevent.
37 ///
38 /// Claiming the slot here closes the window by construction: the choice and
39 /// the count that justifies the next choice are one critical section, so the
40 /// second caller of a full worker sees a full worker.
41 ///
42 /// The returned [`DispatchReservation`] releases the slot when it is
43 /// dropped, which is what makes every early exit between here and tracking
44 /// safe without any of them knowing about capacity. Hold it across
45 /// `track_task` — which takes the durable count over — and drop it after;
46 /// the overlap is one extra held slot for the width of that call, which can
47 /// only park a dispatch that would otherwise have raced, never admit one.
48 ///
49 /// # Errors
50 ///
51 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
52 pub fn select_and_reserve(
53 &self,
54 namespace: &str,
55 task_queue: &str,
56 activity_type: &str,
57 node: Option<&str>,
58 ) -> Result<Option<(WorkerHandle, DispatchReservation)>, ServerError> {
59 let mut state = self.state()?;
60 let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
61 let Some(worker) = eligible_candidates_in_rotation(&mut state, key, node)
62 .into_iter()
63 .next()
64 else {
65 return Ok(None);
66 };
67 let worker_id = worker.id();
68 let held = state.in_flight.entry(worker_id).or_insert(0);
69 *held = held.saturating_add(1);
70 drop(state);
71 Ok(Some((
72 worker,
73 DispatchReservation {
74 registry: self.clone(),
75 worker_id,
76 committed: false,
77 },
78 )))
79 }
80
81 /// Claim one capacity slot on a NAMED worker, or report that it has none.
82 ///
83 /// The candidate-list counterpart of [`Self::select_and_reserve`]. Some
84 /// dispatch paths do not pick one worker from a pool — the outbox fan-out
85 /// walks a candidate list and pushes to the first that accepts — so they
86 /// cannot claim at selection. They claim HERE instead, per candidate, at the
87 /// moment they are about to push to that specific worker.
88 ///
89 /// `Ok(None)` means this worker cannot take the dispatch: it has left the
90 /// registry, its capacity is not yet announced, or it is already holding
91 /// every slot it advertised. The caller moves to the next candidate. That is
92 /// what makes the candidate list safe to have been computed earlier — a
93 /// worker that filled up between the selection and the push is refused here
94 /// rather than pushed past its own admission.
95 ///
96 /// Like `select_and_reserve`, the returned [`DispatchReservation`] releases
97 /// the slot when dropped, so a failed push gives it straight back and the
98 /// next candidate is considered against a true count.
99 ///
100 /// # Errors
101 ///
102 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
103 pub(in crate::worker) fn reserve_worker(
104 &self,
105 worker_id: WorkerId,
106 ) -> Result<Option<DispatchReservation>, ServerError> {
107 {
108 let mut state = self.state()?;
109 // UNKNOWN capacity is not a free slot. Same rule as selection: the
110 // server does not invent a number for a worker that has not yet
111 // said one. Copied out so the read of `workers` does not overlap the
112 // write to `in_flight` below.
113 let advertised = match state.workers.get(&worker_id) {
114 Some(worker) => match worker.max_concurrency {
115 Some(advertised) => advertised,
116 None => return Ok(None),
117 },
118 None => return Ok(None),
119 };
120 let held = state.in_flight.get(&worker_id).copied().unwrap_or(0);
121 if held >= advertised.get() {
122 // Deliberately does NOT insert on the refusal path: a worker
123 // that cannot take this dispatch should not gain a bookkeeping
124 // entry for having been asked.
125 return Ok(None);
126 }
127 state.in_flight.insert(worker_id, held.saturating_add(1));
128 }
129 Ok(Some(DispatchReservation {
130 registry: self.clone(),
131 worker_id,
132 committed: false,
133 }))
134 }
135}
136
137/// One capacity slot on one worker, held from selection until it is dropped.
138///
139/// Returned by [`ConnectedWorkerRegistry::select_and_reserve`]. Dropping it
140/// releases the slot and wakes the selection waits, exactly as a completion
141/// does — so a dispatch that dies anywhere between choosing its worker and
142/// handing the count to the heartbeat tracker gives the slot back without that
143/// path having to know a slot existed. That is the whole point: the release is
144/// owed by the type, not by every error arm.
145///
146/// # Handing the slot over: [`Self::commit`], and why it exists now
147///
148/// This type used to forbid a `commit`/`forget` escape, on the reasoning that
149/// the tracker's own increment would carry the slot afterwards and the two would
150/// merely overlap "for the width of one call". That was measured and it was
151/// wrong in a way that mattered.
152///
153/// The overlap is two separate acquisitions of the registry lock — the tracker
154/// increments under one, this type's `Drop` decrements under another — and
155/// between them the worker's `in_flight` reads ONE HIGHER than the number of
156/// dispatches it is actually holding. A concurrent leg that lands in that window
157/// asks [`ConnectedWorkerRegistry::reserve_worker`], is told `held >=
158/// advertised`, and is refused a slot the worker demonstrably has. Observed
159/// directly: `in_flight = 5` on a worker advertising 4, on a fan sized exactly
160/// to its pool — which is the normal shape, so the margin is zero and one
161/// collision is enough.
162///
163/// So the handover is now a single act with no intermediate state:
164/// [`Self::commit`] disarms this reservation and the tracker skips its own
165/// increment, because the slot this reservation already holds IS the slot the
166/// tracked dispatch holds. The count goes 1 → 1 rather than 1 → 2 → 1, and
167/// never passes through a value that is not true.
168///
169/// The original worry — a missed handoff silently under-counting a busy worker
170/// forever — is answered by making the transfer the ONLY way to commit: the
171/// tracker takes the reservation by value, so it either commits it or drops it,
172/// and a dropped reservation still releases.
173#[must_use = "dropping the reservation immediately releases the slot it was taken to hold"]
174#[derive(Debug)]
175pub struct DispatchReservation {
176 registry: ConnectedWorkerRegistry,
177 worker_id: WorkerId,
178 /// Set when the slot has been handed to the liveness tracker, which then
179 /// owns its release. An uncommitted reservation still releases on `Drop`.
180 committed: bool,
181}
182
183impl DispatchReservation {
184 /// The worker whose slot this reservation holds.
185 #[must_use]
186 pub fn worker_id(&self) -> WorkerId {
187 self.worker_id
188 }
189
190 /// Hand this slot to the liveness tracker without ever releasing it.
191 ///
192 /// Called by
193 /// [`HeartbeatTracker::track_task`](crate::worker::heartbeat::HeartbeatTracker::track_task)
194 /// and by nothing else — it takes the reservation by value, so the transfer
195 /// cannot be half-done. The tracker skips its own increment in exchange:
196 /// this reservation's `+1` becomes the tracked dispatch's `+1`, unchanged
197 /// and never released in between, so no concurrent selection can observe a
198 /// count higher than the work the worker is really holding.
199 pub(in crate::worker) fn commit(mut self) {
200 self.committed = true;
201 }
202}
203
204impl Drop for DispatchReservation {
205 fn drop(&mut self) {
206 // COMMITTED means the liveness tracker took this slot over without the
207 // count ever moving. Releasing here would decrement a slot the tracked
208 // dispatch is still holding.
209 if self.committed {
210 return;
211 }
212 // A poisoned registry lock cannot be propagated out of `Drop`, and it
213 // must not be swallowed: an unreleased slot is capacity this worker
214 // never gets back, which presents to an operator as a pool that
215 // silently shrinks. Report it with the worker named so the leak is
216 // attributable.
217 if let Err(error) = self.registry.record_dispatch_finished(self.worker_id) {
218 tracing::error!(
219 worker_id = ?self.worker_id,
220 %error,
221 "dispatch reservation could not release its capacity slot; this worker's \
222 selection capacity is now under-reported by one until it re-registers"
223 );
224 }
225 }
226}
227
228/// The candidates for one dispatch address: eligible, id-ordered, and rotated
229/// to begin at the pool's cursor, which this call advances by one.
230///
231/// This is the ONE selection derivation in the registry. Both
232/// [`ConnectedWorkerRegistry::workers_for`] — the gRPC push dispatcher's
233/// candidate list — and [`ConnectedWorkerRegistry::select_and_reserve`] — the
234/// NIF bridge's wait — read it,
235/// so the two cannot drift about who is dispatchable or about whose turn it is.
236/// They had drifted: one rotated but never consulted eligibility, the other
237/// enforced eligibility but always returned the lowest worker id, so a pool
238/// served over anything but the push leg sent all of its work to one worker.
239///
240/// Reachability is a dispatch PRECONDITION, so it is enforced here rather than
241/// discovered at push time. Selecting a worker the server cannot push to
242/// produces a dispatch that can only fail, and on the liminal transport it
243/// fails by consuming connection capacity — so an unreachable worker chosen
244/// anyway makes its own unreachability worse. That reasoning governs both
245/// selectors now, so it lives where both read it.
246///
247/// The id sort matters: `by_activity` holds workers in a `HashMap`, whose
248/// iteration order is unspecified. Sorting first makes the rotation the sole,
249/// deterministic source of ordering — true round-robin across calls with the
250/// same membership, not a wobble layered on hash order.
251///
252/// An empty eligible set returns empty WITHOUT creating or advancing a cursor.
253/// The cursor is keyed on arbitrary caller-supplied strings, and one minted for
254/// a pool with nobody to rotate is a leak nothing prunes: the prune in
255/// [`ConnectedWorkerRegistry::remove_worker_from_service`] fires only when an
256/// activity bucket empties, and a pool that never had a worker has no bucket to
257/// empty.
258///
259/// This does NOT count: `pool_census` and `ineligible_workers_over_tiers` answer
260/// "how many" and must never advance the cursor, so they keep their own filters
261/// and are deliberately not folded in here.
262pub(super) fn eligible_candidates_in_rotation(
263 state: &mut RegistryState,
264 key: ActivityKey,
265 node: Option<&str>,
266) -> Vec<WorkerHandle> {
267 let mut workers: Vec<WorkerHandle> = state
268 .by_activity
269 .get(&key)
270 .map(|workers| {
271 workers
272 .values()
273 .filter(|worker| worker_matches_node(worker, node))
274 .filter(|worker| !state.dispatch_ineligible.contains_key(&worker.id))
275 .filter(|worker| !worker_is_at_capacity(state, worker))
276 .cloned()
277 .collect()
278 })
279 .unwrap_or_default();
280 if workers.is_empty() {
281 return workers;
282 }
283 workers.sort_by_key(WorkerHandle::id);
284 let cursor = state.rotation.entry(key).or_insert(0);
285 let start = *cursor % workers.len();
286 *cursor = cursor.wrapping_add(1);
287 let mut rotated = Vec::with_capacity(workers.len());
288 rotated.extend_from_slice(&workers[start..]);
289 rotated.extend_from_slice(&workers[..start]);
290 rotated
291}
292
293/// Whether a worker satisfies an optional node filter. `None` (unpinned) matches
294/// every worker; `Some(node)` matches only a worker advertising that exact node
295/// (NODE affinity = require). A worker with no advertised node never matches a
296/// pinned dispatch.
297pub(super) fn worker_matches_node(worker: &WorkerHandle, node: Option<&str>) -> bool {
298 match node {
299 None => true,
300 Some(node) => worker.node() == Some(node),
301 }
302}