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}
85
86/// Select a worker for the address, or refuse with a typed reason.
87///
88/// `accepting` is the drain gate, consulted exactly where it always was —
89/// before anything else on a selection miss. `park` performs one wait for a
90/// worker arrival: `Some(budget)` must return by `budget` at the latest, `None`
91/// may wait indefinitely (the caller owns the runtime plumbing).
92///
93/// `park` must ALSO return once the server stops accepting work, in both arms.
94/// This loop cannot enforce that itself: it consults `accepting` between waits,
95/// never during one, so a `park` that only ends on a worker arrival makes a
96/// dispatch to an unserved queue unrefusable and — on the blocking pool tokio's
97/// runtime `Drop` joins — unexitable (#72).
98///
99/// # Errors
100///
101/// Returns [`SelectionRefusal`] when the dispatch is refused rather than
102/// served.
103pub fn select_worker_or_refuse(
104 wait: &ServiceWait<'_>,
105 accepting: &mut dyn FnMut() -> Result<(), String>,
106 park: &mut dyn FnMut(Option<Duration>),
107) -> Result<WorkerHandle, SelectionRefusal> {
108 let started_at = Instant::now();
109 let address = wait.address;
110 let policy = wait
111 .config
112 .policy_for(&address.namespace, &address.task_queue);
113 let deadline = wait.config.availability_deadline_for(policy);
114 let mut reported: Option<QueueServiceReason> = None;
115 let outcome = loop {
116 // `node` is the OPTIONAL within-pool affinity carried on the dispatch:
117 // `Some(n)` pins selection to workers advertising node `n`; `None` is
118 // unpinned and reaches any worker in the (namespace, task_queue) pool.
119 match wait.registry.select_worker(
120 &address.namespace,
121 &address.task_queue,
122 &address.activity_type,
123 address.node.as_deref(),
124 ) {
125 Ok(Some(worker)) => {
126 if let Some(reason) = reported {
127 tracing::info!(
128 namespace = %address.namespace,
129 task_queue = %address.task_queue,
130 activity_type = %address.activity_type,
131 workflow_id = %wait.workflow_id,
132 activity_id = %wait.activity_id,
133 queue_service_reason = reason.as_str(),
134 waited_ms = millis(started_at.elapsed()),
135 "queue service restored; the parked dispatch has a worker"
136 );
137 }
138 break Ok(worker);
139 }
140 Ok(None) => {
141 if let Err(reason) = accepting() {
142 break Err(SelectionRefusal::NotAccepting { reason });
143 }
144 let waited = started_at.elapsed();
145 let observed =
146 match observe_selection_miss(wait, policy, deadline, waited, reported) {
147 Ok(Some(observed)) => observed,
148 // A worker arrived between the miss and the census: the
149 // state we would report is already untrue, so re-select
150 // rather than announce it.
151 Ok(None) => continue,
152 Err(refusal) => break Err(refusal),
153 };
154 let (reason, census) = (observed.reason, observed.census);
155 reported = Some(reason);
156 if reason.is_structural() {
157 // Unconditional: no policy admits work onto a queue no
158 // deployment declares, and no clock can change the answer.
159 break Err(unavailable(address, reason, None, waited, census));
160 }
161 match deadline {
162 Some(deadline) => {
163 let Some(remaining) = deadline
164 .checked_sub(waited)
165 .filter(|remaining| !remaining.is_zero())
166 else {
167 break Err(unavailable(
168 address,
169 reason,
170 Some(ExpiredClock::ServiceAvailability),
171 waited,
172 census,
173 ));
174 };
175 park(Some(remaining));
176 }
177 None => park(None),
178 }
179 }
180 Err(error) => {
181 break Err(SelectionRefusal::Registry {
182 reason: format!("registry error: {error}"),
183 });
184 }
185 }
186 };
187 clear_unserved(wait);
188 outcome
189}
190
191/// What one classified selection miss turned out to be.
192pub struct ObservedMiss {
193 /// The classified reason this address is not being served.
194 pub reason: QueueServiceReason,
195 /// The live poller census the classification was made against.
196 pub census: PoolCensus,
197}
198
199/// Classify one selection miss, publish it to the queryable unserved state, and
200/// state it in the log.
201///
202/// This is the whole of "park VISIBLY", factored out so it has exactly ONE
203/// implementation. There are two dispatch loops in this server — the blocking
204/// selection wait below, and the async gRPC outbox leg — and the second was
205/// written before this machinery existed and never adopted it. A dispatch that
206/// parked there was invisible: no state to query, one `info!` line, and no
207/// reachable dead-letter path. Duplicating the classification to fix that would
208/// have guaranteed the two drifted, so both call this.
209///
210/// It deliberately decides NOTHING about whether to keep waiting. Bounding an
211/// unserved dispatch is a semantics decision that belongs to the operator, and
212/// a bound invented here would refuse work nobody asked to have refused.
213///
214/// `Ok(None)` means a worker arrived between the miss and the census, so there
215/// is nothing true left to report — the caller should re-select rather than
216/// announce a state that is already untrue.
217///
218/// # Errors
219///
220/// Returns [`SelectionRefusal::Registry`] when the census itself cannot be read.
221pub fn observe_selection_miss(
222 wait: &ServiceWait<'_>,
223 policy: QueueServicePolicy,
224 deadline: Option<Duration>,
225 waited: Duration,
226 reported: Option<QueueServiceReason>,
227) -> Result<Option<ObservedMiss>, SelectionRefusal> {
228 let address = wait.address;
229 let census = wait
230 .registry
231 .pool_census(
232 &address.namespace,
233 &address.task_queue,
234 &address.activity_type,
235 address.node.as_deref(),
236 )
237 .map_err(|error| SelectionRefusal::Registry {
238 reason: format!("registry error: {error}"),
239 })?;
240 let declaration = wait.declarations.declaration_for(&address.task_queue);
241 let Some(reason) = classify(declaration, &census) else {
242 return Ok(None);
243 };
244 mark_unserved(wait, reason, policy, census);
245 report(&Report {
246 wait,
247 reason,
248 policy,
249 census,
250 waited,
251 deadline,
252 repeat: reported == Some(reason),
253 });
254 Ok(Some(ObservedMiss { reason, census }))
255}
256
257/// Clear one dispatch's unserved entry once it is served or refused.
258pub fn clear_selection_miss(wait: &ServiceWait<'_>) {
259 clear_unserved(wait);
260}
261
262fn unavailable(
263 address: &ServiceAddress,
264 reason: QueueServiceReason,
265 clock: Option<ExpiredClock>,
266 waited: Duration,
267 census: PoolCensus,
268) -> SelectionRefusal {
269 SelectionRefusal::Unavailable(Box::new(WorkerUnavailable {
270 reason,
271 clock,
272 waited,
273 address: address.clone(),
274 census,
275 }))
276}
277
278fn mark_unserved(
279 wait: &ServiceWait<'_>,
280 reason: QueueServiceReason,
281 policy: QueueServicePolicy,
282 census: PoolCensus,
283) {
284 if let Err(error) = wait.state.mark(Parked {
285 address: wait.address,
286 reason,
287 policy,
288 census,
289 workflow_id: wait.workflow_id,
290 activity_id: wait.activity_id,
291 }) {
292 tracing::error!(%error, "failed to publish the unserved queue state");
293 }
294}
295
296fn clear_unserved(wait: &ServiceWait<'_>) {
297 if let Err(error) = wait
298 .state
299 .clear(wait.address, wait.workflow_id, wait.activity_id)
300 {
301 tracing::error!(%error, "failed to clear the unserved queue state");
302 }
303}
304
305struct Report<'a> {
306 wait: &'a ServiceWait<'a>,
307 reason: QueueServiceReason,
308 policy: QueueServicePolicy,
309 census: PoolCensus,
310 waited: Duration,
311 deadline: Option<Duration>,
312 repeat: bool,
313}
314
315/// State the park at WARN the first time, and whenever the verdict changes.
316///
317/// Repeats drop to DEBUG: a queue nobody serves would otherwise emit a WARN on
318/// every poll for as long as the run lives, which is how a real signal becomes
319/// noise nobody reads. The escalation an operator sees is INFO (the old,
320/// unreasoned line) becoming WARN (this one, with the reason and the age).
321fn report(report: &Report<'_>) {
322 let Report {
323 wait,
324 reason,
325 policy,
326 census,
327 waited,
328 deadline,
329 repeat,
330 } = report;
331 let address = wait.address;
332 let age = census
333 .last_compatible_poller_age
334 .map_or_else(|| NEVER.to_owned(), |age| millis(age).to_string());
335 let deadline_ms = deadline.map_or_else(|| NEVER.to_owned(), |value| millis(value).to_string());
336 if *repeat {
337 tracing::debug!(
338 namespace = %address.namespace,
339 task_queue = %address.task_queue,
340 activity_type = %address.activity_type,
341 node = address.node.as_deref(),
342 workflow_id = %wait.workflow_id,
343 activity_id = %wait.activity_id,
344 queue_service_reason = reason.as_str(),
345 queue_service_policy = policy.as_str(),
346 last_compatible_poller_age_ms = %age,
347 waited_ms = millis(*waited),
348 "queue still unserved"
349 );
350 return;
351 }
352 tracing::warn!(
353 namespace = %address.namespace,
354 task_queue = %address.task_queue,
355 activity_type = %address.activity_type,
356 node = address.node.as_deref(),
357 workflow_id = %wait.workflow_id,
358 activity_id = %wait.activity_id,
359 queue_service_reason = reason.as_str(),
360 queue_service_policy = policy.as_str(),
361 last_compatible_poller_age_ms = %age,
362 service_availability_deadline_ms = %deadline_ms,
363 waited_ms = millis(*waited),
364 workers_in_pool = census.workers_in_pool,
365 workers_serving_activity = census.workers_serving_activity,
366 compatible_workers = census.compatible_workers,
367 "dispatch is parked on a queue that is not being served"
368 );
369}