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}