Skip to main content

anyback_reader/cli/
deadline.rs

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/// One absolute backup or restore workflow deadline and its validated watcher limits.
23#[derive(Clone, Copy, Debug)]
24pub struct WorkflowDeadline {
25    expires_at: Option<Instant>,
26    configured: Duration,
27    process_timeouts: ProcessWatcherTimeouts,
28}
29
30/// Caller-owned authority for one publication commit.
31///
32/// Preparation workers never receive this value, so a worker detached by a
33/// timeout cannot change a caller-visible destination.
34#[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    /// Reads and validates all backup timeout environment variables.
85    ///
86    /// Call this constructor before client construction so invalid
87    /// configuration cannot precede network or filesystem mutation.
88    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    // Paused time keeps the second wait from completing in the same coarse
501    // timer tick as the deadline (Windows timers), which `timeout_at` would
502    // report as success.
503    #[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}