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}