Skip to main content

scientific_workflow/execution/
replicate.rs

1//! Process isolation and output scopes for complete study replicates.
2
3use std::env;
4use std::ffi::OsString;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command};
8use std::thread;
9use std::time::Duration;
10
11use crate::configuration::{ReplicateFailurePolicy, ReplicateScheduling, ReplicateSettings};
12use crate::rng_record::ReplicateSeedDeriver;
13
14use super::{ExecutionScope, ExecutionScopeError};
15
16const REPLICATE_INDEX_ENVIRONMENT_VARIABLE: &str = "SCIENTIFIC_WORKFLOW_REPLICATE_INDEX";
17const PARALLEL_POLL_INTERVAL: Duration = Duration::from_millis(10);
18
19/// Controller that dispatches the current executable once per declared replicate.
20///
21/// The initial process becomes the controller. Each child receives one reserved
22/// environment variable and re-enters the same API as a replicate worker. The
23/// caller runs scientific work only when [`Self::dispatch_current_executable`]
24/// returns `Some`.
25#[derive(Clone, Debug)]
26pub struct ReplicateExecutor {
27    settings: ReplicateSettings,
28    output_root: PathBuf,
29}
30
31impl ReplicateExecutor {
32    /// Creates a dispatcher for validated settings and an application-resolved output root.
33    pub fn new(settings: ReplicateSettings, output_root: impl Into<PathBuf>) -> Self {
34        Self {
35            settings,
36            output_root: output_root.into(),
37        }
38    }
39
40    /// Dispatches the current executable or enters one dispatched replicate.
41    ///
42    /// The controller returns `Ok(None)` after every required child process has
43    /// completed successfully. A child returns `Ok(Some(context))` immediately;
44    /// the application must perform exactly one study run using that context.
45    /// Original command-line arguments and inherited stdio are preserved.
46    pub fn dispatch_current_executable(
47        &self,
48    ) -> Result<Option<ReplicateContext>, ReplicateExecutionError> {
49        if let Some(raw_index) = env::var_os(REPLICATE_INDEX_ENVIRONMENT_VARIABLE) {
50            return self.enter_worker(raw_index).map(Some);
51        }
52
53        let executable = env::current_exe().map_err(ReplicateExecutionError::CurrentExecutable)?;
54        let arguments = env::args_os().skip(1).collect::<Vec<_>>();
55        self.prepare_output_scopes()?;
56        match self.settings.scheduling() {
57            ReplicateScheduling::Sequential => {
58                self.run_sequential(&executable, &arguments)?;
59            }
60            ReplicateScheduling::Parallel => {
61                self.run_parallel(&executable, &arguments)?;
62            }
63        }
64        Ok(None)
65    }
66
67    fn enter_worker(
68        &self,
69        raw_index: OsString,
70    ) -> Result<ReplicateContext, ReplicateExecutionError> {
71        let display = raw_index.to_string_lossy().into_owned();
72        let index =
73            display
74                .parse::<u64>()
75                .map_err(|_| ReplicateExecutionError::InvalidWorkerIndex {
76                    variable: REPLICATE_INDEX_ENVIRONMENT_VARIABLE,
77                    value: display,
78                })?;
79        if index >= self.settings.replicates() {
80            return Err(ReplicateExecutionError::WorkerIndexOutOfRange {
81                index,
82                replicates: self.settings.replicates(),
83            });
84        }
85        let directory = self.output_root.join(replicate_directory_name(index));
86        let execution_scope = ExecutionScope::open_existing(directory)
87            .map_err(|source| ReplicateExecutionError::PrepareOutput { index, source })?;
88        Ok(ReplicateContext {
89            index,
90            count: self.settings.replicates(),
91            execution_scope,
92            seed_deriver: ReplicateSeedDeriver::new(self.settings.base_seed(), index),
93        })
94    }
95
96    fn run_sequential(
97        &self,
98        executable: &Path,
99        arguments: &[OsString],
100    ) -> Result<(), ReplicateExecutionError> {
101        let mut failures = Vec::new();
102        for index in 0..self.settings.replicates() {
103            let status = self
104                .child_command(executable, arguments, index)
105                .status()
106                .map_err(|source| ReplicateExecutionError::RunProcess { index, source })?;
107            if !status.success() {
108                failures.push(index);
109                if self.settings.failure_policy() == ReplicateFailurePolicy::FailFast {
110                    break;
111                }
112            }
113        }
114        finish_batch(failures)
115    }
116
117    fn run_parallel(
118        &self,
119        executable: &Path,
120        arguments: &[OsString],
121    ) -> Result<(), ReplicateExecutionError> {
122        let mut active = Vec::new();
123        for index in 0..self.settings.replicates() {
124            let child = match self.child_command(executable, arguments, index).spawn() {
125                Ok(child) => child,
126                Err(source) => {
127                    terminate_children(&mut active);
128                    return Err(ReplicateExecutionError::RunProcess { index, source });
129                }
130            };
131            active.push(ActiveReplicate { index, child });
132        }
133
134        let mut failures = Vec::new();
135        while !active.is_empty() {
136            let mut position = 0;
137            let mut completed_any = false;
138            while position < active.len() {
139                let status = match active[position].child.try_wait() {
140                    Ok(status) => status,
141                    Err(source) => {
142                        let index = active[position].index;
143                        terminate_children(&mut active);
144                        return Err(ReplicateExecutionError::RunProcess { index, source });
145                    }
146                };
147                let Some(status) = status else {
148                    position += 1;
149                    continue;
150                };
151                completed_any = true;
152                let completed = active.swap_remove(position);
153                if !status.success() {
154                    failures.push(completed.index);
155                    if self.settings.failure_policy() == ReplicateFailurePolicy::FailFast {
156                        terminate_children(&mut active);
157                        failures.sort_unstable();
158                        return finish_batch(failures);
159                    }
160                }
161            }
162            if !completed_any && !active.is_empty() {
163                thread::sleep(PARALLEL_POLL_INTERVAL);
164            }
165        }
166        failures.sort_unstable();
167        finish_batch(failures)
168    }
169
170    fn prepare_output_scopes(&self) -> Result<(), ReplicateExecutionError> {
171        let mut created = Vec::new();
172        for index in 0..self.settings.replicates() {
173            match ExecutionScope::create_named(&self.output_root, replicate_directory_name(index)) {
174                Ok(scope) => created.push(scope),
175                Err(source) => {
176                    for scope in created.into_iter().rev() {
177                        let _ = std::fs::remove_dir(scope.directory());
178                    }
179                    return Err(ReplicateExecutionError::PrepareOutput { index, source });
180                }
181            }
182        }
183        Ok(())
184    }
185
186    fn child_command(&self, executable: &Path, arguments: &[OsString], index: u64) -> Command {
187        let mut command = Command::new(executable);
188        command
189            .args(arguments)
190            .env(REPLICATE_INDEX_ENVIRONMENT_VARIABLE, index.to_string());
191        command
192    }
193}
194
195/// Immutable identity and output scope for one replicate worker process.
196#[derive(Clone, Debug)]
197pub struct ReplicateContext {
198    index: u64,
199    count: u64,
200    execution_scope: ExecutionScope,
201    seed_deriver: ReplicateSeedDeriver,
202}
203
204impl ReplicateContext {
205    /// Returns the zero-based replicate index.
206    pub const fn index(&self) -> u64 {
207        self.index
208    }
209
210    /// Returns the total declared replicate count.
211    pub const fn count(&self) -> u64 {
212        self.count
213    }
214
215    /// Returns the existing `replicate_<index>` output scope.
216    pub const fn execution_scope(&self) -> &ExecutionScope {
217        &self.execution_scope
218    }
219
220    /// Returns the replicate output directory.
221    pub fn output_directory(&self) -> &Path {
222        self.execution_scope.directory()
223    }
224
225    /// Returns the lazy, namespace-separated seed deriver for this replicate.
226    pub const fn seed_deriver(&self) -> ReplicateSeedDeriver {
227        self.seed_deriver
228    }
229}
230
231/// Failure while dispatching or entering a replicate subprocess.
232#[derive(Debug, thiserror::Error)]
233#[non_exhaustive]
234pub enum ReplicateExecutionError {
235    /// The operating system could not identify the running executable.
236    #[error("failed to resolve the current executable for replicate dispatch")]
237    CurrentExecutable(#[source] io::Error),
238
239    /// The reserved worker environment variable was not a valid index.
240    #[error("environment variable `{variable}` contains invalid replicate index `{value}`")]
241    InvalidWorkerIndex {
242        /// Reserved environment-variable name.
243        variable: &'static str,
244        /// Rejected environment value.
245        value: String,
246    },
247
248    /// A worker index does not belong to the currently loaded settings.
249    #[error("replicate worker index {index} is outside declared count {replicates}")]
250    WorkerIndexOutOfRange {
251        /// Rejected zero-based index.
252        index: u64,
253        /// Positive count loaded from `study.json`.
254        replicates: u64,
255    },
256
257    /// A replicate output scope could not be created or reopened.
258    #[error("failed to prepare output for replicate {index}")]
259    PrepareOutput {
260        /// Affected zero-based replicate index.
261        index: u64,
262        /// Filesystem scope failure.
263        #[source]
264        source: ExecutionScopeError,
265    },
266
267    /// A replicate process could not be started or observed.
268    #[error("failed to run subprocess for replicate {index}")]
269    RunProcess {
270        /// Affected zero-based replicate index.
271        index: u64,
272        /// Operating-system process failure.
273        #[source]
274        source: io::Error,
275    },
276
277    /// One or more replicate subprocesses returned unsuccessful statuses.
278    #[error("replicate subprocesses failed at indices {indices:?}")]
279    ReplicatesFailed {
280        /// Failed indices in ascending order.
281        indices: Vec<u64>,
282    },
283}
284
285struct ActiveReplicate {
286    index: u64,
287    child: Child,
288}
289
290fn replicate_directory_name(index: u64) -> String {
291    format!("replicate_{index}")
292}
293
294fn finish_batch(failures: Vec<u64>) -> Result<(), ReplicateExecutionError> {
295    if failures.is_empty() {
296        Ok(())
297    } else {
298        Err(ReplicateExecutionError::ReplicatesFailed { indices: failures })
299    }
300}
301
302fn terminate_children(children: &mut Vec<ActiveReplicate>) {
303    for active in children.iter_mut() {
304        let _ = active.child.kill();
305    }
306    for mut active in children.drain(..) {
307        let _ = active.child.wait();
308    }
309}