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    /// The ledger records a real domain version, but that version is not one
33    /// of the exact released predecessors this binary is willing to upgrade.
34    /// This covers both versions below the compatibility floor and gaps
35    /// between an allowed predecessor and the current schema. Refusal happens
36    /// before schema or ledger mutation.
37    #[error(
38        "schema for domain `{domain}` is not a supported predecessor: file has version {found}, \
39         this binary supports version {supported} and accepts existing versions {allowed:?}"
40    )]
41    UnsupportedSchemaPredecessor {
42        domain: String,
43        found: i64,
44        supported: i64,
45        allowed: Vec<i64>,
46    },
47
48    /// A ledger row names an otherwise allowed current or released version,
49    /// but the domain-owned catalog does not exactly match that version's
50    /// frozen schema fingerprint. The row alone is not authority to
51    /// reinterpret a candidate or partially migrated schema.
52    #[error(
53        "schema fingerprint for domain `{domain}` version {version} does not match the \
54         allowed schema: {detail}"
55    )]
56    SchemaFingerprintMismatch {
57        domain: String,
58        version: i64,
59        detail: String,
60    },
61
62    /// A file has no ledger row for a domain but already contains one or more
63    /// objects owned by that domain. At the 0.8.10 compatibility floor this
64    /// is neither a fresh domain nor an authenticated released predecessor:
65    /// silently running idempotent DDL over it would bless an unknown or
66    /// unreleased candidate schema.
67    #[error(
68        "schema domain `{domain}` has no ledger row but already owns objects {objects:?}; \
69         refusing to infer or stamp an unversioned schema"
70    )]
71    UnledgeredDomainObjects {
72        domain: String,
73        objects: Vec<String>,
74    },
75
76    /// A registered migration failed while being applied. The surrounding
77    /// transaction is rolled back; the file is left at its prior version.
78    #[error("migration {version} (`{name}`) for domain `{domain}` failed: {source}")]
79    MigrationFailed {
80        domain: String,
81        version: i64,
82        name: String,
83        #[source]
84        source: rusqlite::Error,
85    },
86
87    /// A migration body ended the runner's IMMEDIATE transaction (COMMIT or
88    /// ROLLBACK, with or without re-BEGINning a fresh one), separating its
89    /// schema work from the ledger stamp. Custody is verified after every
90    /// body via a runner-owned savepoint; the domain is left unstamped.
91    #[error(
92        "migration {version} (`{name}`) for domain `{domain}` ended the runner's transaction; \
93         migration bodies must not COMMIT or ROLLBACK"
94    )]
95    MigrationBrokeTransaction {
96        domain: String,
97        version: i64,
98        name: String,
99    },
100
101    /// The `meerkat_schema` ledger table exists but is not the pinned shape
102    /// (`domain TEXT PRIMARY KEY, version INTEGER NOT NULL`), carries more
103    /// than one row for a domain, or records a non-positive version. This is
104    /// corrupt or foreign ledger state: it is refused, never healed by
105    /// re-running migrations over it.
106    #[error("meerkat_schema ledger is malformed: {detail}")]
107    LedgerMalformed { detail: String },
108
109    /// The Primary profile asked SQLite to establish `journal_mode=WAL` and
110    /// SQLite reported a different effective mode without raising an error
111    /// (the journal-mode pragma can silently keep the old mode). The
112    /// connection does not satisfy the profile's durability policy.
113    #[error("could not establish journal_mode=WAL on `{path}`: effective mode is `{actual}`")]
114    WalNotEstablished { path: PathBuf, actual: String },
115
116    /// A domain registered an invalid migration list (non-contiguous or
117    /// not starting at version 1). This is a programming error in the store
118    /// crate, caught before any file is touched.
119    #[error("domain `{domain}` registered an invalid migration list: {detail}")]
120    InvalidMigrationList { domain: String, detail: String },
121
122    /// The connection profile refused the requested open (for example a
123    /// non-creating profile pointed at a missing file).
124    #[error("cannot open `{path}` with profile {profile}: {detail}")]
125    OpenRefused {
126        path: PathBuf,
127        profile: &'static str,
128        detail: String,
129    },
130
131    /// The exclusive maintenance fence is held for this database: storage is
132    /// under offline maintenance and the operation must not proceed. (Also
133    /// returned by [`crate::fence::ExclusiveFence::acquire`] when in-flight
134    /// operations did not drain within the deadline.)
135    #[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
136    MaintenanceFenceHeld { path: PathBuf },
137}
138
139/// Storage-level classification of a SQLite error.
140///
141/// This is deliberately narrower than the store-boundary taxonomy
142/// (transient / stale / corrupt): staleness (CAS conflicts, revision guards)
143/// is a store-contract concept invisible at this layer, so store crates map
144/// their own guard failures to their stale variants and use this
145/// classification for everything that reaches raw SQLite.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SqliteErrorClass {
148    /// Lock contention or interruption; safe to retry only for idempotent or
149    /// CAS-keyed operations (see the crate-level retryability note).
150    Transient,
151    /// The file is not (or no longer) a usable database.
152    Corrupt,
153    /// Everything else: constraint violations, misuse, API errors. The store
154    /// layer decides what these mean.
155    Other,
156}
157
158/// Classify a rusqlite error at the storage level.
159///
160/// Adoption contract: store crates route every raw [`rusqlite::Error`]
161/// through this one classifier when deciding transient-vs-corrupt at their
162/// store boundary, instead of re-matching SQLite error codes locally.
163/// [`SqliteErrorClass::Other`] is the store layer's to interpret (constraint
164/// violations become CAS/stale semantics there, not here). Classification
165/// alone never authorizes a retry — see the crate-level retryability note.
166pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
167    use rusqlite::ErrorCode;
168    match error {
169        rusqlite::Error::SqliteFailure(f, _) => match f.code {
170            ErrorCode::DatabaseBusy
171            | ErrorCode::DatabaseLocked
172            | ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
173            ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
174            _ => SqliteErrorClass::Other,
175        },
176        _ => SqliteErrorClass::Other,
177    }
178}
179
180/// True when the error is SQLITE_BUSY or SQLITE_LOCKED — the nonblocking
181/// admission probes (write fences) map exactly these to a typed backoff.
182pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
183    use rusqlite::ErrorCode;
184    matches!(
185        error,
186        rusqlite::Error::SqliteFailure(f, _)
187            if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
188    )
189}
190
191#[cfg(test)]
192#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
193mod tests {
194    use super::*;
195
196    fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
197        rusqlite::Error::SqliteFailure(
198            rusqlite::ffi::Error {
199                code,
200                extended_code: 0,
201            },
202            None,
203        )
204    }
205
206    #[test]
207    fn busy_and_locked_classify_transient() {
208        for code in [
209            rusqlite::ErrorCode::DatabaseBusy,
210            rusqlite::ErrorCode::DatabaseLocked,
211        ] {
212            let err = sqlite_failure(code);
213            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
214            assert!(is_busy_or_locked(&err));
215        }
216    }
217
218    #[test]
219    fn corruption_classifies_corrupt() {
220        for code in [
221            rusqlite::ErrorCode::DatabaseCorrupt,
222            rusqlite::ErrorCode::NotADatabase,
223        ] {
224            let err = sqlite_failure(code);
225            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
226            assert!(!is_busy_or_locked(&err));
227        }
228    }
229
230    #[test]
231    fn constraint_violation_classifies_other() {
232        let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
233        assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
234        assert!(!is_busy_or_locked(&err));
235    }
236}