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, 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}