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::{
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: &registry,
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}