meerkat_sqlite/error.rs
1//! Crate error type and storage-level error classification.
2
3use std::path::PathBuf;
4
5/// Whether the explicit offline pre-0.8.10 bridge can authenticate an
6/// unledgered on-disk catalog.
7///
8/// This exists so a refusal can say what is actually true about the file in
9/// front of it. Naming the bridge as a remedy for a catalog it cannot
10/// authenticate sends the operator into a dead end.
11/// SCOPE. Both variants are decided from the file's **catalog shape** alone.
12/// No durable record is read, decoded, or admitted to reach this answer, so
13/// `CatalogAuthenticated` must never be rendered as a promise that every
14/// record survives the bridge. What it does justify is running the bridge:
15/// preparation callbacks refuse per record, naming what stayed behind, rather
16/// than aborting the domain.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BridgeEligibility {
19 /// Some frozen released catalog registered by this binary authenticates
20 /// the on-disk shape exactly, so the explicit bridge will run on this
21 /// domain instead of refusing it outright. Individual records may still
22 /// be left behind; the bridge names each one it could not carry.
23 CatalogAuthenticated,
24 /// No frozen released catalog authenticates the on-disk shape. The file
25 /// predates, or diverges from, everything this binary can prove.
26 Unrecognized,
27}
28
29impl BridgeEligibility {
30 /// True when the explicit bridge can authenticate the catalog and will
31 /// therefore run. Says nothing about individual records; see the type
32 /// docs.
33 pub fn catalog_authenticates(self) -> bool {
34 matches!(self, Self::CatalogAuthenticated)
35 }
36
37 /// The operator-facing remedy sentence for an unledgered domain with this
38 /// eligibility, ready to append to a refusal.
39 ///
40 /// It lives here, beside the answer it is derived from, because the same
41 /// refusal reaches operators through more than one error type. A remedy
42 /// carried by only one of them is how a caller ends up staring at a bare
43 /// "refusing to infer or stamp an unversioned schema" with no next step,
44 /// and two hand-copied remedies are how one of them ends up stale.
45 pub fn remedy_sentence(self) -> &'static str {
46 match self {
47 Self::CatalogAuthenticated => {
48 " This realm was last written before the 0.8.10 durable-state floor and its \
49 on-disk schema is one this binary recognizes, so run the explicit \
50 current-binary bridge once (`rkat --state-root <ROOT> --realm <REALM> storage \
51 migrate --apply --bridge-pre-0-8-10`), then retry the original command. Only \
52 the schema shape has been checked here: the bridge inspects the stored \
53 records themselves and prints any it cannot carry forward, leaving those \
54 records' bytes untouched"
55 }
56 Self::Unrecognized => {
57 " No source catalog the `--bridge-pre-0-8-10` bridge can recover matches this \
58 domain's on-disk shape, so running that bridge will not recover this domain. \
59 The file predates, or diverges from, every source catalog this binary can \
60 bridge here. Nothing is deleted or rewritten: keep the realm and open it with \
61 the version that wrote it, or start a new realm for this binary. The \
62 read-only `rkat --state-root <ROOT> --realm <REALM> storage migrate` dry run \
63 prints the per-domain detail; report that object list if you need this shape \
64 bridged"
65 }
66 }
67 }
68}
69
70/// Errors produced by the shared SQLite mechanics.
71#[derive(Debug, thiserror::Error)]
72pub enum SqliteStoreError {
73 /// Filesystem-level failure (creating parent directories, fence lock
74 /// files, ...).
75 #[error("sqlite store io error: {0}")]
76 Io(#[from] std::io::Error),
77
78 /// Underlying SQLite failure.
79 #[error("sqlite error: {0}")]
80 Sqlite(#[from] rusqlite::Error),
81
82 /// The file's schema ledger records a version newer than this binary
83 /// supports. This is a refusal, not a corruption: a newer binary has
84 /// migrated the file and this binary must not touch it. Surfaces report
85 /// it as a typed, health-visible certification failure (a rollback
86 /// candidate fails cleanly) rather than crash-looping.
87 #[error(
88 "schema for domain `{domain}` is from the future: file has version {found}, \
89 this binary supports up to {supported}"
90 )]
91 SchemaFromTheFuture {
92 domain: String,
93 found: i64,
94 supported: i64,
95 },
96
97 /// The ledger records a real domain version, but that version is not one
98 /// of the exact released predecessors this binary is willing to upgrade.
99 /// This covers both versions below the compatibility floor and gaps
100 /// between an allowed predecessor and the current schema. Refusal happens
101 /// before schema or ledger mutation.
102 #[error(
103 "schema for domain `{domain}` is not a supported predecessor: file has version {found}, \
104 this binary supports version {supported} and accepts existing versions {allowed:?}"
105 )]
106 UnsupportedSchemaPredecessor {
107 domain: String,
108 found: i64,
109 supported: i64,
110 allowed: Vec<i64>,
111 },
112
113 /// A ledger row names an otherwise allowed current or released version,
114 /// but the domain-owned catalog does not exactly match that version's
115 /// frozen schema fingerprint. The row alone is not authority to
116 /// reinterpret a candidate or partially migrated schema.
117 #[error(
118 "schema fingerprint for domain `{domain}` version {version} does not match the \
119 allowed schema: {detail}"
120 )]
121 SchemaFingerprintMismatch {
122 domain: String,
123 version: i64,
124 detail: String,
125 },
126
127 /// A file has no ledger row for a domain but already contains one or more
128 /// objects owned by that domain. At the 0.8.10 compatibility floor this
129 /// is neither a fresh domain nor an authenticated released predecessor:
130 /// silently running idempotent DDL over it would bless an unknown or
131 /// unreleased candidate schema.
132 #[error(
133 "schema domain `{domain}` has no ledger row but already owns objects {objects:?}; \
134 refusing to infer or stamp an unversioned schema.{}",
135 bridgeable.remedy_sentence()
136 )]
137 UnledgeredDomainObjects {
138 domain: String,
139 objects: Vec<String>,
140 /// Whether the explicit offline bridge can authenticate this exact
141 /// on-disk catalog, decided at the raise site where the domain's
142 /// frozen verifiers and the connection are both in scope. Callers
143 /// that offer the bridge as a remedy must consult this rather than
144 /// naming it unconditionally.
145 bridgeable: BridgeEligibility,
146 },
147
148 /// Explicit maintenance could not authenticate an unledgered owned
149 /// catalog as any exact migration prefix or frozen predecessor through
150 /// the requested target. No preparation, migration, or ledger mutation
151 /// has run.
152 #[error(
153 "unledgered schema domain `{domain}` does not match any authorized source catalog \
154 through version {target_version}; found owned objects {objects:?}"
155 )]
156 UnledgeredSchemaNoMatch {
157 domain: String,
158 target_version: i64,
159 objects: Vec<String>,
160 },
161
162 /// Explicit maintenance found more than one exact authorized source
163 /// version for an unledgered catalog. Inferring a version would be
164 /// ambiguous, so the file remains untouched.
165 #[error(
166 "unledgered schema domain `{domain}` ambiguously matches source versions {matches:?} \
167 through requested target version {target_version}"
168 )]
169 UnledgeredSchemaAmbiguous {
170 domain: String,
171 target_version: i64,
172 matches: Vec<i64>,
173 },
174
175 /// A registered migration failed while being applied. The surrounding
176 /// transaction is rolled back; the file is left at its prior version.
177 #[error("migration {version} (`{name}`) for domain `{domain}` failed: {source}")]
178 MigrationFailed {
179 domain: String,
180 version: i64,
181 name: String,
182 #[source]
183 source: rusqlite::Error,
184 },
185
186 /// A migration body ended the runner's IMMEDIATE transaction (COMMIT or
187 /// ROLLBACK, with or without re-BEGINning a fresh one), separating its
188 /// schema work from the ledger stamp. Custody is verified after every
189 /// body via a runner-owned savepoint; the domain is left unstamped.
190 #[error(
191 "migration {version} (`{name}`) for domain `{domain}` ended the runner's transaction; \
192 migration bodies must not COMMIT or ROLLBACK"
193 )]
194 MigrationBrokeTransaction {
195 domain: String,
196 version: i64,
197 name: String,
198 },
199
200 /// The `meerkat_schema` ledger table exists but is not the pinned shape
201 /// (`domain TEXT PRIMARY KEY, version INTEGER NOT NULL`), carries more
202 /// than one row for a domain, or records a non-positive version. This is
203 /// corrupt or foreign ledger state: it is refused, never healed by
204 /// re-running migrations over it.
205 #[error("meerkat_schema ledger is malformed: {detail}")]
206 LedgerMalformed { detail: String },
207
208 /// A profile whose journal policy is
209 /// [`JournalPolicy::EstablishWal`](crate::JournalPolicy::EstablishWal)
210 /// asked SQLite to establish `journal_mode=WAL` and SQLite reported a
211 /// different effective mode without raising an error (the journal-mode
212 /// pragma can silently keep the old mode). The connection does not
213 /// satisfy the profile's durability policy.
214 #[error("could not establish journal_mode=WAL on `{path}`: effective mode is `{actual}`")]
215 WalNotEstablished { path: PathBuf, actual: String },
216
217 /// Converting an existing rollback-journal database to WAL needs a brief
218 /// exclusive lock, and the journal-mode pragma reports `SQLITE_BUSY`
219 /// without consulting the busy handler while another connection holds the
220 /// file. The bounded retry spent its whole budget without winning that
221 /// lock, so the open fails closed rather than serving durable read-write
222 /// traffic from a rollback-journal database, where every write takes a
223 /// database-wide EXCLUSIVE lock with no reader/writer separation.
224 ///
225 /// The database is left exactly as found; the operator remedy is to retry
226 /// once the contending connection releases the file (a second boot
227 /// attempt normally wins it, since the conversion is a no-op the moment
228 /// the file is WAL).
229 #[error(
230 "could not establish journal_mode=WAL on `{path}`: the conversion stayed lock-contended \
231 for {waited_ms} ms"
232 )]
233 WalConversionContended {
234 path: PathBuf,
235 waited_ms: u64,
236 #[source]
237 source: rusqlite::Error,
238 },
239
240 /// A domain registered an invalid migration list (non-contiguous or
241 /// not starting at version 1). This is a programming error in the store
242 /// crate, caught before any file is touched.
243 #[error("domain `{domain}` registered an invalid migration list: {detail}")]
244 InvalidMigrationList { domain: String, detail: String },
245
246 /// The connection profile refused the requested open (for example a
247 /// non-creating profile pointed at a missing file).
248 #[error("cannot open `{path}` with profile {profile}: {detail}")]
249 OpenRefused {
250 path: PathBuf,
251 profile: &'static str,
252 detail: String,
253 },
254
255 /// The exclusive maintenance fence is held for this database: storage is
256 /// under offline maintenance and the operation must not proceed. (Also
257 /// returned by [`crate::fence::ExclusiveFence::acquire`] when in-flight
258 /// operations did not drain within the deadline.)
259 #[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
260 MaintenanceFenceHeld { path: PathBuf },
261}
262
263/// Storage-level classification of a SQLite error.
264///
265/// This is deliberately narrower than the store-boundary taxonomy
266/// (transient / stale / corrupt): staleness (CAS conflicts, revision guards)
267/// is a store-contract concept invisible at this layer, so store crates map
268/// their own guard failures to their stale variants and use this
269/// classification for everything that reaches raw SQLite.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum SqliteErrorClass {
272 /// Lock contention or interruption; safe to retry only for idempotent or
273 /// CAS-keyed operations (see the crate-level retryability note).
274 Transient,
275 /// The file is not (or no longer) a usable database.
276 Corrupt,
277 /// Everything else: constraint violations, misuse, API errors. The store
278 /// layer decides what these mean.
279 Other,
280}
281
282/// Classify a rusqlite error at the storage level.
283///
284/// Adoption contract: store crates route every raw [`rusqlite::Error`]
285/// through this one classifier when deciding transient-vs-corrupt at their
286/// store boundary, instead of re-matching SQLite error codes locally.
287/// [`SqliteErrorClass::Other`] is the store layer's to interpret (constraint
288/// violations become CAS/stale semantics there, not here). Classification
289/// alone never authorizes a retry — see the crate-level retryability note.
290pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
291 use rusqlite::ErrorCode;
292 match error {
293 rusqlite::Error::SqliteFailure(f, _) => match f.code {
294 ErrorCode::DatabaseBusy
295 | ErrorCode::DatabaseLocked
296 | ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
297 ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
298 _ => SqliteErrorClass::Other,
299 },
300 _ => SqliteErrorClass::Other,
301 }
302}
303
304/// True when the error is SQLITE_BUSY or SQLITE_LOCKED — the nonblocking
305/// admission probes (write fences) map exactly these to a typed backoff.
306pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
307 use rusqlite::ErrorCode;
308 matches!(
309 error,
310 rusqlite::Error::SqliteFailure(f, _)
311 if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
312 )
313}
314
315#[cfg(test)]
316#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
317mod tests {
318 use super::*;
319
320 fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
321 rusqlite::Error::SqliteFailure(
322 rusqlite::ffi::Error {
323 code,
324 extended_code: 0,
325 },
326 None,
327 )
328 }
329
330 #[test]
331 fn busy_and_locked_classify_transient() {
332 for code in [
333 rusqlite::ErrorCode::DatabaseBusy,
334 rusqlite::ErrorCode::DatabaseLocked,
335 ] {
336 let err = sqlite_failure(code);
337 assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
338 assert!(is_busy_or_locked(&err));
339 }
340 }
341
342 #[test]
343 fn corruption_classifies_corrupt() {
344 for code in [
345 rusqlite::ErrorCode::DatabaseCorrupt,
346 rusqlite::ErrorCode::NotADatabase,
347 ] {
348 let err = sqlite_failure(code);
349 assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
350 assert!(!is_busy_or_locked(&err));
351 }
352 }
353
354 #[test]
355 fn constraint_violation_classifies_other() {
356 let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
357 assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
358 assert!(!is_busy_or_locked(&err));
359 }
360
361 /// This refusal reaches operators through more than one error type, and
362 /// after a partial bridge it reached them through this one with no next
363 /// step at all: a bare "refusing to infer or stamp an unversioned schema".
364 /// The remedy belongs to the eligibility answer, so every rendering of the
365 /// refusal carries it.
366 #[test]
367 fn unledgered_domain_objects_carry_their_remedy_in_every_rendering() {
368 let authenticated = SqliteStoreError::UnledgeredDomainObjects {
369 domain: "session-store".to_string(),
370 objects: vec!["table:sessions (expected table)".to_string()],
371 bridgeable: BridgeEligibility::CatalogAuthenticated,
372 }
373 .to_string();
374 assert!(
375 authenticated.contains("--bridge-pre-0-8-10"),
376 "a bridgeable catalog must name the command that recovers it: {authenticated}"
377 );
378
379 let unrecognized = SqliteStoreError::UnledgeredDomainObjects {
380 domain: "session-store".to_string(),
381 objects: vec!["table:sessions (expected table)".to_string()],
382 bridgeable: BridgeEligibility::Unrecognized,
383 }
384 .to_string();
385 assert!(
386 !unrecognized.contains("--apply"),
387 "an unrecognized catalog must not be handed a runnable apply command: {unrecognized}"
388 );
389 assert!(
390 unrecognized.contains("will not recover this domain"),
391 "the refusal must say plainly that the bridge cannot help: {unrecognized}"
392 );
393 }
394
395 /// The whole point of the remedy sentences is that an operator can read
396 /// them. A stray run of spaces from a botched line continuation shipped
397 /// once already.
398 #[test]
399 fn remedy_sentences_carry_no_botched_line_continuation() {
400 for eligibility in [
401 BridgeEligibility::CatalogAuthenticated,
402 BridgeEligibility::Unrecognized,
403 ] {
404 let sentence = eligibility.remedy_sentence();
405 assert!(
406 !sentence.contains(" "),
407 "{eligibility:?} remedy carries a run of spaces: {sentence:?}"
408 );
409 }
410 }
411}