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>;