Skip to main content

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}