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 /// The database path names a file SQLite cannot safely address by that
79 /// one name: not a regular file, a file with more than one hard link, or
80 /// a file replaced between validation and open. SQLite derives journal,
81 /// WAL and SHM names from the opened path, so a second name for the same
82 /// database breaks coordinated access and crash recovery. Refused before
83 /// any connection or profile mutation.
84 #[error("unsupported database file {}: {detail}", path.display())]
85 UnsupportedDatabaseFile {
86 path: std::path::PathBuf,
87 detail: String,
88 },
89
90 /// Underlying SQLite failure.
91 #[error("sqlite error: {0}")]
92 Sqlite(#[from] rusqlite::Error),
93
94 /// The file's schema ledger records a version newer than this binary
95 /// supports. This is a refusal, not a corruption: a newer binary has
96 /// migrated the file and this binary must not touch it. Surfaces report
97 /// it as a typed, health-visible certification failure (a rollback
98 /// candidate fails cleanly) rather than crash-looping.
99 #[error(
100 "schema for domain `{domain}` is from the future: file has version {found}, \
101 this binary supports up to {supported}"
102 )]
103 SchemaFromTheFuture {
104 domain: String,
105 found: i64,
106 supported: i64,
107 },
108
109 /// The ledger records a real domain version, but that version is not one
110 /// of the exact released predecessors this binary is willing to upgrade.
111 /// This covers both versions below the compatibility floor and gaps
112 /// between an allowed predecessor and the current schema. Refusal happens
113 /// before schema or ledger mutation.
114 #[error(
115 "schema for domain `{domain}` is not a supported predecessor: file has version {found}, \
116 this binary supports version {supported} and accepts existing versions {allowed:?}"
117 )]
118 UnsupportedSchemaPredecessor {
119 domain: String,
120 found: i64,
121 supported: i64,
122 allowed: Vec<i64>,
123 },
124
125 /// A ledger row names an otherwise allowed current or released version,
126 /// but the domain-owned catalog does not exactly match that version's
127 /// frozen schema fingerprint. The row alone is not authority to
128 /// reinterpret a candidate or partially migrated schema.
129 #[error(
130 "schema fingerprint for domain `{domain}` version {version} does not match the \
131 allowed schema: {detail}"
132 )]
133 SchemaFingerprintMismatch {
134 domain: String,
135 version: i64,
136 detail: String,
137 },
138
139 /// A ledger row stamps the domain's CURRENT version, but the file's
140 /// domain-owned catalog is not that version's schema: owned objects are
141 /// missing, objects the current schema does not own (including retired
142 /// ones) are present, or an owned object's definition (for a table, its
143 /// columns and constraints) differs. Each list names objects as
144 /// `kind:name`. Typical cause: a file stamped by an unreleased build that
145 /// reused this version number for a different schema. The file is refused
146 /// before any access; it is never treated as current.
147 #[error(
148 "schema for domain `{domain}` is stamped current version {version} but its catalog is \
149 not that schema: missing {missing_objects:?}, unexpected {unexpected_objects:?}, \
150 changed {changed_objects:?}"
151 )]
152 CurrentSchemaMismatch {
153 domain: String,
154 version: i64,
155 missing_objects: Vec<String>,
156 unexpected_objects: Vec<String>,
157 changed_objects: Vec<String>,
158 },
159
160 /// A file has no ledger row for a domain but already contains one or more
161 /// objects owned by that domain. At the 0.8.10 compatibility floor this
162 /// is neither a fresh domain nor an authenticated released predecessor:
163 /// silently running idempotent DDL over it would bless an unknown or
164 /// unreleased candidate schema.
165 #[error(
166 "schema domain `{domain}` has no ledger row but already owns objects {objects:?}; \
167 refusing to infer or stamp an unversioned schema.{}",
168 bridgeable.remedy_sentence()
169 )]
170 UnledgeredDomainObjects {
171 domain: String,
172 objects: Vec<String>,
173 /// Whether the explicit offline bridge can authenticate this exact
174 /// on-disk catalog, decided at the raise site where the domain's
175 /// frozen verifiers and the connection are both in scope. Callers
176 /// that offer the bridge as a remedy must consult this rather than
177 /// naming it unconditionally.
178 bridgeable: BridgeEligibility,
179 },
180
181 /// Explicit maintenance could not authenticate an unledgered owned
182 /// catalog as any exact migration prefix or frozen predecessor through
183 /// the requested target. No preparation, migration, or ledger mutation
184 /// has run.
185 #[error(
186 "unledgered schema domain `{domain}` does not match any authorized source catalog \
187 through version {target_version}; found owned objects {objects:?}"
188 )]
189 UnledgeredSchemaNoMatch {
190 domain: String,
191 target_version: i64,
192 objects: Vec<String>,
193 },
194
195 /// Explicit maintenance found more than one exact authorized source
196 /// version for an unledgered catalog. Inferring a version would be
197 /// ambiguous, so the file remains untouched.
198 #[error(
199 "unledgered schema domain `{domain}` ambiguously matches source versions {matches:?} \
200 through requested target version {target_version}"
201 )]
202 UnledgeredSchemaAmbiguous {
203 domain: String,
204 target_version: i64,
205 matches: Vec<i64>,
206 },
207
208 /// A registered migration failed while being applied. The surrounding
209 /// transaction is rolled back; the file is left at its prior version.
210 #[error("migration {version} (`{name}`) for domain `{domain}` failed: {source}")]
211 MigrationFailed {
212 domain: String,
213 version: i64,
214 name: String,
215 #[source]
216 source: rusqlite::Error,
217 },
218
219 /// A migration body ended the runner's IMMEDIATE transaction (COMMIT or
220 /// ROLLBACK, with or without re-BEGINning a fresh one), separating its
221 /// schema work from the ledger stamp. Custody is verified after every
222 /// body via a runner-owned savepoint; the domain is left unstamped.
223 #[error(
224 "migration {version} (`{name}`) for domain `{domain}` ended the runner's transaction; \
225 migration bodies must not COMMIT or ROLLBACK"
226 )]
227 MigrationBrokeTransaction {
228 domain: String,
229 version: i64,
230 name: String,
231 },
232
233 /// The `meerkat_schema` ledger table exists but is not the pinned shape
234 /// (`domain TEXT PRIMARY KEY, version INTEGER NOT NULL`), carries more
235 /// than one row for a domain, or records a non-positive version. This is
236 /// corrupt or foreign ledger state: it is refused, never healed by
237 /// re-running migrations over it.
238 #[error("meerkat_schema ledger is malformed: {detail}")]
239 LedgerMalformed { detail: String },
240
241 /// A profile whose journal policy is
242 /// [`JournalPolicy::EstablishWal`](crate::JournalPolicy::EstablishWal)
243 /// asked SQLite to establish `journal_mode=WAL` and SQLite reported a
244 /// different effective mode without raising an error (the journal-mode
245 /// pragma can silently keep the old mode). The connection does not
246 /// satisfy the profile's durability policy.
247 #[error("could not establish journal_mode=WAL on `{path}`: effective mode is `{actual}`")]
248 WalNotEstablished { path: PathBuf, actual: String },
249
250 /// Converting an existing rollback-journal database to WAL needs a brief
251 /// exclusive lock, and the journal-mode pragma reports `SQLITE_BUSY`
252 /// without consulting the busy handler while another connection holds the
253 /// file. The bounded retry spent its whole budget without winning that
254 /// lock, so the open fails closed rather than serving durable read-write
255 /// traffic from a rollback-journal database, where every write takes a
256 /// database-wide EXCLUSIVE lock with no reader/writer separation.
257 ///
258 /// The database is left exactly as found; the operator remedy is to retry
259 /// once the contending connection releases the file (a second boot
260 /// attempt normally wins it, since the conversion is a no-op the moment
261 /// the file is WAL).
262 #[error(
263 "could not establish journal_mode=WAL on `{path}`: the conversion stayed lock-contended \
264 for {waited_ms} ms"
265 )]
266 WalConversionContended {
267 path: PathBuf,
268 waited_ms: u64,
269 #[source]
270 source: rusqlite::Error,
271 },
272
273 /// A domain registered an invalid migration list (non-contiguous or
274 /// not starting at version 1). This is a programming error in the store
275 /// crate, caught before any file is touched.
276 #[error("domain `{domain}` registered an invalid migration list: {detail}")]
277 InvalidMigrationList { domain: String, detail: String },
278
279 /// The connection profile refused the requested open (for example a
280 /// non-creating profile pointed at a missing file).
281 #[error("cannot open `{path}` with profile {profile}: {detail}")]
282 OpenRefused {
283 path: PathBuf,
284 profile: &'static str,
285 detail: String,
286 },
287
288 /// The exclusive maintenance fence is held for this database: storage is
289 /// under offline maintenance and the operation must not proceed. (Also
290 /// returned by [`crate::fence::ExclusiveFence::acquire`] when in-flight
291 /// operations did not drain, or a kept connection did not close, within
292 /// the deadline: see [`crate::fence::KeptConnectionPresence`].)
293 #[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
294 MaintenanceFenceHeld { path: PathBuf },
295
296 /// An administrative open could not read this process's expected current
297 /// catalog for `domain`. Nothing was read from or written to the database
298 /// by the check. The cause says why; neither is momentary contention.
299 #[error("current schema catalog for domain `{domain}` is not available: {cause}")]
300 CurrentCatalogUnavailable {
301 domain: String,
302 cause: CurrentCatalogUnavailability,
303 },
304}
305
306/// Why the expected current catalog was not available. The cache is a leaf
307/// lock that is waited for, so contention is never a cause.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
309pub enum CurrentCatalogUnavailability {
310 /// No ordinary open in this process has prepared this domain's catalog.
311 /// Preparation belongs to store setup, not to the administrative path.
312 #[error("the catalog was never prepared in this process")]
313 NotPrepared,
314 /// The process catalog cache lock is poisoned.
315 #[error("the catalog cache is poisoned")]
316 Poisoned,
317}
318
319/// Storage-level classification of a SQLite error.
320///
321/// This is deliberately narrower than the store-boundary taxonomy
322/// (transient / stale / corrupt): staleness (CAS conflicts, revision guards)
323/// is a store-contract concept invisible at this layer, so store crates map
324/// their own guard failures to their stale variants and use this
325/// classification for everything that reaches raw SQLite.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum SqliteErrorClass {
328 /// Lock contention or interruption; safe to retry only for idempotent or
329 /// CAS-keyed operations (see the crate-level retryability note).
330 Transient,
331 /// The file is not (or no longer) a usable database.
332 Corrupt,
333 /// Everything else: constraint violations, misuse, API errors. The store
334 /// layer decides what these mean.
335 Other,
336}
337
338/// Classify a rusqlite error at the storage level.
339///
340/// Adoption contract: store crates route every raw [`rusqlite::Error`]
341/// through this one classifier when deciding transient-vs-corrupt at their
342/// store boundary, instead of re-matching SQLite error codes locally.
343/// [`SqliteErrorClass::Other`] is the store layer's to interpret (constraint
344/// violations become CAS/stale semantics there, not here). Classification
345/// alone never authorizes a retry — see the crate-level retryability note.
346pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
347 use rusqlite::ErrorCode;
348 match error {
349 rusqlite::Error::SqliteFailure(f, _) => match f.code {
350 ErrorCode::DatabaseBusy
351 | ErrorCode::DatabaseLocked
352 | ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
353 ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
354 _ => SqliteErrorClass::Other,
355 },
356 _ => SqliteErrorClass::Other,
357 }
358}
359
360/// True when the error is SQLITE_BUSY or SQLITE_LOCKED — the nonblocking
361/// admission probes (write fences) map exactly these to a typed backoff.
362pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
363 use rusqlite::ErrorCode;
364 matches!(
365 error,
366 rusqlite::Error::SqliteFailure(f, _)
367 if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
368 )
369}
370
371#[cfg(test)]
372#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
373mod tests {
374 use super::*;
375
376 fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
377 rusqlite::Error::SqliteFailure(
378 rusqlite::ffi::Error {
379 code,
380 extended_code: 0,
381 },
382 None,
383 )
384 }
385
386 #[test]
387 fn busy_and_locked_classify_transient() {
388 for code in [
389 rusqlite::ErrorCode::DatabaseBusy,
390 rusqlite::ErrorCode::DatabaseLocked,
391 ] {
392 let err = sqlite_failure(code);
393 assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
394 assert!(is_busy_or_locked(&err));
395 }
396 }
397
398 #[test]
399 fn corruption_classifies_corrupt() {
400 for code in [
401 rusqlite::ErrorCode::DatabaseCorrupt,
402 rusqlite::ErrorCode::NotADatabase,
403 ] {
404 let err = sqlite_failure(code);
405 assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
406 assert!(!is_busy_or_locked(&err));
407 }
408 }
409
410 #[test]
411 fn constraint_violation_classifies_other() {
412 let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
413 assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
414 assert!(!is_busy_or_locked(&err));
415 }
416
417 /// This refusal reaches operators through more than one error type, and
418 /// after a partial bridge it reached them through this one with no next
419 /// step at all: a bare "refusing to infer or stamp an unversioned schema".
420 /// The remedy belongs to the eligibility answer, so every rendering of the
421 /// refusal carries it.
422 #[test]
423 fn unledgered_domain_objects_carry_their_remedy_in_every_rendering() {
424 let authenticated = SqliteStoreError::UnledgeredDomainObjects {
425 domain: "session-store".to_string(),
426 objects: vec!["table:sessions (expected table)".to_string()],
427 bridgeable: BridgeEligibility::CatalogAuthenticated,
428 }
429 .to_string();
430 assert!(
431 authenticated.contains("--bridge-pre-0-8-10"),
432 "a bridgeable catalog must name the command that recovers it: {authenticated}"
433 );
434
435 let unrecognized = SqliteStoreError::UnledgeredDomainObjects {
436 domain: "session-store".to_string(),
437 objects: vec!["table:sessions (expected table)".to_string()],
438 bridgeable: BridgeEligibility::Unrecognized,
439 }
440 .to_string();
441 assert!(
442 !unrecognized.contains("--apply"),
443 "an unrecognized catalog must not be handed a runnable apply command: {unrecognized}"
444 );
445 assert!(
446 unrecognized.contains("will not recover this domain"),
447 "the refusal must say plainly that the bridge cannot help: {unrecognized}"
448 );
449 }
450
451 /// The whole point of the remedy sentences is that an operator can read
452 /// them. A stray run of spaces from a botched line continuation shipped
453 /// once already.
454 #[test]
455 fn remedy_sentences_carry_no_botched_line_continuation() {
456 for eligibility in [
457 BridgeEligibility::CatalogAuthenticated,
458 BridgeEligibility::Unrecognized,
459 ] {
460 let sentence = eligibility.remedy_sentence();
461 assert!(
462 !sentence.contains(" "),
463 "{eligibility:?} remedy carries a run of spaces: {sentence:?}"
464 );
465 }
466 }
467}