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}