Skip to main content

chronix_core/
error.rs

1//! Error types for the Chronix time-series database.
2//!
3//! Errors are organized hierarchically: each crate defines its own error type,
4//! and [`ChronixError`] composes them all via `#[from]` conversions.
5
6use std::path::PathBuf;
7
8use thiserror::Error;
9
10/// Top-level error type for the Chronix database.
11///
12/// All crate-level errors are composable into this type via `#[from]`.
13#[derive(Debug, Error)]
14pub enum ChronixError {
15    /// WAL (Write-Ahead Log) errors.
16    #[error("WAL error: {0}")]
17    Wal(#[from] WalError),
18
19    /// Schema validation and evolution errors.
20    #[error("Schema error: {0}")]
21    Schema(#[from] SchemaError),
22
23    /// Configuration errors.
24    #[error("Configuration error: {0}")]
25    Config(#[from] ConfigError),
26
27    /// Capacity exceeded (backpressure).
28    #[error("Capacity exceeded: {0}")]
29    Capacity(String),
30
31    /// I/O errors.
32    #[error("I/O error: {0}")]
33    Io(#[from] std::io::Error),
34}
35
36/// Write-Ahead Log errors.
37#[derive(Debug, Error)]
38pub enum WalError {
39    /// Underlying I/O error.
40    #[error("WAL I/O error: {0}")]
41    Io(#[from] std::io::Error),
42
43    /// Data corruption detected (CRC mismatch).
44    #[error("WAL corruption at offset {offset} in {path}: {detail}")]
45    Corruption {
46        /// Byte offset in the WAL file where corruption was detected.
47        offset: u64,
48        /// Path to the corrupted WAL file.
49        path: PathBuf,
50        /// Human-readable detail about the corruption.
51        detail: String,
52    },
53
54    /// Sequence number discontinuity detected during replay.
55    #[error("WAL sequence skip: expected {expected}, got {got}")]
56    SequenceSkip {
57        /// The expected next sequence number.
58        expected: u64,
59        /// The actual sequence number encountered.
60        got: u64,
61    },
62
63    /// WAL is full — too many unflushed WAL files.
64    #[error("WAL full: {count} unflushed files exceed limit of {limit}")]
65    Full {
66        /// Current number of unflushed WAL files.
67        count: usize,
68        /// Maximum allowed unflushed WAL files.
69        limit: usize,
70    },
71
72    /// The log belongs to a different on-disk format generation.
73    ///
74    /// Distinct from [`Corruption`](Self::Corruption) and from
75    /// [`InvalidHeader`](Self::InvalidHeader): the header is well formed,
76    /// the bytes are intact, and the reader is from another generation — so
77    /// the remedy is to match the versions up rather than to restore. All
78    /// four durable formats classify this the same way; see
79    /// `chronix_engine::format`.
80    #[error("{detail}")]
81    UnsupportedVersion {
82        /// Path to the WAL file.
83        path: PathBuf,
84        /// What was read, what this build expects, and what to do.
85        detail: String,
86    },
87
88    /// Invalid WAL file header (wrong magic bytes).
89    #[error("Invalid WAL header in {path}: {detail}")]
90    InvalidHeader {
91        /// Path to the WAL file with the invalid header.
92        path: PathBuf,
93        /// Description of what's wrong with the header.
94        detail: String,
95    },
96
97    /// Payload exceeds the maximum allowed size per WAL record.
98    #[error("WAL payload too large: {size} bytes exceeds limit of {limit}")]
99    PayloadTooLarge {
100        /// Actual payload size in bytes.
101        size: usize,
102        /// Maximum allowed payload size in bytes.
103        limit: usize,
104    },
105
106    /// Internal lock was poisoned (indicates a prior panic while holding the lock).
107    #[error("WAL lock poisoned: {0}")]
108    LockPoisoned(String),
109
110    /// The WAL writer's file is in an unknown state after a failed `fsync`
111    /// or a failed rewind, and every write is refused until the database is
112    /// reopened.
113    ///
114    /// The message names **reopening**, not `clear_poison()`. It named the
115    /// latter for a long time, and no user of the `chronix` facade can call
116    /// it: it is a `chronix-engine` method the facade does not re-export. An
117    /// error that tells the operator to do something they cannot do is worse
118    /// than one that says nothing.
119    #[error(
120        "WAL writer is poisoned after a failed fsync — the file's contents \
121         are unknown; close and reopen the database to recover"
122    )]
123    Poisoned,
124}
125
126/// Schema validation and evolution errors.
127#[derive(Debug, Error, Clone, PartialEq, Eq)]
128pub enum SchemaError {
129    /// Name is empty.
130    #[error("{label} must not be empty")]
131    EmptyName {
132        /// Which entity had the empty name (e.g., "measurement name", "tag key").
133        label: String,
134    },
135
136    /// Name exceeds maximum length.
137    #[error("{label} too long: {length} bytes exceeds maximum of {max}")]
138    NameTooLong {
139        /// Which entity exceeded the limit.
140        label: String,
141        /// Actual byte length.
142        length: usize,
143        /// Maximum allowed length.
144        max: usize,
145    },
146
147    /// Name contains null byte.
148    #[error("{label} contains null byte (\\0)")]
149    NullByte {
150        /// Which entity contained the null byte.
151        label: String,
152    },
153
154    /// Name/value contains a character that would break
155    /// the canonical form encoding (e.g. `=` in tag values).
156    #[error("{label} contains invalid character '{ch}' in \"{value}\"")]
157    InvalidCharacter {
158        /// Which entity contained the invalid character.
159        label: String,
160        /// The forbidden character.
161        ch: char,
162        /// The offending value for diagnostic context.
163        value: String,
164    },
165
166    /// Too many tags on a series key.
167    #[error("Too many tags: {count} exceeds maximum of {max}")]
168    TooManyTags {
169        /// Actual tag count.
170        count: usize,
171        /// Maximum allowed.
172        max: usize,
173    },
174
175    /// Point has no fields.
176    #[error("Point must have at least one field")]
177    EmptyFields,
178
179    /// Too many fields on a single point.
180    #[error("Too many fields: {count} exceeds maximum of {max}")]
181    TooManyFields {
182        /// Actual field count.
183        count: usize,
184        /// Maximum allowed.
185        max: usize,
186    },
187
188    /// A field value failed validation (e.g., non-finite f64, oversized string).
189    #[error("Invalid value for field '{field}': {reason}")]
190    InvalidFieldValue {
191        /// Field name with the invalid value.
192        field: String,
193        /// Why the value is invalid.
194        reason: String,
195    },
196
197    /// Type conflict: a field already exists with a different type.
198    #[error(
199        "Type conflict for field '{field}' in measurement '{measurement}': expected {expected}, got {got}"
200    )]
201    TypeConflict {
202        /// Measurement name where the conflict occurred.
203        measurement: String,
204        /// Field name with the type conflict.
205        field: String,
206        /// The existing (expected) type.
207        expected: String,
208        /// The conflicting (received) type.
209        got: String,
210    },
211
212    /// A decimal value carries more fractional digits than its column
213    /// declares, so storing it would have to round.
214    ///
215    /// Separate from [`TypeConflict`](Self::TypeConflict) because the fix is
216    /// different: the types agree, the *scale* does not, and the writer
217    /// either rounds the value itself or declares the column with the scale
218    /// it needs before the first write.
219    #[error(
220        "Decimal scale conflict for field '{field}' in measurement '{measurement}': \
221         the column stores {declared} fractional digit(s) and the value {value} needs {got}. \
222         Declare the field with the scale it needs before the first write, or round the value."
223    )]
224    DecimalScaleConflict {
225        /// Measurement name where the conflict occurred.
226        measurement: String,
227        /// Field name with the scale conflict.
228        field: String,
229        /// The column's declared scale.
230        declared: u8,
231        /// The value's scale.
232        got: u8,
233        /// The offending value, rendered exactly.
234        value: String,
235    },
236
237    /// Measurement not found.
238    #[error("Measurement '{0}' not found")]
239    MeasurementNotFound(String),
240
241    /// Internal lock was poisoned (indicates a prior panic while holding the lock).
242    #[error("Schema lock poisoned: {0}")]
243    LockPoisoned(String),
244
245    /// An entity name failed validation.
246    #[error("Invalid name '{name}': {reason}")]
247    InvalidName {
248        /// The invalid name.
249        name: String,
250        /// Why the name is invalid.
251        reason: String,
252    },
253}
254
255/// Configuration errors.
256#[derive(Debug, Error)]
257pub enum ConfigError {
258    /// A required field was not set.
259    #[error("missing required field: {field}")]
260    MissingField {
261        /// Name of the missing field.
262        field: &'static str,
263    },
264
265    /// A configuration value failed validation.
266    #[error("invalid configuration: {message}")]
267    Validation {
268        /// Human-readable validation failure description.
269        message: String,
270    },
271
272    /// Failed to read the configuration file.
273    #[error("failed to read config file: {0}")]
274    Io(#[from] std::io::Error),
275
276    /// Failed to parse TOML content.
277    #[error("failed to parse TOML: {0}")]
278    Parse(#[from] toml::de::Error),
279}
280
281/// A specialized `Result` type for Chronix operations.
282pub type Result<T> = std::result::Result<T, ChronixError>;
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn chronix_error_from_wal() {
290        let wal_err = WalError::Full { count: 5, limit: 4 };
291        let err: ChronixError = wal_err.into();
292        let msg = format!("{err}");
293        assert!(msg.contains("WAL"));
294        assert!(msg.contains('5'));
295    }
296
297    #[test]
298    fn chronix_error_from_schema() {
299        let schema_err = SchemaError::EmptyFields;
300        let err: ChronixError = schema_err.into();
301        assert!(format!("{err}").contains("field"));
302    }
303
304    #[test]
305    fn chronix_error_from_io() {
306        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
307        let err: ChronixError = io_err.into();
308        assert!(format!("{err}").contains("gone"));
309    }
310
311    #[test]
312    fn schema_error_type_conflict_message() {
313        let err = SchemaError::TypeConflict {
314            measurement: "cpu".to_string(),
315            field: "value".to_string(),
316            expected: "f64".to_string(),
317            got: "i64".to_string(),
318        };
319        let msg = format!("{err}");
320        assert!(msg.contains("value"));
321        assert!(msg.contains("f64"));
322        assert!(msg.contains("i64"));
323    }
324
325    #[test]
326    fn wal_error_corruption_message() {
327        let err = WalError::Corruption {
328            offset: 1024,
329            path: PathBuf::from("/data/wal_1.cxwl"),
330            detail: "CRC mismatch".to_string(),
331        };
332        let msg = err.to_string();
333        assert!(msg.contains("1024"));
334        assert!(msg.contains("CRC"));
335    }
336
337    #[test]
338    fn wal_error_sequence_skip_message() {
339        let err = WalError::SequenceSkip {
340            expected: 5,
341            got: 8,
342        };
343        let msg = err.to_string();
344        assert!(msg.contains('5'));
345        assert!(msg.contains('8'));
346    }
347
348    #[test]
349    fn wal_error_invalid_header_message() {
350        let err = WalError::InvalidHeader {
351            path: PathBuf::from("/data/wal.cxwl"),
352            detail: "bad magic".into(),
353        };
354        let msg = err.to_string();
355        assert!(msg.contains("bad magic"));
356    }
357
358    #[test]
359    fn wal_error_lock_poisoned_message() {
360        let err = WalError::LockPoisoned("inner mutex".into());
361        assert!(err.to_string().contains("inner mutex"));
362    }
363
364    #[test]
365    fn wal_error_io_message() {
366        let err = WalError::Io(std::io::Error::new(
367            std::io::ErrorKind::PermissionDenied,
368            "read-only",
369        ));
370        assert!(err.to_string().contains("read-only"));
371    }
372
373    #[test]
374    fn wal_error_full_message() {
375        let err = WalError::Full {
376            count: 10,
377            limit: 4,
378        };
379        let msg = err.to_string();
380        assert!(msg.contains("10"));
381        assert!(msg.contains('4'));
382    }
383
384    #[test]
385    fn wal_error_payload_too_large_message() {
386        let err = WalError::PayloadTooLarge {
387            size: 512_000_000,
388            limit: 268_435_456,
389        };
390        let msg = err.to_string();
391        assert!(msg.contains("512000000"));
392        assert!(msg.contains("268435456"));
393    }
394
395    #[test]
396    fn schema_error_lock_poisoned_message() {
397        let err = SchemaError::LockPoisoned("registry lock".into());
398        assert!(err.to_string().contains("registry lock"));
399    }
400
401    #[test]
402    fn schema_error_measurement_not_found_message() {
403        let err = SchemaError::MeasurementNotFound("disk".into());
404        assert!(err.to_string().contains("disk"));
405    }
406
407    #[test]
408    fn config_error_missing_field_message() {
409        let err = ConfigError::MissingField { field: "data_dir" };
410        assert!(err.to_string().contains("data_dir"));
411    }
412
413    #[test]
414    fn config_error_validation_message() {
415        let err = ConfigError::Validation {
416            message: "too small".into(),
417        };
418        assert!(err.to_string().contains("too small"));
419    }
420
421    #[test]
422    fn chronix_error_from_config() {
423        let config_err = ConfigError::MissingField { field: "data_dir" };
424        let err: ChronixError = config_err.into();
425        assert!(err.to_string().contains("data_dir"));
426    }
427}