Skip to main content

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 within the deadline.)
292    #[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
293    MaintenanceFenceHeld { path: PathBuf },
294
295    /// An administrative open could not read this process's expected current
296    /// catalog for `domain`. Nothing was read from or written to the database
297    /// by the check. The cause says why; neither is momentary contention.
298    #[error("current schema catalog for domain `{domain}` is not available: {cause}")]
299    CurrentCatalogUnavailable {
300        domain: String,
301        cause: CurrentCatalogUnavailability,
302    },
303}
304
305/// Why the expected current catalog was not available. The cache is a leaf
306/// lock that is waited for, so contention is never a cause.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
308pub enum CurrentCatalogUnavailability {
309    /// No ordinary open in this process has prepared this domain's catalog.
310    /// Preparation belongs to store setup, not to the administrative path.
311    #[error("the catalog was never prepared in this process")]
312    NotPrepared,
313    /// The process catalog cache lock is poisoned.
314    #[error("the catalog cache is poisoned")]
315    Poisoned,
316}
317
318/// Storage-level classification of a SQLite error.
319///
320/// This is deliberately narrower than the store-boundary taxonomy
321/// (transient / stale / corrupt): staleness (CAS conflicts, revision guards)
322/// is a store-contract concept invisible at this layer, so store crates map
323/// their own guard failures to their stale variants and use this
324/// classification for everything that reaches raw SQLite.
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum SqliteErrorClass {
327    /// Lock contention or interruption; safe to retry only for idempotent or
328    /// CAS-keyed operations (see the crate-level retryability note).
329    Transient,
330    /// The file is not (or no longer) a usable database.
331    Corrupt,
332    /// Everything else: constraint violations, misuse, API errors. The store
333    /// layer decides what these mean.
334    Other,
335}
336
337/// Classify a rusqlite error at the storage level.
338///
339/// Adoption contract: store crates route every raw [`rusqlite::Error`]
340/// through this one classifier when deciding transient-vs-corrupt at their
341/// store boundary, instead of re-matching SQLite error codes locally.
342/// [`SqliteErrorClass::Other`] is the store layer's to interpret (constraint
343/// violations become CAS/stale semantics there, not here). Classification
344/// alone never authorizes a retry — see the crate-level retryability note.
345pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
346    use rusqlite::ErrorCode;
347    match error {
348        rusqlite::Error::SqliteFailure(f, _) => match f.code {
349            ErrorCode::DatabaseBusy
350            | ErrorCode::DatabaseLocked
351            | ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
352            ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
353            _ => SqliteErrorClass::Other,
354        },
355        _ => SqliteErrorClass::Other,
356    }
357}
358
359/// True when the error is SQLITE_BUSY or SQLITE_LOCKED — the nonblocking
360/// admission probes (write fences) map exactly these to a typed backoff.
361pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
362    use rusqlite::ErrorCode;
363    matches!(
364        error,
365        rusqlite::Error::SqliteFailure(f, _)
366            if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
367    )
368}
369
370#[cfg(test)]
371#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
372mod tests {
373    use super::*;
374
375    fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
376        rusqlite::Error::SqliteFailure(
377            rusqlite::ffi::Error {
378                code,
379                extended_code: 0,
380            },
381            None,
382        )
383    }
384
385    #[test]
386    fn busy_and_locked_classify_transient() {
387        for code in [
388            rusqlite::ErrorCode::DatabaseBusy,
389            rusqlite::ErrorCode::DatabaseLocked,
390        ] {
391            let err = sqlite_failure(code);
392            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
393            assert!(is_busy_or_locked(&err));
394        }
395    }
396
397    #[test]
398    fn corruption_classifies_corrupt() {
399        for code in [
400            rusqlite::ErrorCode::DatabaseCorrupt,
401            rusqlite::ErrorCode::NotADatabase,
402        ] {
403            let err = sqlite_failure(code);
404            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
405            assert!(!is_busy_or_locked(&err));
406        }
407    }
408
409    #[test]
410    fn constraint_violation_classifies_other() {
411        let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
412        assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
413        assert!(!is_busy_or_locked(&err));
414    }
415
416    /// This refusal reaches operators through more than one error type, and
417    /// after a partial bridge it reached them through this one with no next
418    /// step at all: a bare "refusing to infer or stamp an unversioned schema".
419    /// The remedy belongs to the eligibility answer, so every rendering of the
420    /// refusal carries it.
421    #[test]
422    fn unledgered_domain_objects_carry_their_remedy_in_every_rendering() {
423        let authenticated = SqliteStoreError::UnledgeredDomainObjects {
424            domain: "session-store".to_string(),
425            objects: vec!["table:sessions (expected table)".to_string()],
426            bridgeable: BridgeEligibility::CatalogAuthenticated,
427        }
428        .to_string();
429        assert!(
430            authenticated.contains("--bridge-pre-0-8-10"),
431            "a bridgeable catalog must name the command that recovers it: {authenticated}"
432        );
433
434        let unrecognized = SqliteStoreError::UnledgeredDomainObjects {
435            domain: "session-store".to_string(),
436            objects: vec!["table:sessions (expected table)".to_string()],
437            bridgeable: BridgeEligibility::Unrecognized,
438        }
439        .to_string();
440        assert!(
441            !unrecognized.contains("--apply"),
442            "an unrecognized catalog must not be handed a runnable apply command: {unrecognized}"
443        );
444        assert!(
445            unrecognized.contains("will not recover this domain"),
446            "the refusal must say plainly that the bridge cannot help: {unrecognized}"
447        );
448    }
449
450    /// The whole point of the remedy sentences is that an operator can read
451    /// them. A stray run of spaces from a botched line continuation shipped
452    /// once already.
453    #[test]
454    fn remedy_sentences_carry_no_botched_line_continuation() {
455        for eligibility in [
456            BridgeEligibility::CatalogAuthenticated,
457            BridgeEligibility::Unrecognized,
458        ] {
459            let sentence = eligibility.remedy_sentence();
460            assert!(
461                !sentence.contains("  "),
462                "{eligibility:?} remedy carries a run of spaces: {sentence:?}"
463            );
464        }
465    }
466}