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, 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/// # Errors
107///
108/// Returns [`SelectionRefusal`] when the dispatch is refused rather than
109/// served.
110pub fn select_worker_or_refuse(
111 wait: &ServiceWait<'_>,
112 accepting: &mut dyn FnMut() -> Result<(), String>,
113 park: &mut dyn FnMut(Option<Duration>),
114) -> Result<WorkerHandle, SelectionRefusal> {
115 let started_at = Instant::now();
116 let address = wait.address;
117 let policy = wait
118 .config
119 .policy_for(&address.namespace, &address.task_queue);
120 let deadline = wait.config.availability_deadline_for(policy);
121 let mut reported: Option<QueueServiceReason> = None;
122 let outcome = loop {
123 // `node` is the OPTIONAL within-pool affinity carried on the dispatch:
124 // `Some(n)` pins selection to workers advertising node `n`; `None` is
125 // unpinned and reaches any worker in the (namespace, task_queue) pool.
126 match wait.registry.select_worker(
127 &address.namespace,
128 &address.task_queue,
129 &address.activity_type,
130 address.node.as_deref(),
131 ) {
132 Ok(Some(worker)) => {
133 if let Some(reason) = reported {
134 tracing::info!(
135 namespace = %address.namespace,
136 task_queue = %address.task_queue,
137 activity_type = %address.activity_type,
138 workflow_id = %wait.workflow_id,
139 activity_id = %wait.activity_id,
140 queue_service_reason = reason.as_str(),
141 waited_ms = millis(started_at.elapsed()),
142 "queue service restored; the parked dispatch has a worker"
143 );
144 }
145 break Ok(worker);
146 }
147 Ok(None) => {
148 if let Err(reason) = accepting() {
149 break Err(SelectionRefusal::NotAccepting { reason });
150 }
151 let waited = started_at.elapsed();
152 let observed =
153 match observe_selection_miss(wait, policy, deadline, waited, reported) {
154 Ok(Some(observed)) => observed,
155 // A worker arrived between the miss and the census: the
156 // state we would report is already untrue, so re-select
157 // rather than announce it.
158 Ok(None) => continue,
159 Err(refusal) => break Err(refusal),
160 };
161 let (reason, census) = (observed.reason, observed.census);
162 reported = Some(reason);
163 if reason.is_structural() {
164 // Unconditional: no policy admits work onto a queue no
165 // deployment declares, and no clock can change the answer.
166 break Err(unavailable(address, reason, None, waited, census));
167 }
168 match deadline {
169 Some(deadline) => {
170 let Some(remaining) = deadline
171 .checked_sub(waited)
172 .filter(|remaining| !remaining.is_zero())
173 else {
174 break Err(unavailable(
175 address,
176 reason,
177 Some(ExpiredClock::ServiceAvailability),
178 waited,
179 census,
180 ));
181 };
182 park(Some(remaining));
183 }
184 None => park(None),
185 }
186 }
187 Err(error) => {
188 break Err(SelectionRefusal::Registry {
189 reason: format!("registry error: {error}"),
190 });
191 }
192 }
193 };
194 clear_unserved(wait);
195 outcome
196}
197
198/// What one classified selection miss turned out to be.
199pub struct ObservedMiss {
200 /// The classified reason this address is not being served.
201 pub reason: QueueServiceReason,
202 /// The live poller census the classification was made against.
203 pub census: PoolCensus,
204}
205
206/// Classify one selection miss, publish it to the queryable unserved state, and
207/// state it in the log.
208///
209/// This is the whole of "park VISIBLY", factored out so it has exactly ONE
210/// implementation. There are two dispatch loops in this server — the blocking
211/// selection wait below, and the async gRPC outbox leg — and the second was
212/// written before this machinery existed and never adopted it. A dispatch that
213/// parked there was invisible: no state to query, one `info!` line, and no
214/// reachable dead-letter path. Duplicating the classification to fix that would
215/// have guaranteed the two drifted, so both call this.
216///
217/// It deliberately decides NOTHING about whether to keep waiting. Bounding an
218/// unserved dispatch is a semantics decision that belongs to the operator, and
219/// a bound invented here would refuse work nobody asked to have refused.
220///
221/// `Ok(None)` means a worker arrived between the miss and the census, so there
222/// is nothing true left to report — the caller should re-select rather than
223/// announce a state that is already untrue.
224///
225/// # Errors
226///
227/// Returns [`SelectionRefusal::Registry`] when the census itself cannot be read.
228pub fn observe_selection_miss(
229 wait: &ServiceWait<'_>,
230 policy: QueueServicePolicy,
231 deadline: Option<Duration>,
232 waited: Duration,
233 reported: Option<QueueServiceReason>,
234) -> Result<Option<ObservedMiss>, SelectionRefusal> {
235 let address = wait.address;
236 let census = wait
237 .registry
238 .pool_census(
239 &address.namespace,
240 &address.task_queue,
241 &address.activity_type,
242 address.node.as_deref(),
243 )
244 .map_err(|error| SelectionRefusal::Registry {
245 reason: format!("registry error: {error}"),
246 })?;
247 let declaration = wait.declarations.declaration_for(&address.task_queue);
248 let Some(reason) = classify(declaration, &census) else {
249 return Ok(None);
250 };
251 let repeat = reported == Some(reason);
252 mark_unserved(wait, reason, policy, census);
253 report(&Report {
254 wait,
255 reason,
256 policy,
257 census,
258 waited,
259 deadline,
260 repeat,
261 });
262 // Only the UNBOUNDED park is pushed to the cluster channel: a
263 // deadline-bounded park expires into a typed refusal that reaches the
264 // workflow's own history, while this class otherwise ends nowhere an
265 // operator is pushed to (#266 T4). Same transition rule as the WARN:
266 // first classification and reason changes emit, repeats are silent.
267 if deadline.is_none() && !repeat {
268 emit_unbounded_park(wait, reason, policy, &census, waited);
269 }
270 Ok(Some(ObservedMiss { reason, census }))
271}
272
273/// Clear one dispatch's unserved entry once it is served or refused.
274pub fn clear_selection_miss(wait: &ServiceWait<'_>) {
275 clear_unserved(wait);
276}
277
278fn unavailable(
279 address: &ServiceAddress,
280 reason: QueueServiceReason,
281 clock: Option<ExpiredClock>,
282 waited: Duration,
283 census: PoolCensus,
284) -> SelectionRefusal {
285 SelectionRefusal::Unavailable(Box::new(WorkerUnavailable {
286 reason,
287 clock,
288 waited,
289 address: address.clone(),
290 census,
291 }))
292}
293
294fn mark_unserved(
295 wait: &ServiceWait<'_>,
296 reason: QueueServiceReason,
297 policy: QueueServicePolicy,
298 census: PoolCensus,
299) {
300 if let Err(error) = wait.state.mark(Parked {
301 address: wait.address,
302 reason,
303 policy,
304 census,
305 workflow_id: wait.workflow_id,
306 activity_id: wait.activity_id,
307 }) {
308 tracing::error!(%error, "failed to publish the unserved queue state");
309 }
310}
311
312/// Announce one unbounded-park transition on the deployment's cluster channel.
313///
314/// The taxonomy reason and policy cross as their canonical spellings because
315/// `aion-core` must not depend on this crate's enums; every other field is the
316/// same data the WARN and the queryable state already carry. An unwired
317/// publisher (isolated tests) emits nothing — the honesty contract on
318/// [`ServiceWait::publisher`] — and a publisher with no live subscribers is
319/// the calm single-node case, not an error.
320fn emit_unbounded_park(
321 wait: &ServiceWait<'_>,
322 reason: QueueServiceReason,
323 policy: QueueServicePolicy,
324 census: &PoolCensus,
325 waited: Duration,
326) {
327 let Some(publisher) = wait.publisher else {
328 return;
329 };
330 let address = wait.address;
331 publisher.emit(|meta| aion_core::ClusterEvent::DispatchParked {
332 meta,
333 namespace: address.namespace.clone(),
334 task_queue: address.task_queue.clone(),
335 activity_type: address.activity_type.clone(),
336 node: address.node.clone(),
337 reason: reason.as_str().to_owned(),
338 policy: policy.as_str().to_owned(),
339 workflow_id: wait.workflow_id.clone(),
340 activity_id: wait.activity_id.clone(),
341 waited_ms: millis(waited),
342 workers_in_pool: census.workers_in_pool,
343 workers_serving_activity: census.workers_serving_activity,
344 compatible_workers: census.compatible_workers,
345 last_compatible_poller_age_ms: census.last_compatible_poller_age.map(millis),
346 });
347}
348
349fn clear_unserved(wait: &ServiceWait<'_>) {
350 if let Err(error) = wait
351 .state
352 .clear(wait.address, wait.workflow_id, wait.activity_id)
353 {
354 tracing::error!(%error, "failed to clear the unserved queue state");
355 }
356}
357
358struct Report<'a> {
359 wait: &'a ServiceWait<'a>,
360 reason: QueueServiceReason,
361 policy: QueueServicePolicy,
362 census: PoolCensus,
363 waited: Duration,
364 deadline: Option<Duration>,
365 repeat: bool,
366}
367
368/// State the park at WARN the first time, and whenever the verdict changes.
369///
370/// Repeats drop to DEBUG: a queue nobody serves would otherwise emit a WARN on
371/// every poll for as long as the run lives, which is how a real signal becomes
372/// noise nobody reads. The escalation an operator sees is INFO (the old,
373/// unreasoned line) becoming WARN (this one, with the reason and the age).
374fn report(report: &Report<'_>) {
375 let Report {
376 wait,
377 reason,
378 policy,
379 census,
380 waited,
381 deadline,
382 repeat,
383 } = report;
384 let address = wait.address;
385 let age = census
386 .last_compatible_poller_age
387 .map_or_else(|| NEVER.to_owned(), |age| millis(age).to_string());
388 let deadline_ms = deadline.map_or_else(|| NEVER.to_owned(), |value| millis(value).to_string());
389 if *repeat {
390 tracing::debug!(
391 namespace = %address.namespace,
392 task_queue = %address.task_queue,
393 activity_type = %address.activity_type,
394 node = address.node.as_deref(),
395 workflow_id = %wait.workflow_id,
396 activity_id = %wait.activity_id,
397 queue_service_reason = reason.as_str(),
398 queue_service_policy = policy.as_str(),
399 last_compatible_poller_age_ms = %age,
400 waited_ms = millis(*waited),
401 "queue still unserved"
402 );
403 return;
404 }
405 tracing::warn!(
406 namespace = %address.namespace,
407 task_queue = %address.task_queue,
408 activity_type = %address.activity_type,
409 node = address.node.as_deref(),
410 workflow_id = %wait.workflow_id,
411 activity_id = %wait.activity_id,
412 queue_service_reason = reason.as_str(),
413 queue_service_policy = policy.as_str(),
414 last_compatible_poller_age_ms = %age,
415 service_availability_deadline_ms = %deadline_ms,
416 waited_ms = millis(*waited),
417 workers_in_pool = census.workers_in_pool,
418 workers_serving_activity = census.workers_serving_activity,
419 compatible_workers = census.compatible_workers,
420 "dispatch is parked on a queue that is not being served"
421 );
422}