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