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