aion_server/worker/queue_service/wait.rs
1//! The selection wait, made honest.
2//!
3//! Before R1 this loop was: registry miss, one `tracing::info!`, block. It
4//! could not tell "served slowly" from "served by nobody", so an operator had
5//! nothing to alert on and a run could sit forever with no state.
6//!
7//! Now every miss is classified against the deployed contract records and the
8//! live poller census, published to the queue-service state, logged at WARN
9//! with the reason and the last-compatible-poller age, and resolved by the
10//! policy: structural unservability refuses at once, a fleet condition refuses
11//! when the service-availability deadline expires, and `durable_pending` parks
12//! visibly until a worker arrives.
13//!
14//! What did NOT change: the drain gate still runs first and still wins, and a
15//! server with no configured deadline still waits — loudly, and with a state an
16//! operator can read.
17//!
18//! That wait is unbounded only while the server is accepting work. The drain
19//! gate here is consulted once per iteration, so it can only refuse a dispatch
20//! that is *between* waits; releasing one that is already inside a wait is the
21//! caller's `park` contract below, and the caller owes it (#72).
22
23use std::time::{Duration, Instant};
24
25use aion_core::{ActivityId, WorkflowId};
26
27use super::census::{PoolCensus, classify};
28use super::declarations::QueueDeclarationSource;
29use super::policy::{QueueServiceConfig, QueueServicePolicy};
30use super::state::{Parked, QueueServiceState};
31use super::taxonomy::{
32 ExpiredClock, QueueServiceReason, ServiceAddress, WorkerUnavailable, millis,
33};
34use crate::worker::registry::{
35 ConnectedWorkerRegistry, DispatchReservation, WorkerArrival, WorkerHandle,
36};
37
38/// Age rendering for an address no compatible worker has ever served.
39const NEVER: &str = "never";
40
41/// Why selection ended without a worker.
42#[derive(Clone, Debug)]
43pub enum SelectionRefusal {
44 /// The queue is not being served and the policy/clock refused the dispatch.
45 Unavailable(Box<WorkerUnavailable>),
46 /// The server stopped accepting work — the drain gate's own reason,
47 /// untouched.
48 NotAccepting {
49 /// Reason reported by the drain gate.
50 reason: String,
51 },
52 /// The registry itself could not be read.
53 Registry {
54 /// Underlying registry failure, rendered.
55 reason: String,
56 },
57}
58
59impl SelectionRefusal {
60 /// The failure string handed back at the engine dispatch seam.
61 #[must_use]
62 pub fn reason_string(&self) -> String {
63 match self {
64 Self::Unavailable(unavailable) => unavailable.reason_string(),
65 Self::NotAccepting { reason } | Self::Registry { reason } => reason.clone(),
66 }
67 }
68}
69
70/// Everything the wait needs that outlives one iteration.
71pub struct ServiceWait<'a> {
72 /// Live connected-worker registry.
73 pub registry: &'a ConnectedWorkerRegistry,
74 /// Deployed queue declarations.
75 pub declarations: &'a QueueDeclarationSource,
76 /// Operator's policies and clocks.
77 pub config: &'a QueueServiceConfig,
78 /// Queryable unserved-queue state.
79 pub state: &'a QueueServiceState,
80 /// Address this dispatch needs served.
81 pub address: &'a ServiceAddress,
82 /// Owning workflow.
83 pub workflow_id: &'a WorkflowId,
84 /// Activity ordinal recorded in history.
85 pub activity_id: &'a ActivityId,
86 /// Cluster-event publisher an UNBOUNDED park announces itself on (#266
87 /// T4). `None` (isolated tests) emits nothing on the wire; like the
88 /// defaulted queue seams on the dispatchers, an unwired publisher loses
89 /// only the pushed echo — the WARN and the queryable unserved state can
90 /// never be switched off by wiring. Both production dispatchers share the
91 /// boot's one publisher here.
92 pub publisher: Option<&'a crate::cluster_publisher::ClusterEventPublisher>,
93}
94
95/// Select a worker for the address, or refuse with a typed reason.
96///
97/// `accepting` is the drain gate, consulted exactly where it always was —
98/// before anything else on a selection miss. `park` performs one wait for a
99/// worker arrival: `Some(budget)` must return by `budget` at the latest, `None`
100/// may wait indefinitely (the caller owns the runtime plumbing).
101///
102/// `park` must ALSO return once the server stops accepting work, in both arms.
103/// This loop cannot enforce that itself: it consults `accepting` between waits,
104/// never during one, so a `park` that only ends on a worker arrival makes a
105/// dispatch to an unserved queue unrefusable and — on the blocking pool tokio's
106/// runtime `Drop` joins — unexitable (#72).
107///
108/// The [`WorkerArrival`] handed to `park` is the arrival signal, and this loop
109/// takes it BEFORE the `select_worker` that missed — that ordering is the
110/// contract and it is why `park` receives a subscription rather than being
111/// trusted to take one. Both registry wake sources are
112/// `Notify::notify_waiters`, which stores no permit, so a subscription taken
113/// inside `park` would already have slept through any worker that registered
114/// during the selection above it. A `park` that awaits what it is given cannot
115/// sleep through an arrival that has already happened; a `park` that drops it
116/// and subscribes for itself re-opens the defect. A `park` with no executor in
117/// reach may drop it unused **only** if its wait is bounded by construction, so
118/// this loop re-consults selection without needing any signal at all.
119///
120/// # Errors
121///
122/// Returns [`SelectionRefusal`] when the dispatch is refused rather than
123/// served.
124pub fn select_worker_or_refuse(
125 wait: &ServiceWait<'_>,
126 accepting: &mut dyn FnMut() -> Result<(), String>,
127 park: &mut dyn FnMut(WorkerArrival, Option<Duration>),
128) -> Result<(WorkerHandle, DispatchReservation), SelectionRefusal> {
129 let started_at = Instant::now();
130 let address = wait.address;
131 let policy = wait
132 .config
133 .policy_for(&address.namespace, &address.task_queue);
134 let deadline = wait.config.availability_deadline_for(policy);
135 let mut reported: Option<QueueServiceReason> = None;
136 let outcome = loop {
137 // SUBSCRIBE BEFORE YOU LOOK. Taken here, ahead of the selection below
138 // and ahead of the census inside `observe_selection_miss`, so a
139 // registration or a published verdict landing anywhere in this iteration
140 // is retained by the `park` at the bottom rather than fired into an
141 // empty waiter list. On the hit path it is dropped unawaited, which
142 // costs one allocation and unlinks the waiter — subscribing later to
143 // save that is the bug this ordering exists to prevent.
144 let arrival = wait.registry.worker_arrival();
145 // `node` is the OPTIONAL within-pool affinity carried on the dispatch:
146 // `Some(n)` pins selection to workers advertising node `n`; `None` is
147 // unpinned and reaches any worker in the (namespace, task_queue) pool.
148 // RESERVING selector, not the plain one. The slot is claimed inside the
149 // same lock acquisition that chose the worker, so the next caller round
150 // this loop cannot re-choose a worker this one has already filled. A
151 // plain `select_worker` here would let a simultaneous fan all read the
152 // same pre-dispatch count and all push past the advertised concurrency.
153 match wait.registry.select_and_reserve(
154 &address.namespace,
155 &address.task_queue,
156 &address.activity_type,
157 address.node.as_deref(),
158 ) {
159 Ok(Some((worker, reservation))) => {
160 if let Some(reason) = reported {
161 tracing::info!(
162 namespace = %address.namespace,
163 task_queue = %address.task_queue,
164 activity_type = %address.activity_type,
165 workflow_id = %wait.workflow_id,
166 activity_id = %wait.activity_id,
167 queue_service_reason = reason.as_str(),
168 waited_ms = millis(started_at.elapsed()),
169 "queue service restored; the parked dispatch has a worker"
170 );
171 }
172 break Ok((worker, reservation));
173 }
174 Ok(None) => {
175 if let Err(reason) = accepting() {
176 break Err(SelectionRefusal::NotAccepting { reason });
177 }
178 let waited = started_at.elapsed();
179 let observed =
180 match observe_selection_miss(wait, policy, deadline, waited, reported) {
181 Ok(Some(observed)) => observed,
182 // A worker arrived between the miss and the census: the
183 // state we would report is already untrue, so re-select
184 // rather than announce it.
185 Ok(None) => continue,
186 Err(refusal) => break Err(refusal),
187 };
188 let (reason, census) = (observed.reason, observed.census);
189 reported = Some(reason);
190 if reason.is_structural() {
191 // Unconditional: no policy admits work onto a queue no
192 // deployment declares, and no clock can change the answer.
193 break Err(unavailable(address, reason, None, waited, census));
194 }
195 match deadline {
196 Some(deadline) => {
197 let Some(remaining) = deadline
198 .checked_sub(waited)
199 .filter(|remaining| !remaining.is_zero())
200 else {
201 break Err(unavailable(
202 address,
203 reason,
204 Some(ExpiredClock::ServiceAvailability),
205 waited,
206 census,
207 ));
208 };
209 park(arrival, Some(remaining));
210 }
211 None => park(arrival, None),
212 }
213 }
214 Err(error) => {
215 break Err(SelectionRefusal::Registry {
216 reason: format!("registry error: {error}"),
217 });
218 }
219 }
220 };
221 clear_unserved(wait);
222 outcome
223}
224
225/// What one classified selection miss turned out to be.
226pub struct ObservedMiss {
227 /// The classified reason this address is not being served.
228 pub reason: QueueServiceReason,
229 /// The live poller census the classification was made against.
230 pub census: PoolCensus,
231}
232
233/// Classify one selection miss, publish it to the queryable unserved state, and
234/// state it in the log.
235///
236/// This is the whole of "park VISIBLY", factored out so it has exactly ONE
237/// implementation. There are two dispatch loops in this server — the blocking
238/// selection wait below, and the async gRPC outbox leg — and the second was
239/// written before this machinery existed and never adopted it. A dispatch that
240/// parked there was invisible: no state to query, one `info!` line, and no
241/// reachable dead-letter path. Duplicating the classification to fix that would
242/// have guaranteed the two drifted, so both call this.
243///
244/// It deliberately decides NOTHING about whether to keep waiting. Bounding an
245/// unserved dispatch is a semantics decision that belongs to the operator, and
246/// a bound invented here would refuse work nobody asked to have refused.
247///
248/// `Ok(None)` means a worker arrived between the miss and the census, so there
249/// is nothing true left to report — the caller should re-select rather than
250/// announce a state that is already untrue.
251///
252/// # Errors
253///
254/// Returns [`SelectionRefusal::Registry`] when the census itself cannot be read.
255pub fn observe_selection_miss(
256 wait: &ServiceWait<'_>,
257 policy: QueueServicePolicy,
258 deadline: Option<Duration>,
259 waited: Duration,
260 reported: Option<QueueServiceReason>,
261) -> Result<Option<ObservedMiss>, SelectionRefusal> {
262 let address = wait.address;
263 let census = wait
264 .registry
265 .pool_census(
266 &address.namespace,
267 &address.task_queue,
268 &address.activity_type,
269 address.node.as_deref(),
270 )
271 .map_err(|error| SelectionRefusal::Registry {
272 reason: format!("registry error: {error}"),
273 })?;
274 let declaration = wait.declarations.declaration_for(&address.task_queue);
275 let Some(reason) = classify(declaration, &census) else {
276 return Ok(None);
277 };
278 let repeat = reported == Some(reason);
279 mark_unserved(wait, reason, policy, census);
280 report(&Report {
281 wait,
282 reason,
283 policy,
284 census,
285 waited,
286 deadline,
287 repeat,
288 });
289 // Only the UNBOUNDED park is pushed to the cluster channel: a
290 // deadline-bounded park expires into a typed refusal that reaches the
291 // workflow's own history, while this class otherwise ends nowhere an
292 // operator is pushed to (#266 T4). Same transition rule as the WARN:
293 // first classification and reason changes emit, repeats are silent.
294 if deadline.is_none() && !repeat {
295 emit_unbounded_park(wait, reason, policy, &census, waited);
296 }
297 Ok(Some(ObservedMiss { reason, census }))
298}
299
300/// Clear one dispatch's unserved entry once it is served or refused.
301pub fn clear_selection_miss(wait: &ServiceWait<'_>) {
302 clear_unserved(wait);
303}
304
305fn unavailable(
306 address: &ServiceAddress,
307 reason: QueueServiceReason,
308 clock: Option<ExpiredClock>,
309 waited: Duration,
310 census: PoolCensus,
311) -> SelectionRefusal {
312 SelectionRefusal::Unavailable(Box::new(WorkerUnavailable {
313 reason,
314 clock,
315 waited,
316 address: address.clone(),
317 census,
318 }))
319}
320
321fn mark_unserved(
322 wait: &ServiceWait<'_>,
323 reason: QueueServiceReason,
324 policy: QueueServicePolicy,
325 census: PoolCensus,
326) {
327 if let Err(error) = wait.state.mark(Parked {
328 address: wait.address,
329 reason,
330 policy,
331 census,
332 workflow_id: wait.workflow_id,
333 activity_id: wait.activity_id,
334 }) {
335 tracing::error!(%error, "failed to publish the unserved queue state");
336 }
337}
338
339/// Announce one unbounded-park transition on the deployment's cluster channel.
340///
341/// The taxonomy reason and policy cross as their canonical spellings because
342/// `aion-core` must not depend on this crate's enums; every other field is the
343/// same data the WARN and the queryable state already carry. An unwired
344/// publisher (isolated tests) emits nothing — the honesty contract on
345/// [`ServiceWait::publisher`] — and a publisher with no live subscribers is
346/// the calm single-node case, not an error.
347fn emit_unbounded_park(
348 wait: &ServiceWait<'_>,
349 reason: QueueServiceReason,
350 policy: QueueServicePolicy,
351 census: &PoolCensus,
352 waited: Duration,
353) {
354 let Some(publisher) = wait.publisher else {
355 return;
356 };
357 let address = wait.address;
358 publisher.emit(|meta| aion_core::ClusterEvent::DispatchParked {
359 meta,
360 namespace: address.namespace.clone(),
361 task_queue: address.task_queue.clone(),
362 activity_type: address.activity_type.clone(),
363 node: address.node.clone(),
364 reason: reason.as_str().to_owned(),
365 policy: policy.as_str().to_owned(),
366 workflow_id: wait.workflow_id.clone(),
367 activity_id: wait.activity_id.clone(),
368 waited_ms: millis(waited),
369 workers_in_pool: census.workers_in_pool,
370 workers_serving_activity: census.workers_serving_activity,
371 compatible_workers: census.compatible_workers,
372 last_compatible_poller_age_ms: census.last_compatible_poller_age.map(millis),
373 });
374}
375
376fn clear_unserved(wait: &ServiceWait<'_>) {
377 if let Err(error) = wait
378 .state
379 .clear(wait.address, wait.workflow_id, wait.activity_id)
380 {
381 tracing::error!(%error, "failed to clear the unserved queue state");
382 }
383}
384
385struct Report<'a> {
386 wait: &'a ServiceWait<'a>,
387 reason: QueueServiceReason,
388 policy: QueueServicePolicy,
389 census: PoolCensus,
390 waited: Duration,
391 deadline: Option<Duration>,
392 repeat: bool,
393}
394
395/// State the park at WARN the first time, and whenever the verdict changes.
396///
397/// Repeats drop to DEBUG: a queue nobody serves would otherwise emit a WARN on
398/// every poll for as long as the run lives, which is how a real signal becomes
399/// noise nobody reads. The escalation an operator sees is INFO (the old,
400/// unreasoned line) becoming WARN (this one, with the reason and the age).
401fn report(report: &Report<'_>) {
402 let Report {
403 wait,
404 reason,
405 policy,
406 census,
407 waited,
408 deadline,
409 repeat,
410 } = report;
411 let address = wait.address;
412 let age = census
413 .last_compatible_poller_age
414 .map_or_else(|| NEVER.to_owned(), |age| millis(age).to_string());
415 let deadline_ms = deadline.map_or_else(|| NEVER.to_owned(), |value| millis(value).to_string());
416 if *repeat {
417 tracing::debug!(
418 namespace = %address.namespace,
419 task_queue = %address.task_queue,
420 activity_type = %address.activity_type,
421 node = address.node.as_deref(),
422 workflow_id = %wait.workflow_id,
423 activity_id = %wait.activity_id,
424 queue_service_reason = reason.as_str(),
425 queue_service_policy = policy.as_str(),
426 last_compatible_poller_age_ms = %age,
427 waited_ms = millis(*waited),
428 "queue still unserved"
429 );
430 return;
431 }
432 tracing::warn!(
433 namespace = %address.namespace,
434 task_queue = %address.task_queue,
435 activity_type = %address.activity_type,
436 node = address.node.as_deref(),
437 workflow_id = %wait.workflow_id,
438 activity_id = %wait.activity_id,
439 queue_service_reason = reason.as_str(),
440 queue_service_policy = policy.as_str(),
441 last_compatible_poller_age_ms = %age,
442 service_availability_deadline_ms = %deadline_ms,
443 waited_ms = millis(*waited),
444 workers_in_pool = census.workers_in_pool,
445 workers_serving_activity = census.workers_serving_activity,
446 compatible_workers = census.compatible_workers,
447 "dispatch is parked on a queue that is not being served"
448 );
449}
450
451#[cfg(test)]
452mod tests {
453 use aion_core::{ActivityId, WorkflowId};
454
455 use crate::worker::registry::ConnectedWorkerRegistry;
456
457 use super::*;
458
459 /// T4 — the finding on the wait path, which is how the NIF bridge reaches
460 /// selection (`bridge.rs::select_worker_or_wait` →
461 /// `bridge.rs::dispatch`/`dispatch_blocking`).
462 ///
463 /// `select_worker` took the lowest matching worker id, so every activity a
464 /// bridge-dispatched workflow scheduled into a pool went to the same worker
465 /// and the rest of the pool idled. It now returns the candidate at the pool's
466 /// rotation cursor, so consecutive waits walk the pool.
467 ///
468 /// With two live workers the loop breaks on the first `Ok(Some(worker))`, so
469 /// neither the drain gate nor the park is ever consulted — the closures below
470 /// are the contract's shape, not part of what is under test.
471 #[test]
472 fn the_wait_rotates_across_live_workers() -> Result<(), Box<dyn std::error::Error>> {
473 let registry = ConnectedWorkerRegistry::default();
474 let types = [String::from("greet")];
475 let (first_tx, _first_rx) = tokio::sync::mpsc::channel(1);
476 let (second_tx, _second_rx) = tokio::sync::mpsc::channel(1);
477 let first = registry.register_namespaces(
478 [String::from("default")],
479 "general",
480 None,
481 types.iter(),
482 first_tx,
483 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
484 )?;
485 let second = registry.register_namespaces(
486 [String::from("default")],
487 "general",
488 None,
489 types.iter(),
490 second_tx,
491 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
492 )?;
493 let first_id = first
494 .worker_id()
495 .ok_or("registration assigned no worker id")?;
496 let second_id = second
497 .worker_id()
498 .ok_or("registration assigned no worker id")?;
499
500 let declarations = QueueDeclarationSource::default();
501 let config = QueueServiceConfig::default();
502 let state = QueueServiceState::default();
503 let address = ServiceAddress {
504 namespace: String::from("default"),
505 task_queue: String::from("general"),
506 activity_type: String::from("greet"),
507 node: None,
508 };
509 let workflow_id = WorkflowId::new_v4();
510 let activity_id = ActivityId::from_sequence_position(1);
511 let wait = ServiceWait {
512 registry: ®istry,
513 declarations: &declarations,
514 config: &config,
515 state: &state,
516 address: &address,
517 workflow_id: &workflow_id,
518 activity_id: &activity_id,
519 publisher: None,
520 };
521 let mut accepting = || Ok::<(), String>(());
522 let mut park = |_arrival: WorkerArrival, _budget: Option<Duration>| {};
523
524 let mut selected = Vec::new();
525 for _ in 0..4 {
526 let (worker, reservation) = select_worker_or_refuse(&wait, &mut accepting, &mut park)
527 .map_err(|refusal| refusal.reason_string())?;
528 selected.push(worker.id());
529 // Released before the next selection: the subject here is the
530 // rotation cursor, not capacity, and a held slot would change which
531 // worker the next round is even allowed to pick.
532 drop(reservation);
533 }
534 assert_eq!(
535 selected,
536 vec![first_id, second_id, first_id, second_id],
537 "the wait must rotate across the live workers rather than name the lowest id every \
538 time"
539 );
540
541 first.deregister()?;
542 second.deregister()?;
543 Ok(())
544 }
545}