Skip to main content

scientific_workflow/
execution.rs

1//! Automatic filesystem scopes for one project execution and its task recordings.
2//!
3//! [`ExecutionScope`] centralizes collision-resistant directory creation so
4//! applications do not format timestamps, append process identifiers, or
5//! create task directories themselves. The scope owns organization only;
6//! individual [`crate::storage::SystemStateWriter`] values still exclusively
7//! create and own their recording directories.
8
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use thiserror::Error;
14
15use crate::clock::utc_now_rfc3339;
16
17/// Process-local suffix used when multiple scopes share one timestamp.
18static EXECUTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
19
20/// Failure while creating or opening an execution scope.
21#[derive(Debug, Error)]
22#[non_exhaustive]
23pub enum ExecutionScopeError {
24    /// A caller-supplied scope name was empty or not one safe path component.
25    #[error("invalid execution scope name `{name}`")]
26    InvalidName {
27        /// Rejected scope name.
28        name: String,
29    },
30    /// The host UTC clock could not be formatted for the scope identity.
31    #[error("failed to format execution scope creation timestamp")]
32    Timestamp {
33        /// Timestamp-formatting failure.
34        #[source]
35        source: time::error::Format,
36    },
37    /// A filesystem operation failed at a scope boundary.
38    #[error("failed to {operation} execution scope at `{path}`")]
39    Io {
40        /// Stable filesystem action.
41        operation: &'static str,
42        /// Affected root or scope path.
43        path: PathBuf,
44        /// Underlying operating-system failure.
45        #[source]
46        source: std::io::Error,
47    },
48    /// Generated identity attempts repeatedly collided with existing scopes.
49    #[error("could not allocate a unique execution scope beneath `{root}`")]
50    IdentityExhausted {
51        /// Parent directory in which allocation was attempted.
52        root: PathBuf,
53    },
54}
55
56/// One created or reopened project-execution directory.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct ExecutionScope {
59    directory: PathBuf,
60    created_at_utc: Option<String>,
61}
62
63impl ExecutionScope {
64    /// Creates a uniquely named scope beneath `recording_root`.
65    ///
66    /// The readable UTC component is supplemented by process and sequence
67    /// values. Exclusive directory creation remains the final collision check.
68    pub fn create_generated(recording_root: impl AsRef<Path>) -> Result<Self, ExecutionScopeError> {
69        let recording_root = recording_root.as_ref();
70        fs::create_dir_all(recording_root).map_err(|source| ExecutionScopeError::Io {
71            operation: "create recording root for",
72            path: recording_root.to_path_buf(),
73            source,
74        })?;
75        let created_at_utc =
76            utc_now_rfc3339().map_err(|source| ExecutionScopeError::Timestamp { source })?;
77        let compact_timestamp = compact_timestamp(&created_at_utc);
78        for _ in 0..1024 {
79            let sequence = EXECUTION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
80            let name = format!(
81                "execution-{compact_timestamp}-{}-{sequence}",
82                std::process::id()
83            );
84            let directory = recording_root.join(name);
85            match fs::create_dir(&directory) {
86                Ok(()) => {
87                    return Ok(Self {
88                        directory,
89                        created_at_utc: Some(created_at_utc),
90                    });
91                }
92                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue,
93                Err(source) => {
94                    return Err(ExecutionScopeError::Io {
95                        operation: "create generated",
96                        path: directory,
97                        source,
98                    });
99                }
100            }
101        }
102        Err(ExecutionScopeError::IdentityExhausted {
103            root: recording_root.to_path_buf(),
104        })
105    }
106
107    /// Creates one caller-named scope beneath `recording_root`.
108    pub fn create_named(
109        recording_root: impl AsRef<Path>,
110        name: impl Into<String>,
111    ) -> Result<Self, ExecutionScopeError> {
112        let recording_root = recording_root.as_ref();
113        let name = name.into();
114        validate_name(&name)?;
115        let created_at_utc =
116            utc_now_rfc3339().map_err(|source| ExecutionScopeError::Timestamp { source })?;
117        fs::create_dir_all(recording_root).map_err(|source| ExecutionScopeError::Io {
118            operation: "create recording root for",
119            path: recording_root.to_path_buf(),
120            source,
121        })?;
122        let directory = recording_root.join(&name);
123        fs::create_dir(&directory).map_err(|source| ExecutionScopeError::Io {
124            operation: "create named",
125            path: directory.clone(),
126            source,
127        })?;
128        Ok(Self {
129            directory,
130            created_at_utc: Some(created_at_utc),
131        })
132    }
133
134    /// Opens an existing execution scope without creating or modifying files.
135    pub fn open_existing(directory: impl Into<PathBuf>) -> Result<Self, ExecutionScopeError> {
136        let directory = directory.into();
137        let metadata = fs::metadata(&directory).map_err(|source| ExecutionScopeError::Io {
138            operation: "inspect existing",
139            path: directory.clone(),
140            source,
141        })?;
142        if !metadata.is_dir() {
143            return Err(ExecutionScopeError::Io {
144                operation: "open non-directory",
145                path: directory.clone(),
146                source: std::io::Error::new(
147                    std::io::ErrorKind::NotADirectory,
148                    "execution scope must be a directory",
149                ),
150            });
151        }
152        Ok(Self {
153            directory,
154            created_at_utc: None,
155        })
156    }
157
158    /// Returns the scope directory.
159    pub fn directory(&self) -> &Path {
160        &self.directory
161    }
162
163    /// Returns the automatically captured creation timestamp when this handle
164    /// created the scope.
165    ///
166    /// Reopened legacy scopes return `None` because no auxiliary scope metadata
167    /// is invented merely to recover a timestamp.
168    pub fn created_at_utc(&self) -> Option<&str> {
169        self.created_at_utc.as_deref()
170    }
171
172    /// Derives the absent recording path reserved for one deterministic task.
173    ///
174    /// The directory is deliberately not created: the recording writer must
175    /// retain exclusive creation and overwrite protection.
176    pub fn task_recording_directory(&self, task_ordinal: u64) -> PathBuf {
177        self.directory.join(format!("task-{task_ordinal:06}"))
178    }
179}
180
181/// Requires a nonempty relative name containing exactly one normal component.
182fn validate_name(name: &str) -> Result<(), ExecutionScopeError> {
183    let path = Path::new(name);
184    let mut components = path.components();
185    let valid = !name.trim().is_empty()
186        && matches!(components.next(), Some(Component::Normal(_)))
187        && components.next().is_none();
188    if valid {
189        Ok(())
190    } else {
191        Err(ExecutionScopeError::InvalidName {
192            name: name.to_owned(),
193        })
194    }
195}
196
197/// Removes RFC 3339 punctuation while retaining ordered UTC date/time digits.
198fn compact_timestamp(timestamp: &str) -> String {
199    timestamp
200        .chars()
201        .filter(|character| character.is_ascii_alphanumeric())
202        .collect()
203}