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    /// 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}