Skip to main content

scientific_workflow/storage/
error.rs

1//! Errors produced by persistent scientific-workflow storage.
2//!
3//! This module owns diagnostic context for the complete storage boundary:
4//! versioned metadata, record encoding and decoding, output directories,
5//! immutable chunk files, bounded writer queues, and worker lifecycle. It does
6//! not redefine errors that belong to the in-memory data model. Instead,
7//! [`StorageError`] wraps [`StateError`] or [`StateSeriesError`] when storage adds stream,
8//! record, or filesystem context to one of those failures.
9//!
10//! # Context ownership
11//!
12//! Paths, stream names, field names, indices, and validation explanations are
13//! owned by each error. An error therefore remains useful after its encoder,
14//! reader, writer, decoder, or run coordinator has been dropped. These
15//! allocations occur only on failure paths.
16//!
17//! # Source preservation
18//!
19//! IO, JSON, state access, series collection, custom field decoding, and
20//! terminal worker failures preserve their underlying errors through
21//! [`std::error::Error::source`]. Semantic format and lifecycle failures record
22//! their complete conflicting values directly because they have no lower-level
23//! source.
24//!
25//! # Responsibility boundary
26//!
27//! `StorageError` contains no scientific payload. In particular, a decoded
28//! state that violates a series invariant is dropped before its
29//! [`StateSeriesError`] is wrapped. Writer-terminal errors are shared through `Arc`
30//! so every blocked or later submitter can observe one authoritative failure
31//! without requiring `StorageError: Clone`.
32
33use std::error::Error;
34use std::io;
35use std::path::PathBuf;
36use std::sync::Arc;
37
38use thiserror::Error;
39
40use crate::system_state::StateError;
41use crate::time_series::StateSeriesError;
42
43/// A failure encountered while encoding, writing, reading, or decoding a run.
44///
45/// Variants are grouped by the boundary that detects them: configuration and
46/// lifecycle, persisted-format validation, field processing, filesystem and
47/// JSON mechanics, and asynchronous writer coordination.
48///
49/// The enum is non-exhaustive so future integrity checks or durability modes
50/// can add precise variants without forcing downstream crates to exhaustively
51/// match every storage failure.
52#[derive(Debug, Error)]
53#[non_exhaustive]
54pub enum StorageError {
55    // ---------------------------------------------------------------------
56    // Configuration and lifecycle
57    // ---------------------------------------------------------------------
58    /// A new recording refused to replace an existing path.
59    ///
60    /// Storage never silently overwrites a previous recording. Existing
61    /// running recordings are accepted only through explicit continuation.
62    #[error("recording directory `{path}` already exists")]
63    RecordingDirectoryExists {
64        /// Existing path that prevented recording creation.
65        path: PathBuf,
66    },
67
68    /// One storage setting violates a constructor invariant.
69    ///
70    /// Settings represented by `NonZero*` types are rejected before this
71    /// point. This variant covers relationships between values, unsafe relative
72    /// paths, unsupported names, and similar semantic configuration failures.
73    #[error("invalid storage setting `{setting}`: {reason}")]
74    InvalidConfiguration {
75        /// Stable setting name used by documentation and diagnostics.
76        setting: &'static str,
77        /// Concise explanation of the violated invariant.
78        reason: String,
79    },
80
81    /// Two logical output streams were configured with the same name.
82    #[error("output stream `{stream}` is configured more than once")]
83    DuplicateStateStream {
84        /// Repeated normalized stream name.
85        stream: String,
86    },
87
88    /// A caller selected a stream absent from the recording declaration.
89    #[error("recording does not declare state stream `{stream}`")]
90    UnknownStateStream {
91        /// Requested stream name.
92        stream: String,
93    },
94
95    /// The recording-wide writer has stopped accepting new work.
96    #[error("system-state writer has stopped accepting records")]
97    StateWriterClosed,
98
99    /// A caller repeated a recording operation after successful termination.
100    #[error("state recording has already finished")]
101    RecordingFinished,
102
103    /// Another writer currently owns the recording directory.
104    #[error("state recording `{path}` is already owned by another writer")]
105    RecordingDirectoryInUse {
106        /// Output root whose advisory exclusive lease could not be acquired.
107        path: PathBuf,
108    },
109
110    /// Explicit continuation was requested for a terminal recording.
111    #[error("recording metadata `{path}` is terminal and cannot be continued")]
112    RecordingNotContinuable {
113        /// Metadata file declaring a complete or failed lifecycle.
114        path: PathBuf,
115    },
116
117    /// Existing running configuration differs from the requested builder.
118    #[error("cannot continue recording metadata `{path}`: {reason}")]
119    RecordingConfigurationMismatch {
120        /// Existing authoritative metadata document.
121        path: PathBuf,
122        /// Concise description of the incompatible configuration.
123        reason: String,
124    },
125
126    /// A stream selected as a checkpoint omits part of the full state schema.
127    #[error("stream `{stream}` cannot reconstruct the full system state: {reason}")]
128    IncompleteCheckpointStream {
129        /// Logical stream selected for latest-checkpoint reconstruction.
130        stream: String,
131        /// Missing, additional, or reordered schema detail.
132        reason: String,
133    },
134
135    /// No complete record exists from which a state can be reconstructed.
136    #[error("stream `{stream}` contains no complete checkpoint record")]
137    NoCheckpointState {
138        /// Logical checkpoint stream searched during continuation.
139        stream: String,
140    },
141
142    /// A completed stream contains no record to reconstruct.
143    #[error("stream `{stream}` contains no recorded state")]
144    NoRecordedState {
145        /// Logical completed stream searched for its latest state.
146        stream: String,
147    },
148
149    /// Chunk filenames do not describe one recoverable committed prefix and
150    /// at most one highest open chunk.
151    #[error("cannot recover stream output at `{path}`: {reason}")]
152    RecoveryConflict {
153        /// Stream directory or conflicting payload path.
154        path: PathBuf,
155        /// Concise filename/inventory conflict.
156        reason: String,
157    },
158
159    /// The host UTC clock could not be represented in the canonical metadata
160    /// timestamp format.
161    #[error("failed to format the operational timestamp while attempting to {operation}")]
162    OperationalTimestamp {
163        /// Lifecycle action requesting the timestamp.
164        operation: &'static str,
165        /// Timestamp-formatting failure.
166        #[source]
167        source: time::error::Format,
168    },
169
170    /// A monotonic writer session exceeded the exact persisted duration range.
171    #[error("active recording duration exceeds the supported u64 nanosecond range")]
172    OperationalDurationOverflow,
173
174    // ---------------------------------------------------------------------
175    // Persisted format and integrity
176    // ---------------------------------------------------------------------
177    /// `metadata.json` declares a format version this crate cannot read.
178    #[error(
179        "metadata file `{path}` uses format version {found}, but this crate supports version {supported}"
180    )]
181    UnsupportedVersion {
182        /// Metadata file containing the unsupported declaration.
183        path: PathBuf,
184        /// Version found in the file.
185        found: u32,
186        /// Version implemented by this crate.
187        supported: u32,
188    },
189
190    /// Syntactically valid metadata violates a semantic storage invariant.
191    #[error("invalid recording metadata in `{path}`: {reason}")]
192    InvalidMetadata {
193        /// Authoritative metadata file that failed validation.
194        path: PathBuf,
195        /// Concise invariant violation.
196        reason: String,
197    },
198
199    /// A reader requiring a completed recording encountered terminally
200    /// unsuitable metadata.
201    #[error("recording metadata `{path}` does not declare successful completion")]
202    RecordingNotComplete {
203        /// Metadata file whose lifecycle state is incomplete or failed.
204        path: PathBuf,
205    },
206
207    /// A committed chunk named by metadata is absent from the filesystem.
208    #[error("committed chunk `{path}` is missing")]
209    MissingChunk {
210        /// Expected chunk path.
211        path: PathBuf,
212    },
213
214    /// A committed chunk's actual length differs from its metadata descriptor.
215    #[error("chunk `{path}` has {actual} bytes, but metadata declares {expected}")]
216    ChunkSizeMismatch {
217        /// Chunk path whose filesystem length was checked.
218        path: PathBuf,
219        /// Authoritative encoded byte length from metadata.
220        expected: u64,
221        /// Encoded byte length reported by the filesystem.
222        actual: u64,
223    },
224
225    /// A committed chunk's checksum differs from its metadata descriptor.
226    #[error("chunk `{path}` checksum is `{actual}`, but metadata declares `{expected}`")]
227    ChecksumMismatch {
228        /// Chunk path whose contents were checked.
229        path: PathBuf,
230        /// Authoritative checksum encoded in metadata.
231        expected: String,
232        /// Checksum computed from the chunk contents.
233        actual: String,
234    },
235
236    /// One syntactically readable JSONL record violates record invariants.
237    #[error("invalid record at line {line} of `{path}`: {reason}")]
238    InvalidRecord {
239        /// Chunk file containing the invalid record.
240        path: PathBuf,
241        /// One-based JSONL line number.
242        line: u64,
243        /// Concise framing or semantic invariant violation.
244        reason: String,
245    },
246
247    // ---------------------------------------------------------------------
248    // State borrowing, encoding, and decoding
249    // ---------------------------------------------------------------------
250    /// The encoder could not borrow one declared field from the live state.
251    #[error(
252        "cannot sample field `{field}` for stream `{stream}` at iteration {iteration}: {source}"
253    )]
254    StateAccess {
255        /// Logical output stream being sampled.
256        stream: String,
257        /// Iteration of the sampled state.
258        iteration: u64,
259        /// Declared stream field that could not be borrowed.
260        field: String,
261        /// Original SystemState access failure.
262        #[source]
263        source: StateError,
264    },
265
266    /// Serde failed while encoding one borrowed payload.
267    #[error("failed to encode field `{field}` for stream `{stream}` at iteration {iteration}")]
268    EncodeField {
269        /// Logical output stream being encoded.
270        stream: String,
271        /// Iteration of the sampled state.
272        iteration: u64,
273        /// Field whose payload serializer failed.
274        field: String,
275        /// Underlying JSON serializer failure.
276        #[source]
277        source: serde_json::Error,
278    },
279
280    /// A decoder registration attempted to reuse one field key.
281    #[error("a payload decoder is already registered for field `{field}`")]
282    DuplicateDecoder {
283        /// Repeated state field key.
284        field: String,
285    },
286
287    /// No concrete payload decoder was declared for a persisted field key.
288    #[error("no payload decoder is registered for field `{field}`")]
289    MissingDecoder {
290        /// Persisted state field key requiring reconstruction.
291        field: String,
292    },
293
294    /// A user-supplied field decoder failed to reconstruct its concrete value.
295    #[error("failed to decode field `{field}` for stream `{stream}` at iteration {iteration}")]
296    DecodeField {
297        /// Logical stream being reconstructed.
298        stream: String,
299        /// Iteration of the raw record.
300        iteration: u64,
301        /// Field whose registered decoder failed.
302        field: String,
303        /// Decoder-specific failure retained behind an object-safe boundary.
304        #[source]
305        source: Box<dyn Error + Send + Sync + 'static>,
306    },
307
308    /// A reconstructed state violated its destination series invariant.
309    ///
310    /// The rejected state is intentionally not retained in this error: it was
311    /// created from persisted input and is dropped on failed reconstruction,
312    /// preventing an error value from pinning arbitrarily large payloads.
313    #[error("decoded state for stream `{stream}` at iteration {iteration} cannot enter its series")]
314    StateSeriesInvariant {
315        /// Logical stream being reconstructed.
316        stream: String,
317        /// Iteration of the rejected decoded state.
318        iteration: u64,
319        /// Original in-memory collection invariant failure.
320        #[source]
321        source: StateSeriesError,
322    },
323
324    // ---------------------------------------------------------------------
325    // Filesystem and JSON mechanics
326    // ---------------------------------------------------------------------
327    /// A filesystem operation failed.
328    #[error("failed to {operation} at `{path}`")]
329    Io {
330        /// Stable action description such as `create chunk` or `sync metadata`.
331        operation: &'static str,
332        /// Filesystem path involved in the failed operation.
333        path: PathBuf,
334        /// Underlying operating-system error.
335        #[source]
336        source: io::Error,
337    },
338
339    /// JSON framing, metadata serialization, or raw record parsing failed.
340    #[error("failed to {operation} JSON at `{path}`")]
341    Json {
342        /// Stable action description such as `parse metadata`.
343        operation: &'static str,
344        /// Metadata or chunk path associated with the JSON operation.
345        path: PathBuf,
346        /// Underlying Serde JSON failure.
347        #[source]
348        source: serde_json::Error,
349    },
350
351    /// Exact byte accounting overflowed its `u64` persisted representation.
352    #[error("encoded byte count overflowed while processing stream `{stream}`")]
353    ByteCountOverflow {
354        /// Logical stream whose accounting could not be represented.
355        stream: String,
356    },
357
358    /// One indivisible record exceeds the stream's strict queue-byte budget.
359    ///
360    /// Returning immediately is essential: waiting for capacity can never
361    /// make a record larger than the complete budget admissible.
362    #[error(
363        "encoded record for stream `{stream}` has {bytes} bytes, exceeding the queue limit of {limit}"
364    )]
365    RecordTooLarge {
366        /// Logical stream that rejected the encoded record.
367        stream: String,
368        /// Exact framed size of the rejected record.
369        bytes: u64,
370        /// Configured strict queue-byte limit.
371        limit: u64,
372    },
373
374    /// A stream submission did not advance its iteration.
375    #[error(
376        "record iteration {iteration} for stream `{stream}` does not follow previously accepted iteration {previous}"
377    )]
378    OutOfOrderIteration {
379        /// Logical stream receiving the record.
380        stream: String,
381        /// Rejected iteration.
382        iteration: u64,
383        /// Most recently accepted iteration.
384        previous: u64,
385    },
386
387    // ---------------------------------------------------------------------
388    // Queue and writer-worker lifecycle
389    // ---------------------------------------------------------------------
390    /// The bounded recording queue disconnected before shutdown completed.
391    #[error("system-state writer queue disconnected before shutdown completed")]
392    WriterQueueDisconnected,
393
394    /// The recording worker terminated with an authoritative storage failure.
395    ///
396    /// The shared source lets multiple blocked submitters observe the same
397    /// terminal failure without cloning an IO or JSON error.
398    #[error("system-state writer terminated: {source}")]
399    StateWriterTerminated {
400        /// Shared authoritative worker failure.
401        #[source]
402        source: Arc<StorageError>,
403    },
404
405    /// Joining a writer thread revealed an unexpected panic.
406    #[error("system-state writer worker panicked")]
407    StateWriterPanicked,
408}