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}