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