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