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    /// A completed stream contains no record to reconstruct.
144    #[error("stream `{stream}` contains no recorded state")]
145    NoRecordedState {
146        /// Logical completed stream searched for its latest state.
147        stream: String,
148    },
149
150    /// Chunk filenames do not describe one recoverable committed prefix and
151    /// at most one highest open chunk.
152    #[error("cannot recover stream output at `{path}`: {reason}")]
153    RecoveryConflict {
154        /// Stream directory or conflicting payload path.
155        path: PathBuf,
156        /// Concise filename/inventory conflict.
157        reason: String,
158    },
159
160    /// The host UTC clock could not be represented in the canonical metadata
161    /// timestamp format.
162    #[error("failed to format the operational timestamp while attempting to {operation}")]
163    OperationalTimestamp {
164        /// Lifecycle action requesting the timestamp.
165        operation: &'static str,
166        /// Timestamp-formatting failure.
167        #[source]
168        source: time::error::Format,
169    },
170
171    /// A monotonic writer session exceeded the exact persisted duration range.
172    #[error("active recording duration exceeds the supported u64 nanosecond range")]
173    OperationalDurationOverflow,
174
175    // ---------------------------------------------------------------------
176    // Persisted format and integrity
177    // ---------------------------------------------------------------------
178    /// `metadata.json` declares a format version this crate cannot read.
179    #[error(
180        "metadata file `{path}` uses format version {found}, but this crate supports version {supported}"
181    )]
182    UnsupportedVersion {
183        /// Metadata file containing the unsupported declaration.
184        path: PathBuf,
185        /// Version found in the file.
186        found: u32,
187        /// Version implemented by this crate.
188        supported: u32,
189    },
190
191    /// Syntactically valid metadata violates a semantic storage invariant.
192    #[error("invalid recording metadata in `{path}`: {reason}")]
193    InvalidMetadata {
194        /// Authoritative metadata file that failed validation.
195        path: PathBuf,
196        /// Concise invariant violation.
197        reason: String,
198    },
199
200    /// A reader requiring a completed recording encountered terminally
201    /// unsuitable metadata.
202    #[error("recording metadata `{path}` does not declare successful completion")]
203    RecordingNotComplete {
204        /// Metadata file whose lifecycle state is incomplete or failed.
205        path: PathBuf,
206    },
207
208    /// A committed chunk named by metadata is absent from the filesystem.
209    #[error("committed chunk `{path}` is missing")]
210    MissingChunk {
211        /// Expected chunk path.
212        path: PathBuf,
213    },
214
215    /// A committed chunk's actual length differs from its metadata descriptor.
216    #[error("chunk `{path}` has {actual} bytes, but metadata declares {expected}")]
217    ChunkSizeMismatch {
218        /// Chunk path whose filesystem length was checked.
219        path: PathBuf,
220        /// Authoritative encoded byte length from metadata.
221        expected: u64,
222        /// Encoded byte length reported by the filesystem.
223        actual: u64,
224    },
225
226    /// A committed chunk's checksum differs from its metadata descriptor.
227    #[error("chunk `{path}` checksum is `{actual}`, but metadata declares `{expected}`")]
228    ChecksumMismatch {
229        /// Chunk path whose contents were checked.
230        path: PathBuf,
231        /// Authoritative checksum encoded in metadata.
232        expected: String,
233        /// Checksum computed from the chunk contents.
234        actual: String,
235    },
236
237    /// One syntactically readable JSONL record violates record invariants.
238    #[error("invalid record at line {line} of `{path}`: {reason}")]
239    InvalidRecord {
240        /// Chunk file containing the invalid record.
241        path: PathBuf,
242        /// One-based JSONL line number.
243        line: u64,
244        /// Concise framing or semantic invariant violation.
245        reason: String,
246    },
247
248    // ---------------------------------------------------------------------
249    // State borrowing, encoding, and decoding
250    // ---------------------------------------------------------------------
251    /// The encoder could not borrow one declared field from the live state.
252    #[error(
253        "cannot sample field `{field}` for stream `{stream}` at iteration {iteration}: {source}"
254    )]
255    StateAccess {
256        /// Logical output stream being sampled.
257        stream: String,
258        /// Iteration of the sampled state.
259        iteration: u64,
260        /// Declared stream field that could not be borrowed.
261        field: String,
262        /// Original SystemState access failure.
263        #[source]
264        source: StateError,
265    },
266
267    /// Serde failed while encoding one borrowed payload.
268    #[error("failed to encode field `{field}` for stream `{stream}` at iteration {iteration}")]
269    EncodeField {
270        /// Logical output stream being encoded.
271        stream: String,
272        /// Iteration of the sampled state.
273        iteration: u64,
274        /// Field whose payload serializer failed.
275        field: String,
276        /// Underlying JSON serializer failure.
277        #[source]
278        source: serde_json::Error,
279    },
280
281    /// A decoder registration attempted to reuse one field key.
282    #[error("a payload decoder is already registered for field `{field}`")]
283    DuplicateDecoder {
284        /// Repeated state field key.
285        field: String,
286    },
287
288    /// No concrete payload decoder was declared for a persisted field key.
289    #[error("no payload decoder is registered for field `{field}`")]
290    MissingDecoder {
291        /// Persisted state field key requiring reconstruction.
292        field: String,
293    },
294
295    /// A user-supplied field decoder failed to reconstruct its concrete value.
296    #[error("failed to decode field `{field}` for stream `{stream}` at iteration {iteration}")]
297    DecodeField {
298        /// Logical stream being reconstructed.
299        stream: String,
300        /// Iteration of the raw record.
301        iteration: u64,
302        /// Field whose registered decoder failed.
303        field: String,
304        /// Decoder-specific failure retained behind an object-safe boundary.
305        #[source]
306        source: Box<dyn Error + Send + Sync + 'static>,
307    },
308
309    /// A reconstructed state violated its destination series invariant.
310    ///
311    /// The rejected state is intentionally not retained in this error: it was
312    /// created from persisted input and is dropped on failed reconstruction,
313    /// preventing an error value from pinning arbitrarily large payloads.
314    #[error("decoded state for stream `{stream}` at iteration {iteration} cannot enter its series")]
315    StateSeriesInvariant {
316        /// Logical stream being reconstructed.
317        stream: String,
318        /// Iteration of the rejected decoded state.
319        iteration: u64,
320        /// Original in-memory collection invariant failure.
321        #[source]
322        source: StateSeriesError,
323    },
324
325    // ---------------------------------------------------------------------
326    // Filesystem and JSON mechanics
327    // ---------------------------------------------------------------------
328    /// A filesystem operation failed.
329    #[error("failed to {operation} at `{path}`")]
330    Io {
331        /// Stable action description such as `create chunk` or `sync metadata`.
332        operation: &'static str,
333        /// Filesystem path involved in the failed operation.
334        path: PathBuf,
335        /// Underlying operating-system error.
336        #[source]
337        source: io::Error,
338    },
339
340    /// JSON framing, metadata serialization, or raw record parsing failed.
341    #[error("failed to {operation} JSON at `{path}`")]
342    Json {
343        /// Stable action description such as `parse metadata`.
344        operation: &'static str,
345        /// Metadata or chunk path associated with the JSON operation.
346        path: PathBuf,
347        /// Underlying Serde JSON failure.
348        #[source]
349        source: serde_json::Error,
350    },
351
352    /// Exact byte accounting overflowed its `u64` persisted representation.
353    #[error("encoded byte count overflowed while processing stream `{stream}`")]
354    ByteCountOverflow {
355        /// Logical stream whose accounting could not be represented.
356        stream: String,
357    },
358
359    /// One indivisible record exceeds the stream's strict queue-byte budget.
360    ///
361    /// Returning immediately is essential: waiting for capacity can never
362    /// make a record larger than the complete budget admissible.
363    #[error(
364        "encoded record for stream `{stream}` has {bytes} bytes, exceeding the queue limit of {limit}"
365    )]
366    RecordTooLarge {
367        /// Logical stream that rejected the encoded record.
368        stream: String,
369        /// Exact framed size of the rejected record.
370        bytes: u64,
371        /// Configured strict queue-byte limit.
372        limit: u64,
373    },
374
375    /// A stream submission did not advance its iteration.
376    #[error(
377        "record iteration {iteration} for stream `{stream}` does not follow previously accepted iteration {previous}"
378    )]
379    OutOfOrderIteration {
380        /// Logical stream receiving the record.
381        stream: String,
382        /// Rejected iteration.
383        iteration: u64,
384        /// Most recently accepted iteration.
385        previous: u64,
386    },
387
388    // ---------------------------------------------------------------------
389    // Queue and writer-worker lifecycle
390    // ---------------------------------------------------------------------
391    /// The bounded recording queue disconnected before shutdown completed.
392    #[error("system-state writer queue disconnected before shutdown completed")]
393    WriterQueueDisconnected,
394
395    /// The recording worker terminated with an authoritative storage failure.
396    ///
397    /// The shared source lets multiple blocked submitters observe the same
398    /// terminal failure without cloning an IO or JSON error.
399    #[error("system-state writer terminated: {source}")]
400    StateWriterTerminated {
401        /// Shared authoritative worker failure.
402        #[source]
403        source: Arc<StorageError>,
404    },
405
406    /// Joining a writer thread revealed an unexpected panic.
407    #[error("system-state writer worker panicked")]
408    StateWriterPanicked,
409}