Skip to main content

obj_core/
error.rs

1//! Crate-level error type.
2//!
3//! Every fallible operation in `obj-core` returns
4//! [`Result<T, Error>`](Result). `Error` is intentionally small at
5//! milestone M2; later milestones (WAL, B-tree, catalog) extend it with
6//! more variants. The variants are non-exhaustive so additions do not
7//! count as breaking changes for in-tree callers.
8//!
9//! Power-of-ten Rule 7: every `Result` and `Option` is handled
10//! explicitly. There are no `.unwrap()` or `.expect()` calls in this
11//! crate's production code paths.
12
13#![forbid(unsafe_code)]
14
15use std::io;
16use thiserror::Error;
17
18use crate::index::IndexKind;
19
20/// The pager-level error type.
21///
22/// Construct variants directly when synthesising an error; downstream
23/// callers should match exhaustively or use the `#[non_exhaustive]`
24/// catch-all so that future variants are not source-breaking additions.
25#[derive(Debug, Error)]
26#[non_exhaustive]
27pub enum Error {
28    /// An I/O error from the platform layer (file open, read, write,
29    /// flush). Wraps the underlying [`std::io::Error`] for inspection
30    /// and never silently discards it (Rule 7).
31    #[error("i/o error: {0}")]
32    Io(#[from] io::Error),
33
34    /// The on-disk image is malformed or its checksum does not match.
35    /// `page_id` is the page where corruption was detected; the value
36    /// `0` refers to the file header. The error carries no `source` —
37    /// corruption is the *direct* failure mode.
38    #[error("corruption detected on page {page_id}")]
39    Corruption {
40        /// Page index where the corruption was detected. `0` = header.
41        page_id: u64,
42    },
43
44    /// A WAL frame whose CRC32C does not validate sits **before** the
45    /// last commit marker in the current generation. Unlike a torn
46    /// tail (silently discarded), mid-WAL corruption is a recovery
47    /// error: replaying past it would drop or alias durable data, and
48    /// recovery refuses to guess. `frame_offset` is the byte offset
49    /// of the bad frame in the WAL file. See `docs/format.md`
50    /// § Recovery semantics.
51    #[error("WAL corruption detected at frame offset {frame_offset}")]
52    WalCorruption {
53        /// Byte offset of the corrupted frame inside the WAL sidecar.
54        frame_offset: u64,
55    },
56
57    /// The file's magic, page-size, or major version is not what this
58    /// build of the library understands. Distinct from `Corruption`
59    /// because the file may be perfectly valid for a different reader.
60    #[error("not an obj database (or unsupported format): {reason}")]
61    InvalidFormat {
62        /// Human-readable explanation; intended for logging, not for
63        /// programmatic dispatch.
64        reason: &'static str,
65    },
66
67    /// Caller passed an out-of-range `PageId`, capacity, or similar
68    /// numeric input that the type system could not statically
69    /// rule out. Always indicates a caller bug.
70    #[error("invalid argument: {0}")]
71    InvalidArgument(&'static str),
72
73    /// A B+tree traversal exceeded its statically-bounded depth limit
74    /// (`MAX_BTREE_DEPTH = 32`). Power-of-ten Rule 1: every recursive
75    /// shape is bounded; this is the surfaced error when the bound
76    /// trips, not a panic.
77    #[error("B+tree depth exceeded the {limit}-level bound")]
78    BTreeDepthExceeded {
79        /// The bound that was exceeded.
80        limit: usize,
81    },
82
83    /// A B+tree insert was given a key longer than the format spec
84    /// permits (`PAGE_SIZE / 4`). See `docs/format.md` § Key and
85    /// value encoding.
86    #[error("B+tree key length {key_len} exceeds max {max}")]
87    BTreeKeyTooLarge {
88        /// The offending key's length in bytes.
89        key_len: usize,
90        /// The maximum permitted key length in bytes.
91        max: usize,
92    },
93
94    /// A B+tree insert was given a value too large to fit inline in a
95    /// leaf alongside at least one slot. Overflow chains are deferred
96    /// to a future minor format version.
97    #[error("B+tree value length {value_len} exceeds inline max {max}")]
98    BTreeValueTooLarge {
99        /// The offending value's length in bytes.
100        value_len: usize,
101        /// The maximum value length that still fits inline.
102        max: usize,
103    },
104
105    /// A B+tree insert was given a key that already exists in the
106    /// tree. M4 trees do not allow duplicates; the multi-value /
107    /// composite-key story arrives in M7.
108    #[error("B+tree key already exists")]
109    BTreeKeyExists,
110
111    /// A B+tree range scan exceeded the per-scan node budget
112    /// (`MAX_RANGE_NODES = 1_000_000`). Power-of-ten Rule 2.
113    #[error("B+tree range scan exceeded the {limit}-node budget")]
114    BTreeScanLimitExceeded {
115        /// The bound that was exceeded.
116        limit: usize,
117    },
118
119    /// A B+tree invariant that `debug_assert!` would normally catch
120    /// has tripped in a release build. Surfaced as an `Error` rather
121    /// than a panic per power-of-ten Rules 5 + 7.
122    #[error("B+tree invariant violated: {reason}")]
123    BTreeInvariantViolated {
124        /// Human-readable description of the violated invariant.
125        reason: &'static str,
126    },
127
128    /// A document encode call produced a record larger than the
129    /// B+tree leaf can hold inline. Overflow chains for oversize
130    /// documents are deferred to a later format-minor (see
131    /// `docs/format.md` § Document records). M5 documents must fit
132    /// inline.
133    #[error("document record length {len} exceeds inline max {max}")]
134    DocumentTooLarge {
135        /// Total record length (per-doc header + postcard payload).
136        len: usize,
137        /// Maximum length that still fits inline in a leaf.
138        max: usize,
139    },
140
141    /// A decode call observed a per-document header whose
142    /// `collection_id` does not match the catalog row for the
143    /// `Document` type being decoded. Indicates a programming bug
144    /// (wrong type at a key) or a forensic mishap (cross-collection
145    /// byte forgery) — never a transient I/O issue.
146    #[error("collection id mismatch: header says {found}, expected {expected}")]
147    CollectionIdMismatch {
148        /// The collection id the caller said this record should belong to.
149        expected: u32,
150        /// The collection id the on-disk record actually carries.
151        found: u32,
152    },
153
154    /// The on-disk record's `type_version` is older than the
155    /// `Document::VERSION` of the Rust type, and that type's
156    /// `Migrate` impl is the default-erroring body. Real
157    /// `Document` types override `Migrate::migrate` to handle this.
158    #[error(
159        "schema migration not implemented for collection '{collection}': from v{from_version} to v{to_version}"
160    )]
161    SchemaMigrationNotImplemented {
162        /// The collection whose record could not be migrated.
163        collection: &'static str,
164        /// The stored `type_version`.
165        from_version: u32,
166        /// The reader's `Document::VERSION`.
167        to_version: u32,
168    },
169
170    /// The on-disk record's `type_version` is **newer** than the
171    /// reader's `Document::VERSION`. The reader refuses to guess
172    /// what the unknown fields mean — better a hard error than a
173    /// silent loss of data.
174    #[error(
175        "schema version from future for collection '{collection}': stored v{from}, reader v{to}"
176    )]
177    SchemaVersionFromFuture {
178        /// The collection whose record was rejected.
179        collection: &'static str,
180        /// The stored `type_version` (larger than reader's).
181        from: u32,
182        /// The reader's `Document::VERSION`.
183        to: u32,
184    },
185
186    /// `Catalog::insert` was called for a name already present in the
187    /// catalog. The user should call `Catalog::update` instead, or
188    /// pick a different name.
189    #[error("collection '{name}' already exists in the catalog")]
190    CollectionAlreadyExists {
191        /// The collection name that was already registered.
192        name: String,
193    },
194
195    /// Per-collection `Id` allocator exhausted its `u64` space, or
196    /// the catalog's per-collection-id `u32` space. At 10⁹/sec the
197    /// `u64` case takes ~584 years; this is a defense-in-depth
198    /// check (Rule 5) not a likely runtime event.
199    ///
200    /// The `collection` field is an owned `String` so user-supplied
201    /// `&str` names can be reported verbatim without leaking a
202    /// `'static` slot. The `#[non_exhaustive]` attribute on `Error`
203    /// keeps this widening backward-compatible for downstream
204    /// pattern-matchers.
205    #[error("id space exhausted for collection '{collection}'")]
206    IdSpaceExhausted {
207        /// The collection (or `"<catalog>"`) whose id allocator
208        /// was exhausted.
209        collection: String,
210    },
211
212    /// A postcard encode or decode operation failed at the codec
213    /// boundary. The wrapped error carries postcard's diagnostic;
214    /// obj does not further interpret it because postcard is treated
215    /// as a black-box codec (`docs/format.md` § Postcard pin).
216    #[error("codec (postcard) error: {0}")]
217    Codec(#[from] postcard::Error),
218
219    /// A cross-process or in-process lock was contended for the
220    /// caller-supplied timeout (`Config::busy_timeout` or the
221    /// explicit deadline passed to `FileHandle::lock_writer` /
222    /// `FileHandle::lock_reader`).  Power-of-ten Rule 2: every wait
223    /// has an explicit budget; exhausting it surfaces here rather
224    /// than blocking the caller forever.  See `docs/format.md`
225    /// § File locking for the lock-byte layout and the protocol.
226    #[error("lock busy ({kind:?})")]
227    Busy {
228        /// Which lock category was contended.
229        kind: LockKind,
230    },
231
232    /// The `Db` was opened with `Db::open_readonly` and the caller
233    /// invoked an operation that would mutate the database.
234    /// Surfaced eagerly so callers never have to inspect the
235    /// underlying lock state to know the call was illegal.
236    #[error("operation '{operation}' is not allowed on a read-only database")]
237    ReadOnly {
238        /// Short label naming the call that was rejected (e.g.
239        /// `"insert"`, `"transaction"`).
240        operation: &'static str,
241    },
242
243    /// A typed-API caller asked for a collection that the catalog
244    /// does not have a row for. Distinct from
245    /// [`Error::CollectionAlreadyExists`] (the insert-side dual);
246    /// distinct from [`Error::Corruption`] (which would indicate a
247    /// catalog row is malformed, not absent).
248    #[error("collection '{name}' is not registered")]
249    CollectionNotFound {
250        /// The collection name the caller asked for.
251        name: String,
252    },
253
254    /// A `Collection::update` or `Collection::delete` was given an
255    /// id that does not exist in the collection's primary B-tree.
256    #[error("document {id} not found in collection '{collection}'")]
257    DocumentNotFound {
258        /// The collection name.
259        collection: &'static str,
260        /// The id that was not found.
261        id: u64,
262    },
263
264    /// An index extraction call (#56) was unable to resolve the
265    /// configured field path against the document's `Dynamic` view.
266    /// The document does not carry a value at the path the index
267    /// names — typically a schema-evolution gap: the type used to
268    /// have the field, an older document did not, and reconciliation
269    /// has not rewritten it yet.
270    #[error("index '{index}' on collection '{collection}': field path '{path}' is absent")]
271    IndexFieldMissing {
272        /// The collection the index belongs to.
273        collection: String,
274        /// The index's name.
275        index: String,
276        /// The dotted path the index is keyed on.
277        path: String,
278    },
279
280    /// Lookup of a named index against a collection's descriptor
281    /// failed: the catalog has no `IndexDescriptor` with that name,
282    /// or the descriptor exists but is in `DroppedPending` status.
283    #[error("index '{name}' not found on collection '{collection}'")]
284    IndexNotFound {
285        /// The collection the lookup was scoped to.
286        collection: String,
287        /// The index name the caller asked for.
288        name: String,
289    },
290
291    /// `Collection::find_unique` was called on an index that is not
292    /// `Unique` — `find_unique` is only defined for unique indexes.
293    /// Callers that want non-unique lookups should use
294    /// `Collection::lookup`.
295    #[error("index '{name}' on collection '{collection}' is not a Unique index")]
296    IndexNotUnique {
297        /// The collection the lookup was scoped to.
298        collection: String,
299        /// The index name the caller asked for.
300        name: String,
301    },
302
303    /// The reconciler observed a runtime [`crate::index::IndexSpec`] whose
304    /// `kind` disagrees with the on-disk descriptor of the same
305    /// name. To change an index's kind the application must
306    /// drop-then-redeclare under a different name (or rebuild from
307    /// scratch); silent in-place rewrites would invalidate any
308    /// extant entries.
309    #[error(
310        "index '{name}' kind mismatch: existing descriptor is {found:?}, runtime spec is {expected:?}"
311    )]
312    IndexKindMismatch {
313        /// The index name.
314        name: String,
315        /// The runtime spec's kind.
316        expected: IndexKind,
317        /// The on-disk descriptor's kind.
318        found: IndexKind,
319    },
320
321    /// The reconciler observed a runtime [`crate::index::IndexSpec`] whose
322    /// `key_paths` disagree with the on-disk descriptor of the
323    /// same name and kind. Like [`Error::IndexKindMismatch`], the
324    /// only safe response is for the application to drop-and-
325    /// redeclare under a different name.
326    #[error("index '{name}' key_paths mismatch with stored descriptor")]
327    IndexKeyPathsMismatch {
328        /// The index name.
329        name: String,
330    },
331
332    /// A `Unique` index extraction observed a key value that
333    /// already exists on a different document in the same
334    /// collection. The maintenance path surfaces this rather than
335    /// silently overwriting; the WAL transaction rolls back so no
336    /// partial state remains.
337    #[error(
338        "unique constraint violation on index '{index}' \
339         in collection '{collection}'"
340    )]
341    UniqueConstraintViolation {
342        /// The collection the index belongs to.
343        collection: String,
344        /// The index name.
345        index: String,
346        /// The encoded key bytes that collided.
347        key: Vec<u8>,
348    },
349
350    /// A single document's `Each` extraction emitted more than the
351    /// per-doc ceiling number of index entries — almost certainly
352    /// the application supplied a runaway sequence rather than a
353    /// real indexable field.
354    #[error(
355        "each-index '{index}' on collection '{collection}': sequence \
356         of {len} entries exceeds per-doc cap {max}"
357    )]
358    EachIndexTooLarge {
359        /// The collection the index belongs to.
360        collection: String,
361        /// The index name.
362        index: String,
363        /// The observed sequence length.
364        len: usize,
365        /// The per-doc cap.
366        max: usize,
367    },
368
369    /// An index extraction call (#56) resolved a field path but the
370    /// value's `Dynamic` shape disagrees with the index kind's
371    /// contract — e.g. an `Each` index whose target field is not a
372    /// sequence, or a `Composite` field that resolved to a `Map`
373    /// (only primitive `Dynamic` values are indexable).
374    #[error(
375        "index '{index}' on collection '{collection}': field '{path}' \
376         has type '{found}', expected '{expected}'"
377    )]
378    IndexFieldTypeMismatch {
379        /// The collection the index belongs to.
380        collection: String,
381        /// The index's name.
382        index: String,
383        /// The dotted path the index is keyed on.
384        path: String,
385        /// The expected `Dynamic` shape (e.g. `"Seq"` for `Each`).
386        expected: &'static str,
387        /// The shape the document actually carries at the path.
388        found: &'static str,
389    },
390
391    /// An M8 query's `sort_by` extension collected more than its
392    /// `sort_buffer_limit` (default 100 000) candidate documents
393    /// before the sort+truncate step could narrow them down.
394    /// Power-of-ten Rule 3: the in-memory sort is bounded; the user
395    /// should add a `.filter` / `.index_range` / `.limit` that bounds
396    /// the survivors, or raise the limit explicitly via
397    /// `.sort_buffer_limit(N)` if the workload genuinely needs it.
398    ///
399    /// M8 sorts are designed for "screen-of-results" workloads, not
400    /// "sort a million rows" workloads; a disk-spill sort is the
401    /// post-M8 follow-up if this turns out to be too restrictive.
402    #[error("query sort buffer exceeded the {limit}-document budget")]
403    SortBufferExceeded {
404        /// The bound that was exceeded.
405        limit: usize,
406    },
407
408    /// An M8 query's `sort_by` extractor produced a [`Dynamic`] whose
409    /// `obj_core::index::encode_field` representation could not be
410    /// computed (e.g. a `Dynamic::String` carrying an embedded NUL
411    /// byte, which the order-preserving encoder rejects). Power-of-
412    /// ten Rule 7: the underlying error is propagated rather than
413    /// silently collapsed into an empty key. Callers who want to
414    /// control the encoding themselves should use
415    /// `Query::sort_by_bytes`, which never touches `encode_field`.
416    ///
417    /// [`Dynamic`]: crate::codec::Dynamic
418    #[error("sort_by key encoding failed: {source}")]
419    SortKeyEncode {
420        /// The underlying encoder failure. Boxed to keep `Error`
421        /// (which contains this variant) `Sized`.
422        #[source]
423        source: Box<Error>,
424    },
425
426    /// `Collection::count_distinct_ids_in_range` (M8 follow-up #72)
427    /// observed more than the caller-configurable per-call cap of
428    /// distinct `Id`s while walking the index B-tree. Power-of-ten
429    /// Rule 3: the in-memory `HashSet<Id>` is bounded; the user
430    /// should narrow the range via `.index_range(...)` so fewer
431    /// distinct docs fall inside the window. The fast path on the
432    /// query layer dispatches to this routine ONLY for `Each`-kind
433    /// indexes — other kinds count entries via the cheaper
434    /// `count_index_range` path that has no distinct-tracking cost.
435    #[error("distinct-id count exceeded the {limit}-id budget")]
436    DistinctCountExceeded {
437        /// The bound that was exceeded.
438        limit: usize,
439    },
440
441    /// `codec::decode` saw an on-disk record whose `type_version` is
442    /// older than the reader's `Document::VERSION`, but the reader's
443    /// `Document::historical_schemas()` has no entry for that version
444    /// (M10 issue #82 introduces the registry; M10 issue #83 wires
445    /// this error). The codec refuses to invent a `Dynamic` view —
446    /// silent fallback hides schema-evolution bugs.
447    #[error("schema not registered for collection '{collection}': stored type_version v{version}")]
448    SchemaNotRegistered {
449        /// The collection whose record could not be migrated.
450        collection: &'static str,
451        /// The stored `type_version` for which no schema was found.
452        version: u32,
453    },
454
455    /// The postcard byte-stream walker driven by
456    /// [`Dynamic::from_postcard_bytes`](crate::codec::Dynamic::from_postcard_bytes)
457    /// exceeded the depth bound
458    /// [`MAX_SCHEMA_DEPTH`](crate::codec::schema::MAX_SCHEMA_DEPTH).
459    /// Power-of-ten Rule 1: the walker uses an explicit stack, but
460    /// the stack depth is itself bounded to avoid a pathological
461    /// schema triggering unbounded growth.
462    #[error("schema walker exceeded the {depth}-level depth bound")]
463    SchemaDepthExceeded {
464        /// The bound that was exceeded.
465        depth: usize,
466    },
467
468    /// The postcard byte-stream walker observed a payload that
469    /// disagrees with the supplied schema in a way the schema
470    /// itself cannot recover from (e.g. a `Bool` slot carrying a
471    /// byte other than `0` / `1`, a `String` slot whose bytes are
472    /// not UTF-8, or a `Seq` length that overflows the input). M10
473    /// issue #41.
474    #[error("schema mismatch at path '{path}': expected {expected}, found {found}")]
475    SchemaTypeMismatch {
476        /// The schema-side variant the walker was decoding when the
477        /// mismatch surfaced.
478        expected: &'static str,
479        /// The shape the bytes actually carried (e.g. `"non-utf8"`,
480        /// `"truncated"`, `"non-bool-byte"`).
481        found: &'static str,
482        /// Dotted path naming the schema slot where the mismatch
483        /// surfaced — useful for forensic logging on a real
484        /// migration failure.
485        path: String,
486    },
487
488    /// `Dynamic::remove` (M10 issue #85) was called on a `Dynamic`
489    /// whose root value (or any intermediate path segment) is not a
490    /// [`Map`](crate::codec::Dynamic::Map). Map-only by construction —
491    /// callers needing scalar removal should replace the value
492    /// outright via `Dynamic::set`.
493    #[error("Dynamic::remove on a non-Map at path '{path}'")]
494    DynamicPathNotMap {
495        /// The dotted path that resolved to a non-Map value.
496        path: String,
497    },
498
499    /// `Db::backup_to` (M11 #92) was called with a destination path
500    /// that already exists. The backup never overwrites existing
501    /// files — the operator must remove the destination explicitly
502    /// or pick a fresh path.
503    #[error("backup destination already exists: {path}")]
504    BackupDestinationExists {
505        /// The destination path the call refused to write.
506        path: std::path::PathBuf,
507    },
508
509    /// `Db::backup_to` (M11 #92) was called on an in-memory database.
510    /// Memory pagers have no file backend; serialising the in-memory
511    /// state to a fresh `.obj` file is deferred to a future minor.
512    #[error("backup_to is not supported for in-memory pagers")]
513    BackupNotSupportedForMemoryPager,
514
515    /// `Db::attach` (M11 #93) was called with a namespace already
516    /// registered on the calling `Db`. Detach the existing
517    /// attachment first or pick a different namespace.
518    #[error("attachment namespace '{namespace}' is already in use")]
519    AttachmentAlreadyExists {
520        /// The namespace the caller tried to register.
521        namespace: String,
522    },
523
524    /// `Db::attach` (M11 #93) was unable to open the attached file.
525    /// The wrapped error carries the underlying cause (typically
526    /// [`Error::Io`] or [`Error::Corruption`]).
527    #[error("attachment at {path} could not be opened: {source}")]
528    AttachmentNotReadable {
529        /// The path the caller tried to attach.
530        path: std::path::PathBuf,
531        /// Underlying open failure.
532        #[source]
533        source: Box<Error>,
534    },
535
536    /// `Db::insert` / `Db::update` / `Db::delete` / `Db::upsert` (and
537    /// the `WriteTxn::collection<T>()` equivalents) refused to
538    /// mutate a collection whose name resolves to an attached
539    /// database. Attached databases are read-only through the
540    /// calling `Db` (M11 #93).
541    #[error(
542        "collection '{collection}' lives in attached database '{namespace}' \
543         which is read-only"
544    )]
545    AttachedDatabaseIsReadOnly {
546        /// Namespace prefix that resolved to an attached db.
547        namespace: String,
548        /// The unqualified collection name within the attached db.
549        collection: String,
550    },
551
552    /// A namespaced collection name was opened against a calling
553    /// `Db` that has no attachment registered under the namespace.
554    #[error("collection namespace '{namespace}' is not attached")]
555    CollectionNamespaceUnknown {
556        /// The namespace prefix that did not resolve.
557        namespace: String,
558    },
559}
560
561/// Lock category for [`Error::Busy`]. Three variants because the
562/// three categories of contention produce different operator
563/// guidance: a contended cross-process `WRITER_LOCK` means another
564/// process is writing; a contended `WriterInProcess` means another
565/// thread of the same process is writing; a contended reader lock
566/// is unusual (31 slots, shared) and indicates either a saturated
567/// 31+-process workload or a stale lock left by a frozen process.
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub enum LockKind {
570    /// Cross-process `WRITER_LOCK` at byte 96.
571    Writer,
572    /// Cross-process `READER_LOCK` byte (any slot in 97..128).
573    Reader,
574    /// In-process write mutex (a sibling thread is mid-write).
575    WriterInProcess,
576}
577
578/// Crate-local `Result` alias. Use this in new code unless an explicit
579/// `std::result::Result` is required for trait-impl reasons.
580pub type Result<T> = std::result::Result<T, Error>;