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