Skip to main content

aion_server/worker/queue_service/
policy.rs

1//! Per-dispatch service policy and the two service clocks.
2//!
3//! R1 rules two policies and two clocks, and this module is where a dispatch's
4//! pair is resolved from what the operator actually wrote.
5//!
6//! **strict** is the default, everywhere, always: a dispatch to a queue that is
7//! structurally unserved refuses instead of starting a lie. **`durable_pending`**
8//! is a written opt-in per queue — the run is marked unserved immediately and
9//! visibly, then parks durably until a worker arrives.
10//!
11//! The two clocks are never conflated:
12//!
13//! * the **service-availability deadline** bounds waiting for a compatible
14//!   worker to EXIST (`NO_LIVE_POLLERS` / `POLLERS_INCOMPATIBLE`);
15//! * the **schedule-to-start timeout** bounds the hand-off to a worker that
16//!   already exists (`SATURATED`).
17//!
18//! Both are `Option`: unset means unbounded, which is exactly today's
19//! behaviour and the only honest default — a server does not get to invent the
20//! deadline an operator never declared. What DID change is that an unbounded
21//! wait is now loud, typed, and queryable instead of silent.
22
23use std::time::Duration;
24
25use serde::Deserialize;
26
27/// What a dispatch does when its queue is not being served.
28#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
29#[serde(rename_all = "snake_case")]
30pub enum QueueServicePolicy {
31    /// Refuse rather than admit work onto a queue that cannot serve it.
32    /// Structural unservability refuses immediately; a fleet condition refuses
33    /// when the service-availability deadline expires. The default.
34    #[default]
35    Strict,
36    /// Accept, mark the run unserved immediately and visibly, and park durably
37    /// until a compatible worker arrives. Written opt-in, per queue. Structural
38    /// unservability still refuses — a queue no deployment declares can never
39    /// be served, so pending on it would be the lie the policy exists to avoid.
40    DurablePending,
41}
42
43impl QueueServicePolicy {
44    /// The canonical spelling used in config and in logs.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Strict => "strict",
49            Self::DurablePending => "durable_pending",
50        }
51    }
52}
53
54impl std::fmt::Display for QueueServicePolicy {
55    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        formatter.write_str(self.as_str())
57    }
58}
59
60/// One written per-queue policy opt-in.
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
62#[serde(deny_unknown_fields)]
63pub struct QueueServiceOverride {
64    /// Namespace the override applies to, or `None` for every namespace.
65    #[serde(default)]
66    pub namespace: Option<String>,
67    /// Task queue the override applies to.
68    pub task_queue: String,
69    /// Policy this queue is served under.
70    pub policy: QueueServicePolicy,
71}
72
73/// The operator's queue-service settings.
74///
75/// # These are now the ONLY clocks that bound waiting for a worker
76///
77/// Worth stating here, where the clocks are set, because it changed and it is
78/// not obvious from anywhere else an operator reads.
79///
80/// An activity's authored per-attempt timeout bounds the ATTEMPT — it starts
81/// when a worker accepts the work and the durable lease is recorded. It does
82/// NOT bound the time a dispatch spends waiting for a worker to exist. It used
83/// to, as a side effect of when the clock started, and that produced a bad
84/// answer twice over: an activity that waited eleven minutes and then ran for
85/// four seconds could blow a five-minute bound without ever having been slow,
86/// and the expiry could not cancel the dispatch it gave up on, so each one
87/// stacked another parked thread rather than freeing anything.
88///
89/// So the wait is bounded by [`Self::service_availability_deadline`] and
90/// [`Self::schedule_to_start_timeout`], and by nothing else. **Both default to
91/// `None`.** A server whose operator has set neither will hold a dispatch to a
92/// queue with no eligible worker indefinitely, visibly — the queue-service state
93/// names the reason and `GET /queues/unserved` lists it — rather than failing it
94/// on a clock that was measuring the wrong thing. No default is invented for
95/// you: how long work may wait for a worker that does not exist is a decision
96/// about your fleet, not one this server can make on your behalf.
97#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
98#[serde(default, deny_unknown_fields)]
99pub struct QueueServiceConfig {
100    /// Policy for every queue with no written override. `strict`.
101    pub default_policy: QueueServicePolicy,
102    /// Written per-queue opt-ins. The first entry matching a dispatch wins, so
103    /// a namespace-scoped entry placed before a namespace-less one is honoured.
104    pub overrides: Vec<QueueServiceOverride>,
105    /// How long a dispatch waits for a compatible worker to exist before a
106    /// typed `WorkerUnavailable` refusal. Unset = unbounded.
107    #[serde(default, with = "optional_duration_millis")]
108    pub service_availability_deadline: Option<Duration>,
109    /// How long a dispatch tries to hand its task to a live compatible worker
110    /// before a typed `SATURATED` refusal. Unset = one attempt, exactly as
111    /// before.
112    #[serde(default, with = "optional_duration_millis")]
113    pub schedule_to_start_timeout: Option<Duration>,
114}
115
116impl QueueServiceConfig {
117    /// The policy governing one dispatch address.
118    #[must_use]
119    pub fn policy_for(&self, namespace: &str, task_queue: &str) -> QueueServicePolicy {
120        self.overrides
121            .iter()
122            .find(|entry| {
123                entry.task_queue == task_queue
124                    && entry
125                        .namespace
126                        .as_ref()
127                        .is_none_or(|scoped| scoped == namespace)
128            })
129            .map_or(self.default_policy, |entry| entry.policy)
130    }
131
132    /// Whether the service-availability deadline governs this dispatch.
133    ///
134    /// `durable_pending` is precisely the declaration that this dispatch waits
135    /// for its worker, so the availability deadline — the strict policy's
136    /// instrument for refusing rather than lying — does not cut it short. The
137    /// schedule-to-start clock still applies under both policies: it bounds a
138    /// hand-off to a worker that already exists, which is a different question.
139    #[must_use]
140    pub const fn availability_deadline_for(&self, policy: QueueServicePolicy) -> Option<Duration> {
141        match policy {
142            QueueServicePolicy::Strict => self.service_availability_deadline,
143            QueueServicePolicy::DurablePending => None,
144        }
145    }
146}
147
148/// Milliseconds-or-absent duration fields, matching the `duration_millis`
149/// convention the rest of the server config uses.
150mod optional_duration_millis {
151    use std::time::Duration;
152
153    use serde::{Deserialize, Deserializer};
154
155    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
156    where
157        D: Deserializer<'de>,
158    {
159        let millis = Option::<u64>::deserialize(deserializer)?;
160        Ok(millis.map(Duration::from_millis))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn strict_is_the_default_policy_and_no_clock_is_invented() {
170        let config = QueueServiceConfig::default();
171        assert_eq!(config.default_policy, QueueServicePolicy::Strict);
172        assert_eq!(
173            config.policy_for("default", "general"),
174            QueueServicePolicy::Strict
175        );
176        assert_eq!(config.service_availability_deadline, None);
177        assert_eq!(config.schedule_to_start_timeout, None);
178    }
179
180    #[test]
181    fn a_written_override_opts_one_queue_into_durable_pending() {
182        let config = QueueServiceConfig {
183            overrides: vec![QueueServiceOverride {
184                namespace: None,
185                task_queue: "general".to_owned(),
186                policy: QueueServicePolicy::DurablePending,
187            }],
188            ..QueueServiceConfig::default()
189        };
190        assert_eq!(
191            config.policy_for("default", "general"),
192            QueueServicePolicy::DurablePending
193        );
194        // Every other queue keeps the strict default.
195        assert_eq!(
196            config.policy_for("default", "billing"),
197            QueueServicePolicy::Strict
198        );
199    }
200
201    #[test]
202    fn an_override_can_be_scoped_to_one_namespace() {
203        let config = QueueServiceConfig {
204            overrides: vec![QueueServiceOverride {
205                namespace: Some("lab".to_owned()),
206                task_queue: "general".to_owned(),
207                policy: QueueServicePolicy::DurablePending,
208            }],
209            ..QueueServiceConfig::default()
210        };
211        assert_eq!(
212            config.policy_for("lab", "general"),
213            QueueServicePolicy::DurablePending
214        );
215        assert_eq!(
216            config.policy_for("prod", "general"),
217            QueueServicePolicy::Strict
218        );
219    }
220
221    #[test]
222    fn the_availability_deadline_bounds_strict_only() {
223        let config = QueueServiceConfig {
224            service_availability_deadline: Some(Duration::from_secs(30)),
225            ..QueueServiceConfig::default()
226        };
227        assert_eq!(
228            config.availability_deadline_for(QueueServicePolicy::Strict),
229            Some(Duration::from_secs(30))
230        );
231        assert_eq!(
232            config.availability_deadline_for(QueueServicePolicy::DurablePending),
233            None
234        );
235    }
236
237    #[test]
238    fn queue_service_settings_deserialize_from_the_written_form() -> Result<(), toml::de::Error> {
239        let config: QueueServiceConfig = toml::from_str(
240            r#"
241            default_policy = "strict"
242            service_availability_deadline = 45000
243            schedule_to_start_timeout = 5000
244
245            [[overrides]]
246            task_queue = "general"
247            policy = "durable_pending"
248            "#,
249        )?;
250        assert_eq!(config.default_policy, QueueServicePolicy::Strict);
251        assert_eq!(
252            config.service_availability_deadline,
253            Some(Duration::from_secs(45))
254        );
255        assert_eq!(
256            config.schedule_to_start_timeout,
257            Some(Duration::from_secs(5))
258        );
259        assert_eq!(
260            config.policy_for("default", "general"),
261            QueueServicePolicy::DurablePending
262        );
263        Ok(())
264    }
265}