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