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 temporary publication.
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}