Skip to main content

meerkat_sqlite/
error.rs

1//! Crate error type and storage-level error classification.
2
3use std::path::PathBuf;
4
5/// Errors produced by the shared SQLite mechanics.
6#[derive(Debug, thiserror::Error)]
7pub enum SqliteStoreError {
8    /// Filesystem-level failure (creating parent directories, fence lock
9    /// files, ...).
10    #[error("sqlite store io error: {0}")]
11    Io(#[from] std::io::Error),
12
13    /// Underlying SQLite failure.
14    #[error("sqlite error: {0}")]
15    Sqlite(#[from] rusqlite::Error),
16
17    /// The file's schema ledger records a version newer than this binary
18    /// supports. This is a refusal, not a corruption: a newer binary has
19    /// migrated the file and this binary must not touch it. Surfaces report
20    /// it as a typed, health-visible certification failure (a rollback
21    /// candidate fails cleanly) rather than crash-looping.
22    #[error(
23        "schema for domain `{domain}` is from the future: file has version {found}, \
24         this binary supports up to {supported}"
25    )]
26    SchemaFromTheFuture {
27        domain: String,
28        found: i64,
29        supported: i64,
30    },
31
32    /// A registered migration failed while being applied. The surrounding
33    /// transaction is rolled back; the file is left at its prior version.
34    #[error("migration {version} (`{name}`) for domain `{domain}` failed: {source}")]
35    MigrationFailed {
36        domain: String,
37        version: i64,
38        name: String,
39        #[source]
40        source: rusqlite::Error,
41    },
42
43    /// A migration body ended the runner's IMMEDIATE transaction (COMMIT or
44    /// ROLLBACK, with or without re-BEGINning a fresh one), separating its
45    /// schema work from the ledger stamp. Custody is verified after every
46    /// body via a runner-owned savepoint; the domain is left unstamped.
47    #[error(
48        "migration {version} (`{name}`) for domain `{domain}` ended the runner's transaction; \
49         migration bodies must not COMMIT or ROLLBACK"
50    )]
51    MigrationBrokeTransaction {
52        domain: String,
53        version: i64,
54        name: String,
55    },
56
57    /// The `meerkat_schema` ledger table exists but is not the pinned shape
58    /// (`domain TEXT PRIMARY KEY, version INTEGER NOT NULL`), carries more
59    /// than one row for a domain, or records a non-positive version. This is
60    /// corrupt or foreign ledger state: it is refused, never healed by
61    /// re-running migrations over it.
62    #[error("meerkat_schema ledger is malformed: {detail}")]
63    LedgerMalformed { detail: String },
64
65    /// The Primary profile asked SQLite to establish `journal_mode=WAL` and
66    /// SQLite reported a different effective mode without raising an error
67    /// (the journal-mode pragma can silently keep the old mode). The
68    /// connection does not satisfy the profile's durability policy.
69    #[error("could not establish journal_mode=WAL on `{path}`: effective mode is `{actual}`")]
70    WalNotEstablished { path: PathBuf, actual: String },
71
72    /// A domain registered an invalid migration list (non-contiguous or
73    /// not starting at version 1). This is a programming error in the store
74    /// crate, caught before any file is touched.
75    #[error("domain `{domain}` registered an invalid migration list: {detail}")]
76    InvalidMigrationList { domain: String, detail: String },
77
78    /// The connection profile refused the requested open (for example a
79    /// non-creating profile pointed at a missing file).
80    #[error("cannot open `{path}` with profile {profile}: {detail}")]
81    OpenRefused {
82        path: PathBuf,
83        profile: &'static str,
84        detail: String,
85    },
86
87    /// The exclusive maintenance fence is held for this database: storage is
88    /// under offline maintenance and the operation must not proceed. (Also
89    /// returned by [`crate::fence::ExclusiveFence::acquire`] when in-flight
90    /// operations did not drain within the deadline.)
91    #[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
92    MaintenanceFenceHeld { path: PathBuf },
93}
94
95/// Storage-level classification of a SQLite error.
96///
97/// This is deliberately narrower than the store-boundary taxonomy
98/// (transient / stale / corrupt): staleness (CAS conflicts, revision guards)
99/// is a store-contract concept invisible at this layer, so store crates map
100/// their own guard failures to their stale variants and use this
101/// classification for everything that reaches raw SQLite.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum SqliteErrorClass {
104    /// Lock contention or interruption; safe to retry only for idempotent or
105    /// CAS-keyed operations (see the crate-level retryability note).
106    Transient,
107    /// The file is not (or no longer) a usable database.
108    Corrupt,
109    /// Everything else: constraint violations, misuse, API errors. The store
110    /// layer decides what these mean.
111    Other,
112}
113
114/// Classify a rusqlite error at the storage level.
115///
116/// Adoption contract: store crates route every raw [`rusqlite::Error`]
117/// through this one classifier when deciding transient-vs-corrupt at their
118/// store boundary, instead of re-matching SQLite error codes locally.
119/// [`SqliteErrorClass::Other`] is the store layer's to interpret (constraint
120/// violations become CAS/stale semantics there, not here). Classification
121/// alone never authorizes a retry — see the crate-level retryability note.
122pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
123    use rusqlite::ErrorCode;
124    match error {
125        rusqlite::Error::SqliteFailure(f, _) => match f.code {
126            ErrorCode::DatabaseBusy
127            | ErrorCode::DatabaseLocked
128            | ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
129            ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
130            _ => SqliteErrorClass::Other,
131        },
132        _ => SqliteErrorClass::Other,
133    }
134}
135
136/// True when the error is SQLITE_BUSY or SQLITE_LOCKED — the nonblocking
137/// admission probes (write fences) map exactly these to a typed backoff.
138pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
139    use rusqlite::ErrorCode;
140    matches!(
141        error,
142        rusqlite::Error::SqliteFailure(f, _)
143            if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
144    )
145}
146
147#[cfg(test)]
148#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
149mod tests {
150    use super::*;
151
152    fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
153        rusqlite::Error::SqliteFailure(
154            rusqlite::ffi::Error {
155                code,
156                extended_code: 0,
157            },
158            None,
159        )
160    }
161
162    #[test]
163    fn busy_and_locked_classify_transient() {
164        for code in [
165            rusqlite::ErrorCode::DatabaseBusy,
166            rusqlite::ErrorCode::DatabaseLocked,
167        ] {
168            let err = sqlite_failure(code);
169            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
170            assert!(is_busy_or_locked(&err));
171        }
172    }
173
174    #[test]
175    fn corruption_classifies_corrupt() {
176        for code in [
177            rusqlite::ErrorCode::DatabaseCorrupt,
178            rusqlite::ErrorCode::NotADatabase,
179        ] {
180            let err = sqlite_failure(code);
181            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
182            assert!(!is_busy_or_locked(&err));
183        }
184    }
185
186    #[test]
187    fn constraint_violation_classifies_other() {
188        let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
189        assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
190        assert!(!is_busy_or_locked(&err));
191    }
192}