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}