Skip to main content

scientific_workflow/storage/
stored_state_series_reader.rs

1//! All-in-one reconstruction of persisted streams into `StateSeries`.
2//!
3//! [`StoredStateSeriesReader`] is the complete public read boundary. It owns a recording
4//! directory, one validated snapshot of its sole `metadata.json`, and a
5//! caller-configured [`JsonPayloadDecoderRegistry`] registry. [`StoredStateSeriesReader::read_stream_as_state_series`] verifies a
6//! selected stream's immutable chunks, dispatches each raw payload to the
7//! decoder registered for its key, assembles complete `SystemState` values, and
8//! returns one fully validated `StateSeries`.
9//!
10//! # Transactional result
11//!
12//! A read returns either the complete series or one [`StorageError`]. States
13//! accumulated before a later checksum, record, decoder, or series failure are
14//! dropped internally and never exposed as a partial success.
15//!
16//! # Memory behavior
17//!
18//! Chunk files are processed one buffered line at a time. Record structure is
19//! deserialized with borrowed `serde_json::value::RawValue` field slices, so a
20//! payload decoder reads directly from the line buffer into its final concrete
21//! allocation. The reader does not construct an intermediate JSON value tree
22//! for tensor elements and does not retain encoded chunk bytes after a record
23//! has been reconstructed.
24//!
25//! The requested result is intentionally eager: `StateSeries` owns every
26//! reconstructed state. A future out-of-core method may be added on this same
27//! reader without exposing the private raw-record machinery.
28
29use std::borrow::Cow;
30use std::fmt;
31use std::fs::{self, File};
32use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
33use std::path::{Path, PathBuf};
34
35use serde::de::{MapAccess, Visitor};
36use serde::{Deserialize, Deserializer, Serialize};
37use serde_json::value::RawValue;
38use sha2::{Digest, Sha256};
39
40use crate::system_state::{SimulationTime, SystemState, SystemStateSchema};
41use crate::time_series::StateSeries;
42
43use super::RecordingTiming;
44use super::error::StorageError;
45use super::json_payload_decoder::JsonPayloadDecoderRegistry;
46use super::jsonl_format::{ChunkMetadata, RecordingMetadata, RecordingStatus, StateStreamMetadata};
47use super::queued_state_writer::RecoveredUnsealedRecord;
48
49/// Name of the only metadata document in one recording directory.
50const METADATA_FILE: &str = "metadata.json";
51
52/// Reader that reconstructs complete in-memory series from one completed recording.
53///
54/// Construction consumes the decoder registry so decoder configuration and
55/// metadata remain one coherent read authority. The type is intentionally
56/// non-Clone because registered decoders may not have meaningful clone
57/// semantics.
58pub struct StoredStateSeriesReader {
59    root: PathBuf,
60    metadata_path: PathBuf,
61    metadata: RecordingMetadata,
62    timing: RecordingTiming,
63    decoders: JsonPayloadDecoderRegistry,
64}
65
66impl StoredStateSeriesReader {
67    /// Opens one recording directory and validates its authoritative metadata.
68    ///
69    /// The metadata snapshot must declare successful completion. Reading an
70    /// active or failed recording is deliberately rejected because its chunk
71    /// inventory is not a complete analysis result.
72    ///
73    /// Decoder coverage is checked per selected stream by
74    /// [`StoredStateSeriesReader::read_stream_as_state_series`], allowing one registry to serve only the streams
75    /// an analysis intends to load.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`StorageError::Io`] when `metadata.json` cannot be read,
80    /// [`StorageError::Json`] when it is not syntactically valid JSON, semantic
81    /// metadata errors from internal `RecordingMetadata::validate`, or
82    /// [`StorageError::RecordingNotComplete`] unless status is complete.
83    pub fn open_completed_recording(
84        root: impl AsRef<Path>,
85        decoders: JsonPayloadDecoderRegistry,
86    ) -> Result<Self, StorageError> {
87        let root = root.as_ref().to_path_buf();
88        let metadata_path = root.join(METADATA_FILE);
89        let bytes = fs::read(&metadata_path).map_err(|source| StorageError::Io {
90            operation: "read metadata",
91            path: metadata_path.clone(),
92            source,
93        })?;
94        let metadata: RecordingMetadata =
95            serde_json::from_slice(&bytes).map_err(|source| StorageError::Json {
96                operation: "parse metadata",
97                path: metadata_path.clone(),
98                source,
99            })?;
100        metadata.validate(&metadata_path)?;
101        if !matches!(metadata.status, RecordingStatus::Complete) {
102            return Err(StorageError::RecordingNotComplete {
103                path: metadata_path,
104            });
105        }
106        let timing = RecordingTiming::from_stored(&metadata.timing, &metadata_path)?;
107        Ok(Self {
108            root,
109            metadata_path,
110            metadata,
111            timing,
112            decoders,
113        })
114    }
115
116    /// Returns the recording directory exactly as supplied at construction.
117    pub fn recording_directory(&self) -> &Path {
118        &self.root
119    }
120
121    /// Iterates declared stream names in deterministic metadata order.
122    pub fn stream_names(&self) -> impl ExactSizeIterator<Item = &str> {
123        self.metadata
124            .streams
125            .iter()
126            .map(|stream| stream.name.as_str())
127    }
128
129    /// Returns the validated storage format version.
130    pub fn format_version(&self) -> u32 {
131        self.metadata.version
132    }
133
134    /// Borrows immutable metadata supplied when recording began.
135    pub fn user_metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
136        &self.metadata.user_metadata
137    }
138
139    /// Borrows values committed only at successful completion.
140    pub fn terminal_metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
141        &self.metadata.terminal_metadata
142    }
143
144    /// Borrows automatic operational timing for the completed recording.
145    pub fn recording_timing(&self) -> &RecordingTiming {
146        &self.timing
147    }
148
149    /// Returns the metadata-declared record count for one completed stream.
150    pub fn stream_record_count(&self, stream: &str) -> Result<u64, StorageError> {
151        let declaration =
152            self.metadata
153                .stream(stream)
154                .ok_or_else(|| StorageError::UnknownStateStream {
155                    stream: stream.to_owned(),
156                })?;
157        declaration
158            .chunks
159            .iter()
160            .try_fold(0_u64, |total, chunk| total.checked_add(chunk.records))
161            .ok_or_else(|| StorageError::ByteCountOverflow {
162                stream: declaration.name.clone(),
163            })
164    }
165
166    /// Returns the exact metadata-declared encoded bytes for one stream.
167    pub fn stream_encoded_bytes(&self, stream: &str) -> Result<u64, StorageError> {
168        let declaration =
169            self.metadata
170                .stream(stream)
171                .ok_or_else(|| StorageError::UnknownStateStream {
172                    stream: stream.to_owned(),
173                })?;
174        declaration
175            .chunks
176            .iter()
177            .try_fold(0_u64, |total, chunk| total.checked_add(chunk.bytes))
178            .ok_or_else(|| StorageError::ByteCountOverflow {
179                stream: declaration.name.clone(),
180            })
181    }
182
183    /// Reconstructs one named logical stream as a complete `StateSeries`.
184    ///
185    /// The method validates decoder coverage before opening chunk files. It
186    /// then checks every chunk's filesystem length, processes its complete
187    /// JSONL records in order, verifies descriptor record/index facts and
188    /// SHA-256, and appends only fully decoded states to the private series.
189    ///
190    /// # Errors
191    ///
192    /// Returns precise stream-selection, decoder, filesystem, integrity,
193    /// record, payload-conversion, or series-invariant errors. No partially
194    /// reconstructed series is returned on failure.
195    pub fn read_stream_as_state_series(&self, stream: &str) -> Result<StateSeries, StorageError> {
196        let declaration =
197            self.metadata
198                .stream(stream)
199                .ok_or_else(|| StorageError::UnknownStateStream {
200                    stream: stream.to_owned(),
201                })?;
202        self.decoders
203            .require(declaration.fields.iter().map(|field| field.name.as_str()))?;
204
205        let spec = stream_spec(&self.metadata_path, declaration)?;
206        let total_records = self.stream_record_count(stream)?;
207        let capacity =
208            usize::try_from(total_records).map_err(|_| StorageError::ByteCountOverflow {
209                stream: declaration.name.clone(),
210            })?;
211        let mut series = StateSeries::with_capacity(spec, capacity);
212        let mut previous_iteration = None;
213
214        for chunk in &declaration.chunks {
215            self.read_chunk(declaration, chunk, &mut previous_iteration, &mut series)?;
216        }
217        Ok(series)
218    }
219
220    /// Reconstructs every declared stream in metadata order.
221    ///
222    /// Distinct stream schemas and sampling intervals remain distinct series. If any
223    /// stream fails, already reconstructed series are dropped and no partial
224    /// vector is returned.
225    pub fn read_all_streams_as_state_series(
226        &self,
227    ) -> Result<Vec<(String, StateSeries)>, StorageError> {
228        self.metadata
229            .streams
230            .iter()
231            .map(|stream| {
232                self.read_stream_as_state_series(&stream.name)
233                    .map(|series| (stream.name.clone(), series))
234            })
235            .collect()
236    }
237
238    /// Reconstructs only the latest state in one completed stream.
239    ///
240    /// Earlier chunks are not opened. The newest chunk's length and checksum
241    /// are verified, then its final newline-terminated record is decoded into
242    /// the stream's partial schema. This is suitable for final-value analysis;
243    /// use checkpoint continuation when the state must cover a complete model
244    /// schema and remain appendable.
245    pub fn read_latest_state_from_stream(&self, stream: &str) -> Result<SystemState, StorageError> {
246        let declaration =
247            self.metadata
248                .stream(stream)
249                .ok_or_else(|| StorageError::UnknownStateStream {
250                    stream: stream.to_owned(),
251                })?;
252        self.decoders
253            .require(declaration.fields.iter().map(|field| field.name.as_str()))?;
254        let chunk = declaration
255            .chunks
256            .last()
257            .ok_or_else(|| StorageError::NoRecordedState {
258                stream: declaration.name.clone(),
259            })?;
260        let path = self.root.join(&declaration.directory).join(&chunk.file);
261        verify_file_size(&path, chunk.bytes)?;
262        let bytes = fs::read(&path).map_err(|source| {
263            if source.kind() == std::io::ErrorKind::NotFound {
264                StorageError::MissingChunk { path: path.clone() }
265            } else {
266                StorageError::Io {
267                    operation: "read latest chunk",
268                    path: path.clone(),
269                    source,
270                }
271            }
272        })?;
273        verify_checksum(
274            &self.metadata_path,
275            &path,
276            &chunk.checksum,
277            Sha256::digest(&bytes),
278        )?;
279        let record = final_jsonl_record(&path, &bytes)?;
280        let spec = stream_spec(&self.metadata_path, declaration)?;
281        let state =
282            decode_state_record_with_decoders(record, &path, declaration, &spec, &self.decoders)?;
283        if state.simulation_time().iteration() != chunk.last_iteration {
284            return Err(invalid_record(
285                &path,
286                chunk.records,
287                format!(
288                    "latest record iteration {} differs from chunk descriptor {}",
289                    state.simulation_time().iteration(),
290                    chunk.last_iteration
291                ),
292            ));
293        }
294        Ok(state)
295    }
296
297    /// Verifies and reconstructs one immutable committed chunk.
298    fn read_chunk(
299        &self,
300        stream: &StateStreamMetadata,
301        chunk: &ChunkMetadata,
302        previous_iteration: &mut Option<u64>,
303        series: &mut StateSeries,
304    ) -> Result<(), StorageError> {
305        let path = self.root.join(&stream.directory).join(&chunk.file);
306        verify_file_size(&path, chunk.bytes)?;
307        let file = File::open(&path).map_err(|source| {
308            if source.kind() == std::io::ErrorKind::NotFound {
309                StorageError::MissingChunk { path: path.clone() }
310            } else {
311                StorageError::Io {
312                    operation: "open chunk",
313                    path: path.clone(),
314                    source,
315                }
316            }
317        })?;
318        let mut input = BufReader::new(file);
319        let mut line = Vec::new();
320        let mut line_number = 0_u64;
321        let mut records = 0_u64;
322        let mut first_iteration = None;
323        let mut last_iteration = None;
324        let mut hasher = Sha256::new();
325
326        loop {
327            line.clear();
328            let bytes_read =
329                input
330                    .read_until(b'\n', &mut line)
331                    .map_err(|source| StorageError::Io {
332                        operation: "read chunk",
333                        path: path.clone(),
334                        source,
335                    })?;
336            if bytes_read == 0 {
337                break;
338            }
339            line_number =
340                line_number
341                    .checked_add(1)
342                    .ok_or_else(|| StorageError::ByteCountOverflow {
343                        stream: stream.name.clone(),
344                    })?;
345            hasher.update(&line);
346            if line.last() != Some(&b'\n') {
347                return Err(invalid_record(
348                    &path,
349                    line_number,
350                    "record is not terminated by a newline",
351                ));
352            }
353            line.pop();
354            if line.is_empty() {
355                return Err(invalid_record(
356                    &path,
357                    line_number,
358                    "record line must not be empty",
359                ));
360            }
361
362            let record: BorrowedRecord<'_> = serde_json::from_slice(&line).map_err(|source| {
363                invalid_record(&path, line_number, format!("invalid JSON record: {source}"))
364            })?;
365            validate_iteration(&path, line_number, record.iteration, *previous_iteration)?;
366            let iteration = record.iteration;
367            first_iteration.get_or_insert(iteration);
368            last_iteration = Some(iteration);
369            *previous_iteration = Some(iteration);
370
371            let time = match record.physical_time {
372                Some(physical_time) => {
373                    SimulationTime::from_iteration_and_physical_time(iteration, physical_time)
374                        .ok_or_else(|| {
375                            invalid_record(&path, line_number, "physical time must be finite")
376                        })?
377                }
378                None => SimulationTime::from_iteration(iteration),
379            };
380            let mut state = series.schema().create_empty_state(time);
381            decode_values(
382                &self.decoders,
383                stream,
384                &path,
385                line_number,
386                record.values,
387                &mut state,
388            )?;
389            series.push_state(state).map_err(|rejection| {
390                let (source, state) = rejection.into_parts();
391                let index = state.simulation_time().iteration();
392                drop(state);
393                StorageError::StateSeriesInvariant {
394                    stream: stream.name.clone(),
395                    iteration: index,
396                    source,
397                }
398            })?;
399            records = records
400                .checked_add(1)
401                .ok_or_else(|| StorageError::ByteCountOverflow {
402                    stream: stream.name.clone(),
403                })?;
404        }
405
406        validate_chunk_facts(
407            &self.metadata_path,
408            stream,
409            chunk,
410            records,
411            first_iteration,
412            last_iteration,
413        )?;
414        verify_checksum(
415            &self.metadata_path,
416            &path,
417            &chunk.checksum,
418            hasher.finalize(),
419        )
420    }
421}
422
423/// Borrows the final nonempty newline-terminated record from one chunk image.
424fn final_jsonl_record<'a>(path: &Path, bytes: &'a [u8]) -> Result<&'a [u8], StorageError> {
425    if !bytes.ends_with(b"\n") {
426        return Err(invalid_record(
427            path,
428            1,
429            "latest chunk is not terminated by a newline",
430        ));
431    }
432    let without_final_newline = &bytes[..bytes.len() - 1];
433    let start = without_final_newline
434        .iter()
435        .rposition(|byte| *byte == b'\n')
436        .map_or(0, |position| position + 1);
437    let record = &without_final_newline[start..];
438    if record.is_empty() {
439        return Err(invalid_record(path, 1, "latest record must not be empty"));
440    }
441    Ok(record)
442}
443
444impl fmt::Debug for StoredStateSeriesReader {
445    /// Formats bounded configuration without decoder internals or payloads.
446    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
447        formatter
448            .debug_struct("StoredStateSeriesReader")
449            .field("root", &self.root)
450            .field("streams", &self.metadata.streams.len())
451            .field("decoders", &self.decoders)
452            .finish_non_exhaustive()
453    }
454}
455
456/// Reconstructs the newest complete state used by coordinated run resume.
457///
458/// `open_record` is the final complete JSONL object already obtained while the
459/// progress checker examined the sole unsealed chunk. When absent, this helper
460/// seeks directly to the last record of the newest sealed chunk. It does not
461/// scan, hash, size-check, or decode any earlier sealed record.
462pub(crate) fn decode_resume_state(
463    root: &Path,
464    _metadata_path: &Path,
465    stream: &StateStreamMetadata,
466    full_spec: &SystemStateSchema,
467    decoders: &JsonPayloadDecoderRegistry,
468    open_record: Option<&RecoveredUnsealedRecord>,
469) -> Result<SystemState, StorageError> {
470    validate_complete_resume_schema(stream, full_spec)?;
471    decoders.require(full_spec.field_schemas().iter().map(|field| field.name()))?;
472
473    if let Some(record) = open_record {
474        let bytes = read_open_record(record, &stream.name)?;
475        return decode_state_record_with_decoders(
476            &bytes,
477            record.path(),
478            stream,
479            full_spec,
480            decoders,
481        );
482    }
483
484    let chunk = stream
485        .chunks
486        .last()
487        .ok_or_else(|| StorageError::NoCheckpointState {
488            stream: stream.name.clone(),
489        })?;
490    let path = root.join(&stream.directory).join(&chunk.file);
491    let record = read_last_sealed_record(&path)?;
492    if record.is_empty() {
493        return Err(StorageError::NoCheckpointState {
494            stream: stream.name.clone(),
495        });
496    }
497    decode_state_record_with_decoders(&record, &path, stream, full_spec, decoders)
498}
499
500/// Reads one recovery-selected open record without rescanning its chunk.
501fn read_open_record(
502    record: &RecoveredUnsealedRecord,
503    stream: &str,
504) -> Result<Vec<u8>, StorageError> {
505    let length = usize::try_from(record.bytes()).map_err(|_| StorageError::ByteCountOverflow {
506        stream: stream.to_owned(),
507    })?;
508    let mut file = File::open(record.path()).map_err(|source| StorageError::Io {
509        operation: "open latest recoverable record",
510        path: record.path().to_path_buf(),
511        source,
512    })?;
513    file.seek(SeekFrom::Start(record.offset()))
514        .map_err(|source| StorageError::Io {
515            operation: "seek latest recoverable record",
516            path: record.path().to_path_buf(),
517            source,
518        })?;
519    let mut bytes = vec![0_u8; length];
520    file.read_exact(&mut bytes)
521        .map_err(|source| StorageError::Io {
522            operation: "read latest recoverable record",
523            path: record.path().to_path_buf(),
524            source,
525        })?;
526    Ok(bytes)
527}
528
529/// Requires exact key order and descriptions for a full-state checkpoint.
530fn validate_complete_resume_schema(
531    stream: &StateStreamMetadata,
532    full_spec: &SystemStateSchema,
533) -> Result<(), StorageError> {
534    if stream.fields.len() != full_spec.len() {
535        return Err(StorageError::IncompleteCheckpointStream {
536            stream: stream.name.clone(),
537            reason: format!(
538                "stream declares {} fields but the full state declares {}",
539                stream.fields.len(),
540                full_spec.len()
541            ),
542        });
543    }
544    for (position, (stored, expected)) in stream
545        .fields
546        .iter()
547        .zip(full_spec.field_schemas())
548        .enumerate()
549    {
550        if stored.name != expected.name() || stored.description.as_deref() != expected.description()
551        {
552            return Err(StorageError::IncompleteCheckpointStream {
553                stream: stream.name.clone(),
554                reason: format!(
555                    "field {position} is `{}` but full state requires `{}`",
556                    stored.name,
557                    expected.name()
558                ),
559            });
560        }
561    }
562    Ok(())
563}
564
565/// Parses one record and dispatches each raw field into its owned final type.
566fn decode_state_record_with_decoders(
567    record: &[u8],
568    path: &Path,
569    stream: &StateStreamMetadata,
570    full_spec: &SystemStateSchema,
571    decoders: &JsonPayloadDecoderRegistry,
572) -> Result<SystemState, StorageError> {
573    let record: BorrowedRecord<'_> = serde_json::from_slice(record)
574        .map_err(|source| invalid_record(path, 1, format!("invalid JSON record: {source}")))?;
575    let time = match record.physical_time {
576        Some(physical_time) => {
577            SimulationTime::from_iteration_and_physical_time(record.iteration, physical_time)
578                .ok_or_else(|| invalid_record(path, 1, "physical time must be finite"))?
579        }
580        None => SimulationTime::from_iteration(record.iteration),
581    };
582    let mut state = full_spec.create_empty_state(time);
583    decode_values(decoders, stream, path, 1, record.values, &mut state)?;
584    Ok(state)
585}
586
587/// Reads only the final newline-terminated JSONL record using backward blocks.
588fn read_last_sealed_record(path: &Path) -> Result<Vec<u8>, StorageError> {
589    const BLOCK: u64 = 8 * 1024;
590    let mut file = File::open(path).map_err(|source| {
591        if source.kind() == std::io::ErrorKind::NotFound {
592            StorageError::MissingChunk {
593                path: path.to_path_buf(),
594            }
595        } else {
596            StorageError::Io {
597                operation: "open latest sealed checkpoint",
598                path: path.to_path_buf(),
599                source,
600            }
601        }
602    })?;
603    let length = file
604        .metadata()
605        .map_err(|source| StorageError::Io {
606            operation: "inspect latest sealed checkpoint",
607            path: path.to_path_buf(),
608            source,
609        })?
610        .len();
611    if length == 0 {
612        return Ok(Vec::new());
613    }
614
615    file.seek(SeekFrom::Start(length - 1))
616        .map_err(|source| StorageError::Io {
617            operation: "seek latest sealed checkpoint",
618            path: path.to_path_buf(),
619            source,
620        })?;
621    let mut final_byte = [0_u8; 1];
622    file.read_exact(&mut final_byte)
623        .map_err(|source| StorageError::Io {
624            operation: "read latest sealed checkpoint",
625            path: path.to_path_buf(),
626            source,
627        })?;
628    if final_byte[0] != b'\n' {
629        return Err(invalid_record(
630            path,
631            1,
632            "sealed chunk does not end with a record newline",
633        ));
634    }
635
636    let record_end = length - 1;
637    let mut cursor = record_end;
638    let mut record_start = 0_u64;
639    while cursor > 0 {
640        let block_start = cursor.saturating_sub(BLOCK);
641        let block_len =
642            usize::try_from(cursor - block_start).map_err(|_| StorageError::ByteCountOverflow {
643                stream: path.display().to_string(),
644            })?;
645        let mut block = vec![0_u8; block_len];
646        file.seek(SeekFrom::Start(block_start))
647            .and_then(|_| file.read_exact(&mut block))
648            .map_err(|source| StorageError::Io {
649                operation: "scan latest sealed record boundary",
650                path: path.to_path_buf(),
651                source,
652            })?;
653        if let Some(position) = block.iter().rposition(|byte| *byte == b'\n') {
654            record_start = block_start + position as u64 + 1;
655            break;
656        }
657        cursor = block_start;
658    }
659
660    let record_len = usize::try_from(record_end - record_start).map_err(|_| {
661        StorageError::ByteCountOverflow {
662            stream: path.display().to_string(),
663        }
664    })?;
665    let mut record = vec![0_u8; record_len];
666    file.seek(SeekFrom::Start(record_start))
667        .and_then(|_| file.read_exact(&mut record))
668        .map_err(|source| StorageError::Io {
669            operation: "read latest sealed record",
670            path: path.to_path_buf(),
671            source,
672        })?;
673    Ok(record)
674}
675
676/// Borrowed JSONL record whose payload values point into one line buffer.
677#[derive(Deserialize)]
678#[serde(deny_unknown_fields)]
679struct BorrowedRecord<'a> {
680    iteration: u64,
681    #[serde(default)]
682    physical_time: Option<f64>,
683    #[serde(borrow)]
684    values: BorrowedValues<'a>,
685}
686
687/// Duplicate-preserving field collection backed by borrowed raw JSON slices.
688struct BorrowedValues<'a> {
689    entries: Vec<(String, &'a RawValue)>,
690}
691
692impl<'de: 'a, 'a> Deserialize<'de> for BorrowedValues<'a> {
693    /// Rejects duplicate keys while retaining raw value boundaries.
694    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
695    where
696        D: Deserializer<'de>,
697    {
698        deserializer.deserialize_map(BorrowedValuesVisitor {
699            output: std::marker::PhantomData,
700        })
701    }
702}
703
704/// Serde visitor for one record's `values` object.
705struct BorrowedValuesVisitor<'a> {
706    /// Selects the shorter lifetime exposed by the containing record.
707    output: std::marker::PhantomData<&'a RawValue>,
708}
709
710impl<'de: 'a, 'a> Visitor<'de> for BorrowedValuesVisitor<'a> {
711    type Value = BorrowedValues<'a>;
712
713    /// Describes the required JSON representation.
714    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
715        formatter.write_str("an object of unique field keys and raw JSON values")
716    }
717
718    /// Collects small owned keys while every potentially large value is
719    /// borrowed directly from the input line.
720    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
721    where
722        A: MapAccess<'de>,
723    {
724        let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0));
725        while let Some((key, value)) = map.next_entry::<Cow<'de, str>, &'de RawValue>()? {
726            if entries.iter().any(|(existing, _)| existing == key.as_ref()) {
727                return Err(serde::de::Error::custom(format!(
728                    "duplicate payload field `{key}`"
729                )));
730            }
731            let value: &'a RawValue = value;
732            entries.push((key.into_owned(), value));
733        }
734        Ok(BorrowedValues { entries })
735    }
736}
737
738/// Serde representation accepted by the crate-private SystemStateSchema parser.
739#[derive(Serialize)]
740struct StreamTemplateRef<'a> {
741    fields: &'a [super::jsonl_format::StateFieldMetadata],
742}
743
744/// Reconstructs one stream's immutable key/description specification.
745fn stream_spec(
746    metadata_path: &Path,
747    stream: &StateStreamMetadata,
748) -> Result<SystemStateSchema, StorageError> {
749    let bytes = serde_json::to_vec(&StreamTemplateRef {
750        fields: &stream.fields,
751    })
752    .map_err(|source| StorageError::Json {
753        operation: "serialize stream schema",
754        path: metadata_path.to_path_buf(),
755        source,
756    })?;
757    SystemStateSchema::parse(metadata_path.to_path_buf(), &bytes).map_err(|source| {
758        StorageError::InvalidMetadata {
759            path: metadata_path.to_path_buf(),
760            reason: format!(
761                "stream `{}` has an invalid state schema: {source}",
762                stream.name
763            ),
764        }
765    })
766}
767
768/// Verifies filesystem length before allocating decoded payloads.
769fn verify_file_size(path: &Path, expected: u64) -> Result<(), StorageError> {
770    let actual = match fs::metadata(path) {
771        Ok(metadata) => metadata.len(),
772        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
773            return Err(StorageError::MissingChunk {
774                path: path.to_path_buf(),
775            });
776        }
777        Err(source) => {
778            return Err(StorageError::Io {
779                operation: "inspect chunk",
780                path: path.to_path_buf(),
781                source,
782            });
783        }
784    };
785    if actual != expected {
786        return Err(StorageError::ChunkSizeMismatch {
787            path: path.to_path_buf(),
788            expected,
789            actual,
790        });
791    }
792    Ok(())
793}
794
795/// Enforces strict iteration order across chunk boundaries.
796fn validate_iteration(
797    path: &Path,
798    line: u64,
799    iteration: u64,
800    previous: Option<u64>,
801) -> Result<(), StorageError> {
802    if let Some(previous) = previous
803        && iteration <= previous
804    {
805        return Err(invalid_record(
806            path,
807            line,
808            format!("iteration {iteration} is not greater than previous iteration {previous}"),
809        ));
810    }
811    Ok(())
812}
813
814/// Validates exact keys, dispatches canonical decoders, and fills one state.
815fn decode_values(
816    decoders: &JsonPayloadDecoderRegistry,
817    stream: &StateStreamMetadata,
818    path: &Path,
819    line: u64,
820    mut values: BorrowedValues<'_>,
821    state: &mut crate::system_state::SystemState,
822) -> Result<(), StorageError> {
823    for field in &stream.fields {
824        let Some(position) = values
825            .entries
826            .iter()
827            .position(|(name, _)| name == &field.name)
828        else {
829            return Err(invalid_record(
830                path,
831                line,
832                format!("missing payload field `{}`", field.name),
833            ));
834        };
835        let (_, raw) = values.entries.swap_remove(position);
836        decoders.decode_into(
837            &stream.name,
838            state.simulation_time().iteration(),
839            &field.name,
840            raw.get(),
841            state,
842        )?;
843    }
844    if !values.entries.is_empty() {
845        let mut extra = values
846            .entries
847            .into_iter()
848            .map(|(name, _)| name)
849            .collect::<Vec<_>>();
850        extra.sort_unstable();
851        return Err(invalid_record(
852            path,
853            line,
854            format!("undeclared payload fields: {}", extra.join(", ")),
855        ));
856    }
857    Ok(())
858}
859
860/// Compares parsed chunk facts with the authoritative metadata descriptor.
861fn validate_chunk_facts(
862    metadata_path: &Path,
863    stream: &StateStreamMetadata,
864    chunk: &ChunkMetadata,
865    records: u64,
866    first_iteration: Option<u64>,
867    last_iteration: Option<u64>,
868) -> Result<(), StorageError> {
869    if records != chunk.records
870        || first_iteration != Some(chunk.first_iteration)
871        || last_iteration != Some(chunk.last_iteration)
872    {
873        return Err(StorageError::InvalidMetadata {
874            path: metadata_path.to_path_buf(),
875            reason: format!(
876                "stream `{}` chunk {} declares {} records at {}..={}, but contains {} records at {:?}..={:?}",
877                stream.name,
878                chunk.ordinal,
879                chunk.records,
880                chunk.first_iteration,
881                chunk.last_iteration,
882                records,
883                first_iteration,
884                last_iteration
885            ),
886        });
887    }
888    Ok(())
889}
890
891/// Compares the streamed SHA-256 digest with the descriptor checksum.
892fn verify_checksum(
893    metadata_path: &Path,
894    path: &Path,
895    expected: &str,
896    digest: impl AsRef<[u8]>,
897) -> Result<(), StorageError> {
898    let Some(expected_digest) = expected.strip_prefix("sha256:") else {
899        return Err(StorageError::InvalidMetadata {
900            path: metadata_path.to_path_buf(),
901            reason: format!("unsupported chunk checksum algorithm in `{expected}`"),
902        });
903    };
904    let actual_digest = lowercase_hex(digest.as_ref());
905    if actual_digest != expected_digest {
906        return Err(StorageError::ChecksumMismatch {
907            path: path.to_path_buf(),
908            expected: expected.to_owned(),
909            actual: format!("sha256:{actual_digest}"),
910        });
911    }
912    Ok(())
913}
914
915/// Encodes digest bytes in the persisted lowercase hexadecimal notation.
916fn lowercase_hex(bytes: &[u8]) -> String {
917    use std::fmt::Write as _;
918
919    let mut encoded = String::with_capacity(bytes.len() * 2);
920    for byte in bytes {
921        write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
922    }
923    encoded
924}
925
926/// Constructs one line-aware record error without retaining payload bytes.
927fn invalid_record(path: &Path, line: u64, reason: impl Into<String>) -> StorageError {
928    StorageError::InvalidRecord {
929        path: path.to_path_buf(),
930        line,
931        reason: reason.into(),
932    }
933}