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