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