Skip to main content

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}