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    /// Measurement not found.
195    #[error("Measurement '{0}' not found")]
196    MeasurementNotFound(String),
197
198    /// Internal lock was poisoned (indicates a prior panic while holding the lock).
199    #[error("Schema lock poisoned: {0}")]
200    LockPoisoned(String),
201
202    /// An entity name failed validation.
203    #[error("Invalid name '{name}': {reason}")]
204    InvalidName {
205        /// The invalid name.
206        name: String,
207        /// Why the name is invalid.
208        reason: String,
209    },
210}
211
212/// Configuration errors.
213#[derive(Debug, Error)]
214pub enum ConfigError {
215    /// A required field was not set.
216    #[error("missing required field: {field}")]
217    MissingField {
218        /// Name of the missing field.
219        field: &'static str,
220    },
221
222    /// A configuration value failed validation.
223    #[error("invalid configuration: {message}")]
224    Validation {
225        /// Human-readable validation failure description.
226        message: String,
227    },
228
229    /// Failed to read the configuration file.
230    #[error("failed to read config file: {0}")]
231    Io(#[from] std::io::Error),
232
233    /// Failed to parse TOML content.
234    #[error("failed to parse TOML: {0}")]
235    Parse(#[from] toml::de::Error),
236}
237
238/// A specialized `Result` type for Chronix operations.
239pub type Result<T> = std::result::Result<T, ChronixError>;
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn chronix_error_from_wal() {
247        let wal_err = WalError::Full { count: 5, limit: 4 };
248        let err: ChronixError = wal_err.into();
249        let msg = format!("{err}");
250        assert!(msg.contains("WAL"));
251        assert!(msg.contains('5'));
252    }
253
254    #[test]
255    fn chronix_error_from_schema() {
256        let schema_err = SchemaError::EmptyFields;
257        let err: ChronixError = schema_err.into();
258        assert!(format!("{err}").contains("field"));
259    }
260
261    #[test]
262    fn chronix_error_from_io() {
263        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
264        let err: ChronixError = io_err.into();
265        assert!(format!("{err}").contains("gone"));
266    }
267
268    #[test]
269    fn schema_error_type_conflict_message() {
270        let err = SchemaError::TypeConflict {
271            measurement: "cpu".to_string(),
272            field: "value".to_string(),
273            expected: "f64".to_string(),
274            got: "i64".to_string(),
275        };
276        let msg = format!("{err}");
277        assert!(msg.contains("value"));
278        assert!(msg.contains("f64"));
279        assert!(msg.contains("i64"));
280    }
281
282    #[test]
283    fn wal_error_corruption_message() {
284        let err = WalError::Corruption {
285            offset: 1024,
286            path: PathBuf::from("/data/wal_1.cxwl"),
287            detail: "CRC mismatch".to_string(),
288        };
289        let msg = err.to_string();
290        assert!(msg.contains("1024"));
291        assert!(msg.contains("CRC"));
292    }
293
294    #[test]
295    fn wal_error_sequence_skip_message() {
296        let err = WalError::SequenceSkip {
297            expected: 5,
298            got: 8,
299        };
300        let msg = err.to_string();
301        assert!(msg.contains('5'));
302        assert!(msg.contains('8'));
303    }
304
305    #[test]
306    fn wal_error_invalid_header_message() {
307        let err = WalError::InvalidHeader {
308            path: PathBuf::from("/data/wal.cxwl"),
309            detail: "bad magic".into(),
310        };
311        let msg = err.to_string();
312        assert!(msg.contains("bad magic"));
313    }
314
315    #[test]
316    fn wal_error_lock_poisoned_message() {
317        let err = WalError::LockPoisoned("inner mutex".into());
318        assert!(err.to_string().contains("inner mutex"));
319    }
320
321    #[test]
322    fn wal_error_io_message() {
323        let err = WalError::Io(std::io::Error::new(
324            std::io::ErrorKind::PermissionDenied,
325            "read-only",
326        ));
327        assert!(err.to_string().contains("read-only"));
328    }
329
330    #[test]
331    fn wal_error_full_message() {
332        let err = WalError::Full {
333            count: 10,
334            limit: 4,
335        };
336        let msg = err.to_string();
337        assert!(msg.contains("10"));
338        assert!(msg.contains('4'));
339    }
340
341    #[test]
342    fn wal_error_payload_too_large_message() {
343        let err = WalError::PayloadTooLarge {
344            size: 512_000_000,
345            limit: 268_435_456,
346        };
347        let msg = err.to_string();
348        assert!(msg.contains("512000000"));
349        assert!(msg.contains("268435456"));
350    }
351
352    #[test]
353    fn schema_error_lock_poisoned_message() {
354        let err = SchemaError::LockPoisoned("registry lock".into());
355        assert!(err.to_string().contains("registry lock"));
356    }
357
358    #[test]
359    fn schema_error_measurement_not_found_message() {
360        let err = SchemaError::MeasurementNotFound("disk".into());
361        assert!(err.to_string().contains("disk"));
362    }
363
364    #[test]
365    fn config_error_missing_field_message() {
366        let err = ConfigError::MissingField { field: "data_dir" };
367        assert!(err.to_string().contains("data_dir"));
368    }
369
370    #[test]
371    fn config_error_validation_message() {
372        let err = ConfigError::Validation {
373            message: "too small".into(),
374        };
375        assert!(err.to_string().contains("too small"));
376    }
377
378    #[test]
379    fn chronix_error_from_config() {
380        let config_err = ConfigError::MissingField { field: "data_dir" };
381        let err: ChronixError = config_err.into();
382        assert!(err.to_string().contains("data_dir"));
383    }
384}