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}