1use std::{
2 ffi::OsString,
3 future::Future,
4 sync::{
5 Arc,
6 atomic::{AtomicU8, Ordering},
7 },
8 time::Duration,
9};
10
11use anyhow::{Result, bail};
12use anytype::process_watcher::ProcessWatcherTimeouts;
13use tokio::time::Instant;
14
15const WORKFLOW_DEFAULT: Duration = Duration::from_hours(1);
16const WORKFLOW_MAXIMUM: Duration = Duration::from_hours(2);
17const EVENT_CONNECT_MAXIMUM: Duration = Duration::from_mins(2);
18const PROCESS_START_MAXIMUM: Duration = Duration::from_mins(5);
19const PROCESS_IDLE_MAXIMUM: Duration = Duration::from_mins(5);
20const PROCESS_DONE_MAXIMUM: Duration = Duration::from_hours(1);
21
22#[derive(Clone, Copy, Debug)]
24pub struct WorkflowDeadline {
25 expires_at: Option<Instant>,
26 configured: Duration,
27 process_timeouts: ProcessWatcherTimeouts,
28}
29
30#[derive(Clone, Debug)]
35pub(super) struct PublicationCommit {
36 expires_at: Option<Instant>,
37 configured: Duration,
38 timeout_message: &'static str,
39 state: Arc<AtomicU8>,
40}
41
42impl PublicationCommit {
43 pub(super) fn ensure_remaining(&self) -> Result<()> {
44 if self
45 .expires_at
46 .is_some_and(|deadline| Instant::now() >= deadline)
47 {
48 bail!(
49 "{} after {} seconds",
50 self.timeout_message,
51 self.configured.as_secs()
52 );
53 }
54 Ok(())
55 }
56
57 pub(super) fn commit<T>(self, operation: impl FnOnce() -> Result<T>) -> Result<T> {
58 if self
59 .expires_at
60 .is_some_and(|deadline| Instant::now() >= deadline)
61 {
62 let _ = self
63 .state
64 .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire);
65 }
66 if self
67 .state
68 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
69 .is_err()
70 {
71 bail!(
72 "{} after {} seconds",
73 self.timeout_message,
74 self.configured.as_secs()
75 );
76 }
77 let result = operation();
78 self.state.store(3, Ordering::Release);
79 result
80 }
81}
82
83impl WorkflowDeadline {
84 pub fn from_env() -> Result<Self> {
89 Self::from_lookup(|name| std::env::var_os(name))
90 }
91
92 fn from_lookup(mut lookup: impl FnMut(&str) -> Option<OsString>) -> Result<Self> {
93 let workflow = parse_timeout_value(
94 "ANYBACK_WORKFLOW_TIMEOUT_SECS",
95 lookup("ANYBACK_WORKFLOW_TIMEOUT_SECS"),
96 WORKFLOW_DEFAULT,
97 WORKFLOW_MAXIMUM,
98 true,
99 )?;
100 let defaults = ProcessWatcherTimeouts::default();
101 let process_timeouts = ProcessWatcherTimeouts {
102 event_stream_connect_timeout: parse_required_timeout_value(
103 "ANYBACK_EVENT_STREAM_CONNECT_TIMEOUT",
104 lookup("ANYBACK_EVENT_STREAM_CONNECT_TIMEOUT"),
105 defaults.event_stream_connect_timeout,
106 EVENT_CONNECT_MAXIMUM,
107 )?,
108 process_start_timeout: parse_required_timeout_value(
109 "ANYBACK_PROCESS_START_TIMEOUT",
110 lookup("ANYBACK_PROCESS_START_TIMEOUT"),
111 defaults.process_start_timeout,
112 PROCESS_START_MAXIMUM,
113 )?,
114 process_idle_timeout: parse_required_timeout_value(
115 "ANYBACK_PROCESS_IDLE_TIMEOUT",
116 lookup("ANYBACK_PROCESS_IDLE_TIMEOUT"),
117 defaults.process_idle_timeout,
118 PROCESS_IDLE_MAXIMUM,
119 )?,
120 process_done_timeout: parse_required_timeout_value(
121 "ANYBACK_PROCESS_DONE_TIMEOUT",
122 lookup("ANYBACK_PROCESS_DONE_TIMEOUT"),
123 defaults.process_done_timeout,
124 PROCESS_DONE_MAXIMUM,
125 )?,
126 };
127 let expires_at = workflow
128 .map(|duration| {
129 Instant::now().checked_add(duration).ok_or_else(|| {
130 anyhow::anyhow!(
131 "ANYBACK_WORKFLOW_TIMEOUT_SECS cannot be represented as a deadline"
132 )
133 })
134 })
135 .transpose()?;
136 Ok(Self {
137 expires_at,
138 configured: workflow.unwrap_or(Duration::ZERO),
139 process_timeouts,
140 })
141 }
142
143 pub(super) fn local_command() -> Self {
144 Self {
145 expires_at: None,
146 configured: Duration::ZERO,
147 process_timeouts: ProcessWatcherTimeouts::default(),
148 }
149 }
150
151 #[cfg(test)]
152 pub(super) fn new(
153 workflow: Option<Duration>,
154 process_timeouts: ProcessWatcherTimeouts,
155 ) -> Self {
156 Self {
157 expires_at: workflow.and_then(|duration| Instant::now().checked_add(duration)),
158 configured: workflow.unwrap_or(Duration::ZERO),
159 process_timeouts,
160 }
161 }
162
163 pub(super) fn ensure_read_remaining(self) -> Result<()> {
164 if self.expired() {
165 bail!("backup workflow timed out; read was aborted");
166 }
167 Ok(())
168 }
169
170 pub(super) fn ensure_mutation_remaining(self) -> Result<()> {
171 if self.expired() {
172 bail!("restore workflow timed out; mutation outcome is indeterminate");
173 }
174 Ok(())
175 }
176
177 pub(super) fn ensure_restore_preflight_remaining(self) -> Result<()> {
178 if self.expired() {
179 bail!("restore workflow timed out before mutation dispatch");
180 }
181 Ok(())
182 }
183
184 pub(super) async fn run_read<F, T>(self, future: F) -> Result<T>
185 where
186 F: Future<Output = T>,
187 {
188 self.run("backup workflow timed out; read was aborted", future)
189 .await
190 }
191
192 pub(super) async fn run_export<F, T>(self, future: F) -> Result<T>
193 where
194 F: Future<Output = T>,
195 {
196 self.run(
197 "backup workflow timed out; read was aborted and a server-side export artifact may exist",
198 future,
199 )
200 .await
201 }
202
203 pub(super) async fn run_mutation<F, T>(self, future: F) -> Result<T>
204 where
205 F: Future<Output = T>,
206 {
207 self.run(
208 "restore workflow timed out; mutation outcome is indeterminate",
209 future,
210 )
211 .await
212 }
213
214 pub(super) async fn run_restore_preflight<F, T>(self, future: F) -> Result<T>
215 where
216 F: Future<Output = T>,
217 {
218 self.run(
219 "restore workflow timed out before mutation dispatch",
220 future,
221 )
222 .await
223 }
224
225 pub(super) async fn run_read_publication<P, T, Prepare, Commit>(
226 self,
227 timeout_message: &'static str,
228 prepare: Prepare,
229 commit: Commit,
230 ) -> Result<T>
231 where
232 Prepare: FnOnce() -> Result<P> + Send + 'static,
233 P: Send + 'static,
234 Commit: FnOnce(P, PublicationCommit) -> Result<T>,
235 {
236 self.run_publication(timeout_message, prepare, commit).await
237 }
238
239 pub(super) async fn run_mutation_publication<P, T, Prepare, Commit>(
240 self,
241 prepare: Prepare,
242 commit: Commit,
243 ) -> Result<T>
244 where
245 Prepare: FnOnce() -> Result<P> + Send + 'static,
246 P: Send + 'static,
247 Commit: FnOnce(P, PublicationCommit) -> Result<T>,
248 {
249 self.run_publication(
250 "restore workflow timed out; mutation outcome is indeterminate",
251 prepare,
252 commit,
253 )
254 .await
255 }
256
257 pub(super) fn process_timeouts(self) -> Result<ProcessWatcherTimeouts> {
258 let Some(remaining) = self.remaining() else {
259 return Ok(self.process_timeouts);
260 };
261 if remaining.is_zero() {
262 bail!("restore workflow timed out before mutation dispatch");
263 }
264 Ok(ProcessWatcherTimeouts {
265 event_stream_connect_timeout: self
266 .process_timeouts
267 .event_stream_connect_timeout
268 .min(remaining),
269 process_start_timeout: self.process_timeouts.process_start_timeout.min(remaining),
270 process_idle_timeout: self.process_timeouts.process_idle_timeout.min(remaining),
271 process_done_timeout: self.process_timeouts.process_done_timeout.min(remaining),
272 })
273 }
274
275 fn expired(self) -> bool {
276 self.expires_at
277 .is_some_and(|deadline| Instant::now() >= deadline)
278 }
279
280 fn remaining(self) -> Option<Duration> {
281 self.expires_at
282 .map(|deadline| deadline.saturating_duration_since(Instant::now()))
283 }
284
285 async fn run<F, T>(self, message: &str, future: F) -> Result<T>
286 where
287 F: Future<Output = T>,
288 {
289 let Some(deadline) = self.expires_at else {
290 return Ok(future.await);
291 };
292 if Instant::now() >= deadline {
293 bail!("{message} after {} seconds", self.configured.as_secs());
294 }
295 tokio::time::timeout_at(deadline, future)
296 .await
297 .map_err(|_| anyhow::anyhow!("{message} after {} seconds", self.configured.as_secs()))
298 }
299
300 async fn run_publication<P, T, Prepare, Commit>(
301 self,
302 timeout_message: &'static str,
303 prepare: Prepare,
304 commit: Commit,
305 ) -> Result<T>
306 where
307 Prepare: FnOnce() -> Result<P> + Send + 'static,
308 P: Send + 'static,
309 Commit: FnOnce(P, PublicationCommit) -> Result<T>,
310 {
311 let state = Arc::new(AtomicU8::new(0));
312 let authority = PublicationCommit {
313 expires_at: self.expires_at,
314 configured: self.configured,
315 timeout_message,
316 state: Arc::clone(&state),
317 };
318 authority.ensure_remaining()?;
319 let task = tokio::task::spawn_blocking(prepare);
320 let joined = match self.expires_at {
321 Some(deadline) => {
322 if let Ok(joined) = tokio::time::timeout_at(deadline, task).await {
323 joined
324 } else {
325 let _ = state.compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire);
326 bail!(
327 "{timeout_message} after {} seconds",
328 self.configured.as_secs()
329 );
330 }
331 }
332 None => task.await,
333 };
334 let prepared = joined.map_err(|_| anyhow::anyhow!("local publication worker failed"))??;
335 authority.ensure_remaining()?;
336 commit(prepared, authority)
337 }
338}
339
340fn parse_required_timeout_value(
341 name: &str,
342 raw: Option<OsString>,
343 default: Duration,
344 maximum: Duration,
345) -> Result<Duration> {
346 parse_timeout_value(name, raw, default, maximum, false)?
347 .ok_or_else(|| anyhow::anyhow!("{name} cannot disable its process safety boundary"))
348}
349
350fn parse_timeout_value(
351 name: &str,
352 raw: Option<OsString>,
353 default: Duration,
354 maximum: Duration,
355 zero_disables: bool,
356) -> Result<Option<Duration>> {
357 let Some(raw) = raw else {
358 return Ok(Some(default));
359 };
360 let raw = raw
361 .into_string()
362 .map_err(|_| anyhow::anyhow!("{name} must be valid Unicode ASCII decimal"))?;
363 if raw == "0" && zero_disables {
364 return Ok(None);
365 }
366 if raw.is_empty() || raw.starts_with('0') || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
367 bail!("{name} must use canonical ASCII decimal seconds");
368 }
369 let seconds = raw
370 .parse::<u64>()
371 .map_err(|_| anyhow::anyhow!("{name} is outside the supported range"))?;
372 if seconds == 0 || seconds > maximum.as_secs() {
373 bail!(
374 "{name} must be between 1 and {} seconds{}",
375 maximum.as_secs(),
376 if zero_disables {
377 ", or exactly 0 to disable the outer workflow deadline"
378 } else {
379 ""
380 }
381 );
382 }
383 Ok(Some(Duration::from_secs(seconds)))
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn timeout_values_use_canonical_bounded_decimal_grammar() {
392 let default = Duration::from_secs(30);
393 let maximum = Duration::from_mins(1);
394 assert_eq!(
395 parse_timeout_value("TEST", None, default, maximum, true).expect("default"),
396 Some(default)
397 );
398 assert_eq!(
399 parse_timeout_value("TEST", Some("60".into()), default, maximum, true)
400 .expect("maximum"),
401 Some(maximum)
402 );
403 assert_eq!(
404 parse_timeout_value("TEST", Some("0".into()), default, maximum, true)
405 .expect("disabled"),
406 None
407 );
408 for invalid in ["", "00", "01", "+1", "-1", " 1", "1 ", "1.0", "61"] {
409 assert!(
410 parse_timeout_value("TEST", Some(invalid.into()), default, maximum, true).is_err(),
411 "accepted invalid value {invalid:?}"
412 );
413 }
414 assert!(parse_timeout_value("TEST", Some("0".into()), default, maximum, false).is_err());
415 for (name, maximum) in [
416 ("ANYBACK_WORKFLOW_TIMEOUT_SECS", 7200),
417 ("ANYBACK_EVENT_STREAM_CONNECT_TIMEOUT", 120),
418 ("ANYBACK_PROCESS_START_TIMEOUT", 300),
419 ("ANYBACK_PROCESS_IDLE_TIMEOUT", 300),
420 ("ANYBACK_PROCESS_DONE_TIMEOUT", 3600),
421 ] {
422 let above = maximum + 1;
423 assert!(
424 parse_timeout_value(
425 name,
426 Some(above.to_string().into()),
427 Duration::from_secs(1),
428 Duration::from_secs(maximum),
429 name == "ANYBACK_WORKFLOW_TIMEOUT_SECS",
430 )
431 .is_err(),
432 "accepted over-maximum value for {name}"
433 );
434 }
435 }
436
437 #[cfg(unix)]
438 #[test]
439 fn timeout_values_reject_non_unicode() {
440 use std::os::unix::ffi::OsStringExt as _;
441
442 assert!(
443 parse_timeout_value(
444 "TEST",
445 Some(OsString::from_vec(vec![0xff])),
446 Duration::from_secs(1),
447 Duration::from_secs(2),
448 false,
449 )
450 .is_err()
451 );
452 }
453
454 #[test]
455 fn watcher_limits_are_clamped_to_the_outer_remaining_budget() {
456 let deadline = WorkflowDeadline::new(
457 Some(Duration::from_secs(1)),
458 ProcessWatcherTimeouts {
459 event_stream_connect_timeout: Duration::from_secs(10),
460 process_start_timeout: Duration::from_secs(20),
461 process_idle_timeout: Duration::from_secs(30),
462 process_done_timeout: Duration::from_secs(40),
463 },
464 );
465 let timeouts = deadline.process_timeouts().expect("remaining budget");
466 assert!(timeouts.event_stream_connect_timeout <= Duration::from_secs(1));
467 assert!(timeouts.process_start_timeout <= Duration::from_secs(1));
468 assert!(timeouts.process_idle_timeout <= Duration::from_secs(1));
469 assert!(timeouts.process_done_timeout <= Duration::from_secs(1));
470 }
471
472 #[test]
473 fn invalid_timeout_configuration_prevents_later_side_effects() {
474 let side_effect_ran = std::cell::Cell::new(false);
475 let configured = WorkflowDeadline::from_lookup(|name| {
476 (name == "ANYBACK_PROCESS_IDLE_TIMEOUT").then(|| OsString::from("301"))
477 });
478 let result = configured.map(|_| {
479 side_effect_ran.set(true);
480 });
481 assert!(result.is_err());
482 assert!(!side_effect_ran.get());
483 }
484
485 #[tokio::test(start_paused = true)]
486 async fn expired_process_configuration_is_pre_dispatch() {
487 let deadline = WorkflowDeadline::new(
488 Some(Duration::from_secs(1)),
489 ProcessWatcherTimeouts::default(),
490 );
491 tokio::time::advance(Duration::from_secs(1)).await;
492 let error = deadline
493 .process_timeouts()
494 .expect_err("expired workflow must reject subscription")
495 .to_string();
496 assert!(error.contains("before mutation dispatch"));
497 assert!(!error.contains("indeterminate"));
498 }
499
500 #[tokio::test(start_paused = true)]
504 async fn one_absolute_deadline_is_not_reset_between_waits() {
505 let deadline = WorkflowDeadline::new(
506 Some(Duration::from_millis(50)),
507 ProcessWatcherTimeouts::default(),
508 );
509 deadline
510 .run_read(tokio::time::sleep(Duration::from_millis(30)))
511 .await
512 .expect("first phase");
513 assert!(
514 deadline
515 .run_read(tokio::time::sleep(Duration::from_millis(30)))
516 .await
517 .is_err()
518 );
519 }
520}