Skip to main content

scientific_workflow/
storage.rs

1//! Recording persistence and reconstruction for scientific state samples.
2//!
3//! This module is the complete public storage boundary. Simulations configure
4//! named output streams with coordinate-aware sampling intervals through
5//! [`SystemStateWriterBuilder`], then offer a borrowed live [`SystemState`] to
6//! [`SystemStateWriter::observe_state`] after each evolution step. The writer
7//! checks time before accessing any payload and encodes only streams whose
8//! sampling interval includes the current iteration. One bounded queue
9//! and worker serve every configured stream, while each stream retains an
10//! independent byte-targeted chunk sequence. The recording owns exactly one
11//! authoritative `metadata.json` lifecycle.
12//!
13//! # Ownership and backpressure
14//!
15//! Sampling never clones, removes, or retains a scientific payload. The
16//! selected values are borrowed only while Serde creates one owned JSONL
17//! record. That record is then moved into the recording writer. If the configured
18//! queue-byte budget is full, [`SystemStateWriter::observe_state`] blocks until the writer
19//! commits enough queued bytes or reports a terminal error. Records are never
20//! split between chunks.
21//!
22//! # Lifecycle
23//!
24//! [`SystemStateWriterBuilder::create_new_recording`] refuses an existing output root, validates every
25//! stream against one shared state specification, publishes initial `running`
26//! metadata, and then starts the recording writer. Each chunk descriptor is committed
27//! incrementally before that payload receives its sealed filename.
28//! [`SystemStateWriter::complete_recording`] drains the writer, atomically commits
29//! completion timing and terminal metadata, and returns [`CompletedRecording`];
30//! [`SystemStateWriter::mark_recording_failed`] records an explicit failed
31//! lifecycle instead. Dropping an active recording drains its writer thread for
32//! memory and file safety but deliberately leaves metadata as `running`.
33//!
34//! [`SystemStateWriterBuilder::continue_existing_recording`] explicitly validates and appends an
35//! existing running run. [`SystemStateWriterBuilder::continue_recording_from_latest_checkpoint`]
36//! additionally reconstructs a complete owned checkpoint state through
37//! caller-supplied payload decoders. Recovery examines only the highest
38//! unsealed chunk per stream. Checkpoint-aware continuation also verifies the
39//! selected latest sealed checkpoint chunk's exact byte count and SHA-256
40//! checksum before decoding it or returning an append-capable writer.
41//!
42//! # Reading
43//!
44//! [`StoredStateSeriesReader`] accepts a completed output directory and a [`JsonPayloadDecoderRegistry`]
45//! registry. The reader validates metadata, chunks, checksums, record order,
46//! and decoder coverage before reconstructing typed
47//! [`StateSeries`](crate::time_series::StateSeries) values. Decoder
48//! implementations remain per payload type and registrations remain per exact
49//! state key. Latest-state reads verify and decode only the newest chunk.
50
51use std::collections::{HashMap, HashSet};
52use std::fs::{self, File, OpenOptions};
53use std::io::Write;
54use std::num::NonZeroU64;
55use std::path::{Path, PathBuf};
56use std::sync::{Arc, Mutex, MutexGuard};
57use std::time::{Duration, Instant};
58
59use fs2::FileExt;
60use serde::{Deserialize, Serialize};
61use serde_json::{Map, Value};
62
63use crate::clock::{duration_nanoseconds, utc_now_rfc3339};
64use crate::configuration::TaskParameters;
65use crate::system_state::{SystemState, SystemStateSchema};
66
67mod error;
68mod json_payload_decoder;
69mod json_state_record_encoder;
70mod jsonl_format;
71mod queued_state_writer;
72mod stored_state_series_reader;
73
74pub use error::StorageError;
75pub use json_payload_decoder::{
76    JsonPayloadDecoder, JsonPayloadDecoderRegistry, JsonStringDecoder, JsonVecF64Decoder,
77};
78pub use stored_state_series_reader::StoredStateSeriesReader;
79
80use json_state_record_encoder::JsonStateRecordEncoder;
81use jsonl_format::{
82    RecordingMetadata, RecordingStatus, StateFieldMetadata, StateStreamMetadata,
83    TimeAxisMetadata as StoredTimeAxis,
84};
85use queued_state_writer::{RecoveredStateStream, StateStreamStorageConfig, StateWriterWorker};
86
87/// Stable name of the sole structural metadata file in one output root.
88const METADATA_FILE: &str = "metadata.json";
89
90/// Temporary sibling used for atomic metadata replacement.
91const METADATA_TEMP_FILE: &str = ".metadata.json.tmp";
92
93/// Public description of the temporal coordinates used by a run.
94///
95/// Every record always has an integer iteration. Physical time remains
96/// optional, and its unit is legal only when a physical-coordinate name is
97/// configured. Labels are documentation persisted once in `metadata.json`;
98/// they do not change [`crate::system_state::SimulationTime`] representation.
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct TimeAxisMetadata {
101    iteration_name: String,
102    iteration_unit: Option<String>,
103    physical_time_name: Option<String>,
104    physical_time_unit: Option<String>,
105}
106
107impl TimeAxisMetadata {
108    /// Creates a time-axis declaration with a mandatory iteration label.
109    ///
110    /// Whitespace is retained in the builder and rejected by
111    /// [`SystemStateWriterBuilder::create_new_recording`], keeping fluent configuration infallible
112    /// while ensuring persisted labels are never silently normalized.
113    pub fn new(iteration_name: impl Into<String>) -> Self {
114        Self {
115            iteration_name: iteration_name.into(),
116            iteration_unit: None,
117            physical_time_name: None,
118            physical_time_unit: None,
119        }
120    }
121
122    /// Sets the optional unit of the iteration coordinate.
123    #[must_use]
124    pub fn with_iteration_unit(mut self, unit: impl Into<String>) -> Self {
125        self.iteration_unit = Some(unit.into());
126        self
127    }
128
129    /// Declares the optional floating-point physical coordinate.
130    #[must_use]
131    pub fn with_physical_time_name(mut self, name: impl Into<String>) -> Self {
132        self.physical_time_name = Some(name.into());
133        self
134    }
135
136    /// Sets the physical-coordinate unit.
137    ///
138    /// A matching [`TimeAxisMetadata::with_physical_time_name`] is required; construction fails
139    /// at [`SystemStateWriterBuilder::create_new_recording`] if the unit is configured alone.
140    #[must_use]
141    pub fn with_physical_time_unit(mut self, unit: impl Into<String>) -> Self {
142        self.physical_time_unit = Some(unit.into());
143        self
144    }
145
146    /// Declares the physical-time name and unit together.
147    #[must_use]
148    pub fn with_physical_axis(mut self, name: impl Into<String>, unit: impl Into<String>) -> Self {
149        self.physical_time_name = Some(name.into());
150        self.physical_time_unit = Some(unit.into());
151        self
152    }
153
154    /// Converts public configuration into the private persisted representation.
155    fn into_stored(self) -> StoredTimeAxis {
156        StoredTimeAxis {
157            iteration_name: self.iteration_name,
158            iteration_unit: self.iteration_unit,
159            physical_time_name: self.physical_time_name,
160            physical_time_unit: self.physical_time_unit,
161        }
162    }
163}
164
165impl Default for TimeAxisMetadata {
166    /// Uses `iteration` as the integer-time label and declares no units or
167    /// physical coordinate.
168    fn default() -> Self {
169        Self::new("iteration")
170    }
171}
172
173/// Immutable operational timing returned after successful recording completion.
174///
175/// These values describe host execution rather than scientific coordinates.
176/// Scientific iteration and physical time remain part of each recorded
177/// [`SystemState`].
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct RecordingTiming {
180    created_at_utc: String,
181    finalized_at_utc: String,
182    active_duration_ns: u64,
183    continuation_count: u64,
184}
185
186impl RecordingTiming {
187    /// Converts the validated private wire representation into the public view.
188    fn from_stored(
189        timing: &jsonl_format::RecordingTiming,
190        metadata_path: &Path,
191    ) -> Result<Self, StorageError> {
192        let finalized_at_utc =
193            timing
194                .finalized_at_utc
195                .clone()
196                .ok_or_else(|| StorageError::InvalidMetadata {
197                    path: metadata_path.to_path_buf(),
198                    reason: "completed recording lacks finalized timestamp".to_owned(),
199                })?;
200        Ok(Self {
201            created_at_utc: timing.created_at_utc.clone(),
202            finalized_at_utc,
203            active_duration_ns: timing.active_duration_ns,
204            continuation_count: timing.continuation_count,
205        })
206    }
207
208    /// Returns the recording's original UTC creation timestamp in RFC 3339 form.
209    pub fn created_at_utc(&self) -> &str {
210        &self.created_at_utc
211    }
212
213    /// Returns the successful completion timestamp in UTC RFC 3339 form.
214    pub fn finalized_at_utc(&self) -> &str {
215        &self.finalized_at_utc
216    }
217
218    /// Returns the accumulated active writer duration as exact nanoseconds.
219    pub fn active_duration_ns(&self) -> u64 {
220        self.active_duration_ns
221    }
222
223    /// Returns the accumulated active writer duration as a standard duration.
224    pub fn active_duration(&self) -> Duration {
225        Duration::from_nanos(self.active_duration_ns)
226    }
227
228    /// Returns how many times this recording was reopened for continuation.
229    pub fn continuation_count(&self) -> u64 {
230        self.continuation_count
231    }
232}
233
234/// Aggregate persisted facts for one stream in a completed recording.
235#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct CompletedStreamSummary {
237    name: String,
238    chunk_count: u64,
239    record_count: u64,
240    encoded_bytes: u64,
241    first_iteration: Option<u64>,
242    last_iteration: Option<u64>,
243}
244
245impl CompletedStreamSummary {
246    /// Returns the logical stream name.
247    pub fn name(&self) -> &str {
248        &self.name
249    }
250
251    /// Returns the number of immutable chunk files.
252    pub fn chunk_count(&self) -> u64 {
253        self.chunk_count
254    }
255
256    /// Returns the total number of recorded states.
257    pub fn record_count(&self) -> u64 {
258        self.record_count
259    }
260
261    /// Returns the exact total framed bytes across all chunks.
262    pub fn encoded_bytes(&self) -> u64 {
263        self.encoded_bytes
264    }
265
266    /// Returns the first recorded iteration, or `None` for an empty stream.
267    pub fn first_iteration(&self) -> Option<u64> {
268        self.first_iteration
269    }
270
271    /// Returns the final recorded iteration, or `None` for an empty stream.
272    pub fn last_iteration(&self) -> Option<u64> {
273        self.last_iteration
274    }
275}
276
277/// Durable result of a successfully completed recording lifecycle.
278///
279/// The active writer has been consumed and all metadata and chunks are durable
280/// before this handle is created. It cannot append data.
281#[derive(Clone, Debug, Eq, PartialEq)]
282pub struct CompletedRecording {
283    directory: PathBuf,
284    timing: RecordingTiming,
285    terminal_metadata: Map<String, Value>,
286    streams: Vec<CompletedStreamSummary>,
287}
288
289impl CompletedRecording {
290    /// Returns the completed recording directory.
291    pub fn directory(&self) -> &Path {
292        &self.directory
293    }
294
295    /// Returns automatically captured operational timing.
296    pub fn timing(&self) -> &RecordingTiming {
297        &self.timing
298    }
299
300    /// Returns caller-supplied terminal metadata committed with completion.
301    pub fn terminal_metadata(&self) -> &Map<String, Value> {
302        &self.terminal_metadata
303    }
304
305    /// Returns stream summaries in declaration order.
306    pub fn stream_summaries(&self) -> &[CompletedStreamSummary] {
307        &self.streams
308    }
309
310    /// Looks up one completed stream summary by exact name.
311    pub fn stream_summary(&self, name: &str) -> Option<&CompletedStreamSummary> {
312        self.streams.iter().find(|stream| stream.name == name)
313    }
314}
315
316/// Coordinate-aware interval used to select states for one output stream.
317///
318/// The noun variant identifies the coordinate on which the interval is
319/// measured. The current storage format supports iteration-based sampling;
320/// adding physical-time sampling later will not require overloading the word
321/// `step` or changing the surrounding stream API.
322#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
323#[serde(rename_all = "snake_case")]
324pub enum SamplingInterval {
325    /// Select iteration zero and each iteration divisible by this interval.
326    Iterations(NonZeroU64),
327}
328
329impl SamplingInterval {
330    /// Creates an iteration interval, returning `None` for zero.
331    pub const fn iterations(interval: u64) -> Option<Self> {
332        match NonZeroU64::new(interval) {
333            Some(interval) => Some(Self::Iterations(interval)),
334            None => None,
335        }
336    }
337
338    /// Reports whether this interval selects `iteration`.
339    const fn includes(self, iteration: u64) -> bool {
340        match self {
341            Self::Iterations(interval) => iteration.is_multiple_of(interval.get()),
342        }
343    }
344}
345
346/// Configuration for one independently sampled logical output stream.
347///
348/// Field names are exact keys from the run's [`SystemStateSchema`]. Their input order
349/// is irrelevant: the encoder writes them in canonical template order. The
350/// chunk byte limit is a rollover target, so a single larger record remains
351/// intact in its own oversized chunk. The queue byte limit is strict; a record
352/// larger than the complete queue budget is rejected because it can never be
353/// admitted.
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct StateStreamConfig {
356    name: String,
357    directory: String,
358    sampling_interval: SamplingInterval,
359    fields: Vec<String>,
360    storage_limits: Option<(NonZeroU64, NonZeroU64)>,
361}
362
363impl StateStreamConfig {
364    /// Creates a stream whose relative output directory initially equals its
365    /// logical name.
366    ///
367    /// Non-zero types make the sampling interval and both storage limits valid by
368    /// construction. Names, paths, duplicate fields, and state-key membership
369    /// are validated together by
370    /// [`SystemStateWriterBuilder::create_new_recording`].
371    pub fn new<I, K>(
372        name: impl Into<String>,
373        fields: I,
374        sampling_interval: SamplingInterval,
375        max_chunk_bytes: NonZeroU64,
376        queue_bytes: NonZeroU64,
377    ) -> Self
378    where
379        I: IntoIterator<Item = K>,
380        K: Into<String>,
381    {
382        let name = name.into();
383        Self {
384            directory: name.clone(),
385            name,
386            sampling_interval,
387            fields: fields.into_iter().map(Into::into).collect(),
388            storage_limits: Some((max_chunk_bytes, queue_bytes)),
389        }
390    }
391
392    /// Creates a sampled stream that inherits writer-wide storage limits.
393    fn sampled<I, K>(
394        name: impl Into<String>,
395        fields: I,
396        sampling_interval: SamplingInterval,
397    ) -> Self
398    where
399        I: IntoIterator<Item = K>,
400        K: Into<String>,
401    {
402        let name = name.into();
403        Self {
404            directory: name.clone(),
405            name,
406            sampling_interval,
407            fields: fields.into_iter().map(Into::into).collect(),
408            storage_limits: None,
409        }
410    }
411
412    /// Overrides the stream's relative directory beneath the run root.
413    ///
414    /// Absolute paths, empty paths, and `.` or `..` components are rejected at
415    /// start. Distinct streams must use distinct directories.
416    #[must_use]
417    pub fn with_relative_directory(mut self, directory: impl Into<String>) -> Self {
418        self.directory = directory.into();
419        self
420    }
421}
422
423/// Builder for one exclusive state-recording directory.
424///
425/// The builder owns only paths, immutable configuration, and a cheap shared
426/// [`SystemStateSchema`] handle. It opens no files and starts no threads before
427/// [`SystemStateWriterBuilder::create_new_recording`], [`SystemStateWriterBuilder::continue_existing_recording`], or
428/// [`SystemStateWriterBuilder::continue_recording_from_latest_checkpoint`].
429#[derive(Debug)]
430pub struct SystemStateWriterBuilder {
431    root: PathBuf,
432    spec: SystemStateSchema,
433    time: TimeAxisMetadata,
434    user_metadata: Map<String, Value>,
435    shared_stream_limits: Option<(NonZeroU64, NonZeroU64)>,
436    streams: Vec<StateStreamConfig>,
437}
438
439impl SystemStateWriterBuilder {
440    /// Creates an empty run configuration using [`TimeAxisMetadata::default`].
441    ///
442    /// `spec` is cloned only as an `Arc`-backed metadata handle. No scientific
443    /// state or payload exists in this builder.
444    pub fn new(root: impl Into<PathBuf>, spec: &SystemStateSchema) -> Self {
445        Self {
446            root: root.into(),
447            spec: spec.clone(),
448            time: TimeAxisMetadata::default(),
449            user_metadata: Map::new(),
450            shared_stream_limits: None,
451            streams: Vec::new(),
452        }
453    }
454
455    /// Replaces the run's temporal-coordinate documentation.
456    #[must_use]
457    pub fn with_time_axis_metadata(mut self, time: TimeAxisMetadata) -> Self {
458        self.time = time;
459        self
460    }
461
462    /// Replaces caller-owned metadata persisted under `user_metadata`.
463    ///
464    /// Values must already be JSON-compatible. This metadata is structurally
465    /// separate from scientific payloads and is written only to
466    /// `metadata.json`.
467    #[must_use]
468    pub fn with_user_metadata(mut self, metadata: Map<String, Value>) -> Self {
469        self.user_metadata = metadata;
470        self
471    }
472
473    /// Uses one chunk target and one bounded-queue budget for concise stream declarations.
474    ///
475    /// Limits supplied directly through [`StateStreamConfig::new`] remain
476    /// stream-specific and take precedence. Streams added through
477    /// [`SystemStateWriterBuilder::add_sampled_state_stream`] require these
478    /// shared limits.
479    #[must_use]
480    pub fn with_shared_stream_limits(
481        mut self,
482        max_chunk_bytes: NonZeroU64,
483        queue_bytes: NonZeroU64,
484    ) -> Self {
485        self.shared_stream_limits = Some((max_chunk_bytes, queue_bytes));
486        self
487    }
488
489    /// Records one resolved task dictionary as the recording's user metadata.
490    ///
491    /// Fixed and swept values retain their resolved JSON representation.
492    /// The synthetic `task_ordinal` entry is always set from the task itself and
493    /// therefore replaces any same-named input entry.
494    #[must_use]
495    pub fn with_task_parameters(mut self, parameters: &TaskParameters) -> Self {
496        self.user_metadata = parameters
497            .iter()
498            .map(|(key, value)| (key.to_owned(), value.clone()))
499            .collect();
500        self.user_metadata.insert(
501            "task_ordinal".to_owned(),
502            Value::from(parameters.task_ordinal()),
503        );
504        self
505    }
506
507    /// Appends one logical stream declaration in deterministic metadata order.
508    ///
509    /// Duplicate names or directories are reported at start so fluent builder
510    /// assembly remains infallible.
511    #[must_use]
512    pub fn add_state_stream(mut self, stream: StateStreamConfig) -> Self {
513        self.streams.push(stream);
514        self
515    }
516
517    /// Adds a sampled stream using writer-wide storage limits.
518    ///
519    /// The logical name is also its relative output directory. Applications
520    /// needing a different directory or per-stream limits can use
521    /// [`SystemStateWriterBuilder::add_state_stream`] with an explicit
522    /// [`StateStreamConfig`].
523    #[must_use]
524    pub fn add_sampled_state_stream<I, K>(
525        mut self,
526        name: impl Into<String>,
527        fields: I,
528        sampling_interval: SamplingInterval,
529    ) -> Self
530    where
531        I: IntoIterator<Item = K>,
532        K: Into<String>,
533    {
534        self.streams
535            .push(StateStreamConfig::sampled(name, fields, sampling_interval));
536        self
537    }
538
539    /// Validates the complete run, creates its exclusive output root, starts
540    /// each bounded writer, and publishes initial metadata atomically.
541    ///
542    /// # Errors
543    ///
544    /// Returns [`StorageError::RecordingDirectoryExists`] rather than replacing any
545    /// existing filesystem entry. Configuration, state-key selection,
546    /// directory creation, thread startup, JSON, and metadata durability
547    /// failures retain their precise [`StorageError`] context. If startup fails
548    /// after the root is created, the path is retained as diagnostic evidence
549    /// and is never silently removed.
550    pub fn create_new_recording(self) -> Result<SystemStateWriter, StorageError> {
551        SystemStateWriter::create_new_recording(self)
552    }
553
554    /// Continues append writing in an existing running recording directory.
555    ///
556    /// The complete builder configuration is compared with authoritative
557    /// metadata before any chunk is recovered. Only the highest open chunk in
558    /// each stream may be examined. This append-only entry point does not
559    /// reconstruct scientific state; callers requiring a verified checkpoint
560    /// must use [`Self::continue_recording_from_latest_checkpoint`].
561    pub fn continue_existing_recording(self) -> Result<SystemStateWriter, StorageError> {
562        SystemStateWriter::continue_recording(self, None).map(|(writer, _)| writer)
563    }
564
565    /// Resumes a run and reconstructs its newest complete checkpoint state.
566    ///
567    /// `stream` must cover the builder's complete state specification, and
568    /// `decoders` must cover every field. The returned state owns all decoded
569    /// payloads. When reconstruction selects a sealed chunk, its exact byte
570    /// count and SHA-256 checksum are verified before its final record is
571    /// decoded. Writer threads begin only after reconstruction succeeds.
572    pub fn continue_recording_from_latest_checkpoint(
573        self,
574        stream: &str,
575        decoders: JsonPayloadDecoderRegistry,
576    ) -> Result<(SystemStateWriter, SystemState), StorageError> {
577        let (writer, state) =
578            SystemStateWriter::continue_recording(self, Some((stream, decoders)))?;
579        Ok((
580            writer,
581            state.expect("checkpoint-aware resume always reconstructs one state"),
582        ))
583    }
584}
585
586/// Exclusive queued writer for all persistent streams in one recording.
587///
588/// This type is intentionally non-Clone. It owns the only writer handles and
589/// the only legal transition from `running` metadata to a terminal status.
590/// It owns no [`SystemState`] and never extends a payload borrow beyond one
591/// synchronous [`SystemStateWriter::observe_state`] call.
592pub struct SystemStateWriter {
593    root: PathBuf,
594    stream_order: Vec<String>,
595    manifest: Arc<RecordingManifest>,
596    streams: HashMap<String, ScheduledStateStream>,
597    writer: Option<StateWriterWorker>,
598    session_started: Instant,
599    /// Held after writers so normal field drop keeps the lease until every
600    /// worker has drained and released its manifest handle.
601    _lease: RecordingLease,
602}
603
604impl SystemStateWriter {
605    /// Begins configuring a new exclusive state-recording directory.
606    pub fn builder(root: impl Into<PathBuf>, spec: &SystemStateSchema) -> SystemStateWriterBuilder {
607        SystemStateWriterBuilder::new(root, spec)
608    }
609
610    /// Returns the recording directory exactly as configured.
611    pub fn recording_directory(&self) -> &Path {
612        &self.root
613    }
614
615    /// Iterates logical stream names in deterministic declaration order.
616    pub fn stream_names(&self) -> impl ExactSizeIterator<Item = &str> {
617        self.stream_order.iter().map(String::as_str)
618    }
619
620    /// Offers the current live state to every configured sampling stream.
621    ///
622    /// The writer first reads only the state's iteration. Streams that are
623    /// not due perform no field lookup, payload borrow, serialization,
624    /// allocation, or queue operation. Every due stream encodes its selected
625    /// fields before bounded queue admission, so backpressure retains only
626    /// owned bytes and never extends a scientific payload borrow.
627    ///
628    /// # Errors
629    ///
630    /// Returns state or payload serialization errors from a due stream,
631    /// queue-limit and ordering errors, or the writer's authoritative terminal
632    /// failure.
633    pub fn observe_state(&mut self, state: &SystemState) -> Result<(), StorageError> {
634        let iteration = state.simulation_time().iteration();
635        let writer = self
636            .writer
637            .as_ref()
638            .expect("an active recording owns its writer worker");
639        for name in &self.stream_order {
640            let stream = self
641                .streams
642                .get_mut(name)
643                .expect("stream order contains every configured stream");
644            if !stream.sampling_interval.includes(iteration)
645                || stream.last_recorded_iteration == Some(iteration)
646            {
647                continue;
648            }
649            let record = stream.encoder.encode(state)?;
650            writer.submit_record(name, record)?;
651            stream.last_recorded_iteration = Some(iteration);
652        }
653        Ok(())
654    }
655
656    /// Durably seals every record accepted earlier by one logical stream.
657    ///
658    /// This is an ordered per-stream checkpoint barrier, not merely a buffered
659    /// file flush. A non-empty open chunk is synchronized, prepared in the sole
660    /// metadata document, renamed to its sealed filename, and directory-synced
661    /// before this method returns.
662    pub fn flush_stream_to_storage(&self, stream: &str) -> Result<(), StorageError> {
663        if !self.streams.contains_key(stream) {
664            return Err(StorageError::UnknownStateStream {
665                stream: stream.to_owned(),
666            });
667        }
668        self.writer
669            .as_ref()
670            .expect("an active recording owns its writer worker")
671            .flush_state_stream(stream)
672    }
673
674    /// Drains every stream, seals all chunks, and atomically publishes complete
675    /// metadata.
676    ///
677    /// The method consumes the coordinator, making repeated finish or sampling
678    /// impossible in safe Rust. If a writer fails, all remaining writers are
679    /// still drained and a best-effort failed metadata transition is attempted
680    /// before the originating writer error is returned.
681    pub fn complete_recording(self) -> Result<CompletedRecording, StorageError> {
682        self.complete_recording_with_terminal_metadata(Map::new())
683    }
684
685    /// Completes the recording and atomically commits values known only at the
686    /// terminal boundary.
687    ///
688    /// Terminal values are stored separately from immutable creation-time user
689    /// metadata and therefore cannot silently replace task parameters.
690    pub fn complete_recording_with_terminal_metadata(
691        mut self,
692        terminal_metadata: Map<String, Value>,
693    ) -> Result<CompletedRecording, StorageError> {
694        if let Err(error) = self.finish_writer() {
695            let _ = self.transition_terminal(
696                RecordingStatus::Failed {
697                    message: error.to_string(),
698                },
699                Map::new(),
700            );
701            return Err(error);
702        }
703        self.transition_terminal(RecordingStatus::Complete, terminal_metadata)?;
704        self.completed_recording()
705    }
706
707    /// Records one final state to every stream exactly once, then completes.
708    ///
709    /// This terminal observation is independent of the sampling interval. A stream
710    /// already recorded at the same iteration is skipped, while a non-aligned final
711    /// iteration is encoded once. The writer therefore owns both interval-based and
712    /// terminal sampling decisions; the simulation supplies only a borrowed state.
713    pub fn complete_recording_with_final_state(
714        mut self,
715        state: &SystemState,
716    ) -> Result<CompletedRecording, StorageError> {
717        self.record_final_state(state)?;
718        self.complete_recording()
719    }
720
721    /// Records the final state exactly once and atomically commits terminal
722    /// user metadata with successful status and operational timing.
723    pub fn complete_recording_with_final_state_and_terminal_metadata(
724        mut self,
725        state: &SystemState,
726        terminal_metadata: Map<String, Value>,
727    ) -> Result<CompletedRecording, StorageError> {
728        self.record_final_state(state)?;
729        self.complete_recording_with_terminal_metadata(terminal_metadata)
730    }
731
732    /// Encodes the supplied terminal state for streams that lack this iteration.
733    fn record_final_state(&mut self, state: &SystemState) -> Result<(), StorageError> {
734        let iteration = state.simulation_time().iteration();
735        let writer = self
736            .writer
737            .as_ref()
738            .expect("an active recording owns its writer worker");
739        for name in &self.stream_order {
740            let stream = self
741                .streams
742                .get_mut(name)
743                .expect("stream order contains every configured stream");
744            if stream.last_recorded_iteration == Some(iteration) {
745                continue;
746            }
747            let record = stream.encoder.encode(state)?;
748            writer.submit_record(name, record)?;
749            stream.last_recorded_iteration = Some(iteration);
750        }
751        Ok(())
752    }
753
754    /// Drains every stream and atomically records an intentional failed run.
755    ///
756    /// This is appropriate when the simulation itself fails after storage has
757    /// started. The supplied message is structural recording metadata and must not be
758    /// empty or whitespace-only. Successfully accepted records remain as
759    /// immutable chunks and are listed in the failed metadata, but
760    /// [`StoredStateSeriesReader`] deliberately reconstructs only completed runs.
761    ///
762    /// If a writer also fails, its error takes precedence as the returned and
763    /// persisted reason; the caller's message would no longer describe the
764    /// authoritative storage termination.
765    pub fn mark_recording_failed(self, message: impl Into<String>) -> Result<(), StorageError> {
766        self.mark_recording_failed_with_terminal_metadata(message, Map::new())
767    }
768
769    /// Records an intentional failure with terminal-only user metadata.
770    pub fn mark_recording_failed_with_terminal_metadata(
771        mut self,
772        message: impl Into<String>,
773        terminal_metadata: Map<String, Value>,
774    ) -> Result<(), StorageError> {
775        let message = message.into();
776        if message.trim().is_empty() {
777            return Err(StorageError::InvalidConfiguration {
778                setting: "failure_message",
779                reason: "failed run message must not be empty".to_owned(),
780            });
781        }
782
783        if let Err(error) = self.finish_writer() {
784            let _ = self.transition_terminal(
785                RecordingStatus::Failed {
786                    message: error.to_string(),
787                },
788                Map::new(),
789            );
790            return Err(error);
791        }
792        self.transition_terminal(RecordingStatus::Failed { message }, terminal_metadata)
793    }
794
795    /// Performs complete validation before creating or mutating the run root.
796    fn create_new_recording(builder: SystemStateWriterBuilder) -> Result<Self, StorageError> {
797        ensure_absent(&builder.root)?;
798        let prepared = PreparedRecording::from_builder(builder)?;
799        create_root(&prepared.root)?;
800        let lease = RecordingLease::acquire(&prepared.root)?;
801        for stream in &prepared.streams {
802            stream.writer.create_directory()?;
803        }
804        commit_metadata(&prepared.root, &prepared.metadata_path, &prepared.metadata)?;
805        let manifest = Arc::new(RecordingManifest::new(
806            prepared.root.clone(),
807            prepared.metadata_path.clone(),
808            prepared.metadata,
809        ));
810        Self::start_new_prepared(prepared.root, prepared.streams, manifest, lease)
811    }
812
813    /// Validates, recovers, optionally reconstructs, and starts an append run.
814    fn continue_recording(
815        builder: SystemStateWriterBuilder,
816        checkpoint: Option<(&str, JsonPayloadDecoderRegistry)>,
817    ) -> Result<(Self, Option<SystemState>), StorageError> {
818        let prepared = PreparedRecording::from_builder(builder)?;
819        let lease = RecordingLease::acquire(&prepared.root)?;
820        remove_stale_metadata_temp(&prepared.root)?;
821        let mut existing = load_metadata(&prepared.metadata_path)?;
822        if !matches!(existing.status, RecordingStatus::Running) {
823            return Err(StorageError::RecordingNotContinuable {
824                path: prepared.metadata_path,
825            });
826        }
827        ensure_resume_match(&prepared.metadata_path, &prepared.metadata, &existing)?;
828
829        let mut recovered = Vec::with_capacity(prepared.streams.len());
830        for stream in prepared.streams {
831            let declaration = existing
832                .stream(&stream.name)
833                .expect("matched metadata contains every prepared stream");
834            let seed = StateWriterWorker::recover_state_stream(&stream.writer, declaration)?;
835            recovered.push((stream, seed));
836        }
837
838        let state = if let Some((checkpoint_stream, decoders)) = checkpoint {
839            let declaration = existing.stream(checkpoint_stream).ok_or_else(|| {
840                StorageError::UnknownStateStream {
841                    stream: checkpoint_stream.to_owned(),
842                }
843            })?;
844            let seed = recovered
845                .iter()
846                .find(|(stream, _)| stream.name == checkpoint_stream)
847                .map(|(_, seed)| seed)
848                .expect("matched stream has one recovered seed");
849            Some(stored_state_series_reader::decode_resume_state(
850                &prepared.root,
851                &prepared.metadata_path,
852                declaration,
853                &prepared.spec,
854                &decoders,
855                seed.latest_open_record(),
856            )?)
857        } else {
858            None
859        };
860
861        existing.timing.continuation_count = existing
862            .timing
863            .continuation_count
864            .checked_add(1)
865            .ok_or_else(|| StorageError::InvalidMetadata {
866                path: prepared.metadata_path.clone(),
867                reason: "timing.continuation_count overflowed".to_owned(),
868            })?;
869        commit_metadata(&prepared.root, &prepared.metadata_path, &existing)?;
870        let manifest = Arc::new(RecordingManifest::new(
871            prepared.root.clone(),
872            prepared.metadata_path.clone(),
873            existing,
874        ));
875
876        let output = Self::start_resumed_prepared(prepared.root, recovered, manifest, lease)?;
877        Ok((output, state))
878    }
879
880    /// Spawns every empty writer after the initial manifest is durable.
881    fn start_new_prepared(
882        root: PathBuf,
883        streams: Vec<PreparedStateStream>,
884        manifest: Arc<RecordingManifest>,
885        lease: RecordingLease,
886    ) -> Result<Self, StorageError> {
887        let mut scheduled = HashMap::with_capacity(streams.len());
888        let mut configs = Vec::with_capacity(streams.len());
889        let mut stream_order = Vec::with_capacity(streams.len());
890        for prepared in streams {
891            let name = prepared.name;
892            stream_order.push(name.clone());
893            scheduled.insert(
894                name,
895                ScheduledStateStream {
896                    encoder: prepared.encoder,
897                    sampling_interval: prepared.sampling_interval,
898                    last_recorded_iteration: None,
899                },
900            );
901            configs.push(prepared.writer);
902        }
903        let writer = StateWriterWorker::start_new_recording(configs, Arc::clone(&manifest))?;
904        Ok(Self {
905            root,
906            stream_order,
907            manifest,
908            streams: scheduled,
909            writer: Some(writer),
910            session_started: Instant::now(),
911            _lease: lease,
912        })
913    }
914
915    /// Spawns every append writer from its recovered active owner and indices.
916    fn start_resumed_prepared(
917        root: PathBuf,
918        streams: Vec<(PreparedStateStream, RecoveredStateStream)>,
919        manifest: Arc<RecordingManifest>,
920        lease: RecordingLease,
921    ) -> Result<Self, StorageError> {
922        let mut scheduled = HashMap::with_capacity(streams.len());
923        let mut recovered_streams = Vec::with_capacity(streams.len());
924        let mut stream_order = Vec::with_capacity(streams.len());
925        for (prepared, seed) in streams {
926            let name = prepared.name;
927            stream_order.push(name.clone());
928            scheduled.insert(
929                name,
930                ScheduledStateStream {
931                    encoder: prepared.encoder,
932                    sampling_interval: prepared.sampling_interval,
933                    last_recorded_iteration: seed.last_iteration(),
934                },
935            );
936            recovered_streams.push((prepared.writer, seed));
937        }
938        let writer = StateWriterWorker::continue_recovered_recording(
939            recovered_streams,
940            Arc::clone(&manifest),
941        )?;
942        Ok(Self {
943            root,
944            stream_order,
945            manifest,
946            streams: scheduled,
947            writer: Some(writer),
948            session_started: Instant::now(),
949            _lease: lease,
950        })
951    }
952
953    /// Drains and joins the recording's sole queued writer worker.
954    fn finish_writer(&mut self) -> Result<(), StorageError> {
955        let Some(writer) = self.writer.take() else {
956            return Ok(());
957        };
958        writer.finish_recording()
959    }
960
961    /// Commits one terminal status, timestamp, duration, and metadata map.
962    fn transition_terminal(
963        &self,
964        status: RecordingStatus,
965        terminal_metadata: Map<String, Value>,
966    ) -> Result<(), StorageError> {
967        let finalized_at_utc =
968            utc_now_rfc3339().map_err(|source| StorageError::OperationalTimestamp {
969                operation: "finalize recording",
970                source,
971            })?;
972        let active_duration_ns = duration_nanoseconds(self.session_started.elapsed())
973            .ok_or(StorageError::OperationalDurationOverflow)?;
974        self.manifest.transition_terminal(
975            status,
976            finalized_at_utc,
977            active_duration_ns,
978            terminal_metadata,
979        )
980    }
981
982    /// Builds the immutable public result from the durable manifest snapshot.
983    fn completed_recording(&self) -> Result<CompletedRecording, StorageError> {
984        let metadata = self.manifest.snapshot();
985        let timing = RecordingTiming::from_stored(&metadata.timing, &self.manifest.path)?;
986        let streams = metadata
987            .streams
988            .iter()
989            .map(completed_stream_summary)
990            .collect::<Result<Vec<_>, _>>()?;
991        Ok(CompletedRecording {
992            directory: self.root.clone(),
993            timing,
994            terminal_metadata: metadata.terminal_metadata,
995            streams,
996        })
997    }
998}
999
1000/// Derives one public stream aggregate without opening any chunk file.
1001fn completed_stream_summary(
1002    stream: &StateStreamMetadata,
1003) -> Result<CompletedStreamSummary, StorageError> {
1004    let overflow = || StorageError::ByteCountOverflow {
1005        stream: stream.name.clone(),
1006    };
1007    let chunk_count = u64::try_from(stream.chunks.len()).map_err(|_| overflow())?;
1008    let record_count = stream
1009        .chunks
1010        .iter()
1011        .try_fold(0_u64, |total, chunk| total.checked_add(chunk.records))
1012        .ok_or_else(&overflow)?;
1013    let encoded_bytes = stream
1014        .chunks
1015        .iter()
1016        .try_fold(0_u64, |total, chunk| total.checked_add(chunk.bytes))
1017        .ok_or_else(overflow)?;
1018    Ok(CompletedStreamSummary {
1019        name: stream.name.clone(),
1020        chunk_count,
1021        record_count,
1022        encoded_bytes,
1023        first_iteration: stream.chunks.first().map(|chunk| chunk.first_iteration),
1024        last_iteration: stream.chunks.last().map(|chunk| chunk.last_iteration),
1025    })
1026}
1027
1028/// Fully validated builder output before any writer thread starts.
1029struct PreparedRecording {
1030    root: PathBuf,
1031    metadata_path: PathBuf,
1032    spec: SystemStateSchema,
1033    metadata: RecordingMetadata,
1034    streams: Vec<PreparedStateStream>,
1035}
1036
1037impl PreparedRecording {
1038    /// Canonicalizes stream field order and builds expected persisted metadata.
1039    fn from_builder(builder: SystemStateWriterBuilder) -> Result<Self, StorageError> {
1040        let metadata_path = builder.root.join(METADATA_FILE);
1041        let stored_time = builder.time.into_stored();
1042        let mut names = HashSet::with_capacity(builder.streams.len());
1043        let mut directories = HashSet::with_capacity(builder.streams.len());
1044        let mut streams = Vec::with_capacity(builder.streams.len());
1045        let mut declarations = Vec::with_capacity(builder.streams.len());
1046
1047        for config in builder.streams {
1048            if !names.insert(config.name.clone()) {
1049                return Err(StorageError::DuplicateStateStream {
1050                    stream: config.name,
1051                });
1052            }
1053            if !directories.insert(config.directory.clone()) {
1054                return Err(StorageError::InvalidConfiguration {
1055                    setting: "stream.directory",
1056                    reason: format!(
1057                        "multiple streams use relative directory `{}`",
1058                        config.directory
1059                    ),
1060                });
1061            }
1062
1063            let (max_chunk_bytes, queue_bytes) = config
1064                .storage_limits
1065                .or(builder.shared_stream_limits)
1066                .ok_or_else(|| StorageError::InvalidConfiguration {
1067                    setting: "stream.storage_limits",
1068                    reason: format!(
1069                        "stream `{}` has no explicit limits and the writer has no shared limits",
1070                        config.name
1071                    ),
1072                })?;
1073            let encoder = JsonStateRecordEncoder::new(&config.name, &builder.spec, &config.fields)?;
1074            let fields = encoder
1075                .fields()
1076                .map(|name| {
1077                    let field = builder
1078                        .spec
1079                        .field_schema(name)
1080                        .expect("encoder fields were validated against this specification");
1081                    StateFieldMetadata {
1082                        name: name.to_owned(),
1083                        description: field.description().map(str::to_owned),
1084                    }
1085                })
1086                .collect::<Vec<_>>();
1087            declarations.push(StateStreamMetadata {
1088                name: config.name.clone(),
1089                directory: config.directory.clone(),
1090                sampling_interval: config.sampling_interval,
1091                fields,
1092                max_chunk_bytes: max_chunk_bytes.get(),
1093                queue_bytes: queue_bytes.get(),
1094                chunks: Vec::new(),
1095            });
1096            streams.push(PreparedStateStream {
1097                name: config.name.clone(),
1098                encoder,
1099                sampling_interval: config.sampling_interval,
1100                writer: StateStreamStorageConfig::new(
1101                    &config.name,
1102                    builder.root.join(&config.directory),
1103                    max_chunk_bytes,
1104                    queue_bytes,
1105                )?,
1106            });
1107        }
1108
1109        let created_at_utc =
1110            utc_now_rfc3339().map_err(|source| StorageError::OperationalTimestamp {
1111                operation: "create recording",
1112                source,
1113            })?;
1114        let metadata = RecordingMetadata::running(
1115            stored_time,
1116            builder.user_metadata,
1117            declarations,
1118            created_at_utc,
1119        );
1120        metadata.validate(&metadata_path)?;
1121        Ok(Self {
1122            root: builder.root,
1123            metadata_path,
1124            spec: builder.spec,
1125            metadata,
1126            streams,
1127        })
1128    }
1129}
1130
1131/// One canonical encoder paired with its immutable writer configuration.
1132struct PreparedStateStream {
1133    name: String,
1134    encoder: JsonStateRecordEncoder,
1135    sampling_interval: SamplingInterval,
1136    writer: StateStreamStorageConfig,
1137}
1138
1139/// Runtime sampling policy and encoder for one logical stream.
1140struct ScheduledStateStream {
1141    encoder: JsonStateRecordEncoder,
1142    sampling_interval: SamplingInterval,
1143    last_recorded_iteration: Option<u64>,
1144}
1145
1146/// Serialized authority over the sole mutable metadata document.
1147///
1148/// Every worker shares this small coordinator. A transaction clones metadata,
1149/// validates and persists the candidate, then replaces the in-memory snapshot
1150/// only after the atomic filesystem commit succeeds.
1151pub(crate) struct RecordingManifest {
1152    root: PathBuf,
1153    path: PathBuf,
1154    metadata: Mutex<RecordingMetadata>,
1155}
1156
1157impl RecordingManifest {
1158    /// Creates an authority from the exact snapshot already present on disk.
1159    fn new(root: PathBuf, path: PathBuf, metadata: RecordingMetadata) -> Self {
1160        Self {
1161            root,
1162            path,
1163            metadata: Mutex::new(metadata),
1164        }
1165    }
1166
1167    /// Appends one prepared descriptor and commits it before filename sealing.
1168    pub(crate) fn prepare_chunk(
1169        &self,
1170        stream: &str,
1171        descriptor: jsonl_format::ChunkMetadata,
1172    ) -> Result<(), StorageError> {
1173        let mut current = lock_metadata(&self.metadata);
1174        if !matches!(current.status, RecordingStatus::Running) {
1175            return Err(StorageError::RecordingFinished);
1176        }
1177        let mut candidate = current.clone();
1178        let declaration =
1179            candidate
1180                .stream_mut(stream)
1181                .ok_or_else(|| StorageError::UnknownStateStream {
1182                    stream: stream.to_owned(),
1183                })?;
1184        let expected = u64::try_from(declaration.chunks.len()).map_err(|_| {
1185            StorageError::ByteCountOverflow {
1186                stream: stream.to_owned(),
1187            }
1188        })?;
1189        if descriptor.ordinal != expected {
1190            return Err(StorageError::InvalidMetadata {
1191                path: self.path.clone(),
1192                reason: format!(
1193                    "stream `{stream}` prepared chunk ordinal {}, expected {expected}",
1194                    descriptor.ordinal
1195                ),
1196            });
1197        }
1198        declaration.chunks.push(descriptor);
1199        commit_metadata(&self.root, &self.path, &candidate)?;
1200        *current = candidate;
1201        Ok(())
1202    }
1203
1204    /// Atomically commits terminal lifecycle, timing, and user metadata.
1205    fn transition_terminal(
1206        &self,
1207        status: RecordingStatus,
1208        finalized_at_utc: String,
1209        active_duration_ns: u64,
1210        terminal_metadata: Map<String, Value>,
1211    ) -> Result<(), StorageError> {
1212        let mut current = lock_metadata(&self.metadata);
1213        let mut candidate = current.clone();
1214        candidate.status = status;
1215        candidate.timing.finalized_at_utc = Some(finalized_at_utc);
1216        candidate.timing.active_duration_ns = candidate
1217            .timing
1218            .active_duration_ns
1219            .checked_add(active_duration_ns)
1220            .ok_or(StorageError::OperationalDurationOverflow)?;
1221        candidate.terminal_metadata = terminal_metadata;
1222        commit_metadata(&self.root, &self.path, &candidate)?;
1223        *current = candidate;
1224        Ok(())
1225    }
1226
1227    /// Clones the small durable metadata snapshot for a public terminal result.
1228    fn snapshot(&self) -> RecordingMetadata {
1229        lock_metadata(&self.metadata).clone()
1230    }
1231}
1232
1233/// Advisory exclusive ownership of the output root directory itself.
1234///
1235/// Locking the directory handle creates no lockfile or status artifact and the
1236/// operating system releases the lease automatically after process death.
1237struct RecordingLease {
1238    _directory: File,
1239}
1240
1241impl RecordingLease {
1242    /// Acquires non-blocking exclusive writer ownership.
1243    fn acquire(root: &Path) -> Result<Self, StorageError> {
1244        let directory = File::open(root).map_err(|source| StorageError::Io {
1245            operation: "open output root for exclusive ownership",
1246            path: root.to_path_buf(),
1247            source,
1248        })?;
1249        match FileExt::try_lock_exclusive(&directory) {
1250            Ok(()) => Ok(Self {
1251                _directory: directory,
1252            }),
1253            Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => {
1254                Err(StorageError::RecordingDirectoryInUse {
1255                    path: root.to_path_buf(),
1256                })
1257            }
1258            Err(source) => Err(StorageError::Io {
1259                operation: "acquire exclusive output ownership",
1260                path: root.to_path_buf(),
1261                source,
1262            }),
1263        }
1264    }
1265}
1266
1267/// Loads and semantically validates the authoritative metadata snapshot.
1268fn load_metadata(path: &Path) -> Result<RecordingMetadata, StorageError> {
1269    let bytes = fs::read(path).map_err(|source| StorageError::Io {
1270        operation: "read metadata for resume",
1271        path: path.to_path_buf(),
1272        source,
1273    })?;
1274    let metadata: RecordingMetadata =
1275        serde_json::from_slice(&bytes).map_err(|source| StorageError::Json {
1276            operation: "parse metadata for resume",
1277            path: path.to_path_buf(),
1278            source,
1279        })?;
1280    metadata.validate(path)?;
1281    Ok(metadata)
1282}
1283
1284/// Compares every immutable run/stream setting while ignoring chunk progress.
1285fn ensure_resume_match(
1286    path: &Path,
1287    expected: &RecordingMetadata,
1288    existing: &RecordingMetadata,
1289) -> Result<(), StorageError> {
1290    let mut configuration = existing.clone();
1291    for stream in &mut configuration.streams {
1292        stream.chunks.clear();
1293    }
1294    configuration.status = RecordingStatus::Running;
1295    configuration.timing = expected.timing.clone();
1296    configuration.terminal_metadata.clear();
1297    if &configuration != expected {
1298        return Err(StorageError::RecordingConfigurationMismatch {
1299            path: path.to_path_buf(),
1300            reason: "builder time axis, user metadata, or stream declarations differ".to_owned(),
1301        });
1302    }
1303    Ok(())
1304}
1305
1306/// Removes only the known atomic-replacement remnant after acquiring the lease.
1307fn remove_stale_metadata_temp(root: &Path) -> Result<(), StorageError> {
1308    let path = root.join(METADATA_TEMP_FILE);
1309    match fs::remove_file(&path) {
1310        Ok(()) => File::open(root)
1311            .and_then(|directory| directory.sync_all())
1312            .map_err(|source| StorageError::Io {
1313                operation: "synchronize stale metadata cleanup",
1314                path: root.to_path_buf(),
1315                source,
1316            }),
1317        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
1318        Err(source) => Err(StorageError::Io {
1319            operation: "remove stale temporary metadata",
1320            path,
1321            source,
1322        }),
1323    }
1324}
1325
1326/// Locks the metadata snapshot while recovering from a participant panic.
1327fn lock_metadata(metadata: &Mutex<RecordingMetadata>) -> MutexGuard<'_, RecordingMetadata> {
1328    metadata
1329        .lock()
1330        .unwrap_or_else(|poisoned| poisoned.into_inner())
1331}
1332
1333/// Rejects every existing filesystem object and preserves IO inspection errors.
1334fn ensure_absent(root: &Path) -> Result<(), StorageError> {
1335    match root.try_exists() {
1336        Ok(false) => Ok(()),
1337        Ok(true) => Err(StorageError::RecordingDirectoryExists {
1338            path: root.to_path_buf(),
1339        }),
1340        Err(source) => Err(StorageError::Io {
1341            operation: "inspect output root",
1342            path: root.to_path_buf(),
1343            source,
1344        }),
1345    }
1346}
1347
1348/// Exclusively creates the run root, closing the check/create race safely.
1349fn create_root(root: &Path) -> Result<(), StorageError> {
1350    match fs::create_dir(root) {
1351        Ok(()) => Ok(()),
1352        Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
1353            Err(StorageError::RecordingDirectoryExists {
1354                path: root.to_path_buf(),
1355            })
1356        }
1357        Err(source) => Err(StorageError::Io {
1358            operation: "create output root",
1359            path: root.to_path_buf(),
1360            source,
1361        }),
1362    }
1363}
1364
1365/// Atomically replaces the sole authoritative metadata document.
1366///
1367/// The temporary file is created exclusively, serialized once, flushed with
1368/// `sync_all`, renamed over the previous snapshot, and followed by a directory
1369/// sync. A failed attempt removes only its precisely owned temporary path when
1370/// possible; the previous authoritative metadata remains untouched until the
1371/// rename succeeds.
1372fn commit_metadata(
1373    root: &Path,
1374    metadata_path: &Path,
1375    metadata: &RecordingMetadata,
1376) -> Result<(), StorageError> {
1377    metadata.validate(metadata_path)?;
1378    let mut bytes = serde_json::to_vec_pretty(metadata).map_err(|source| StorageError::Json {
1379        operation: "serialize metadata",
1380        path: metadata_path.to_path_buf(),
1381        source,
1382    })?;
1383    bytes.push(b'\n');
1384
1385    let temporary_path = root.join(METADATA_TEMP_FILE);
1386    let result = write_and_replace_metadata(root, metadata_path, &temporary_path, &bytes);
1387    if result.is_err() {
1388        let _ = fs::remove_file(&temporary_path);
1389    }
1390    result
1391}
1392
1393/// Performs the fallible filesystem portion of one metadata transaction.
1394fn write_and_replace_metadata(
1395    root: &Path,
1396    metadata_path: &Path,
1397    temporary_path: &Path,
1398    bytes: &[u8],
1399) -> Result<(), StorageError> {
1400    let mut temporary = OpenOptions::new()
1401        .write(true)
1402        .create_new(true)
1403        .open(temporary_path)
1404        .map_err(|source| StorageError::Io {
1405            operation: "create temporary metadata",
1406            path: temporary_path.to_path_buf(),
1407            source,
1408        })?;
1409    temporary
1410        .write_all(bytes)
1411        .map_err(|source| StorageError::Io {
1412            operation: "write temporary metadata",
1413            path: temporary_path.to_path_buf(),
1414            source,
1415        })?;
1416    temporary.sync_all().map_err(|source| StorageError::Io {
1417        operation: "sync temporary metadata",
1418        path: temporary_path.to_path_buf(),
1419        source,
1420    })?;
1421    drop(temporary);
1422
1423    fs::rename(temporary_path, metadata_path).map_err(|source| StorageError::Io {
1424        operation: "publish metadata",
1425        path: metadata_path.to_path_buf(),
1426        source,
1427    })?;
1428
1429    File::open(root)
1430        .and_then(|directory| directory.sync_all())
1431        .map_err(|source| StorageError::Io {
1432            operation: "sync output root",
1433            path: root.to_path_buf(),
1434            source,
1435        })
1436}