lsm_tree/error.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5use crate::{Checksum, CompressionType, SeqNo};
6#[cfg(not(feature = "std"))]
7use alloc::string::String;
8
9/// The part of a table whose on-disk format [`Error::UnsupportedFormat`]
10/// refers to.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12#[non_exhaustive]
13pub enum FormatPart {
14 /// The `BuRR` filter and retrieval-locator sections, which share one wire
15 /// format and one version stamp.
16 Filter,
17 /// The columnar row groups: a page directory followed by its pages.
18 Columnar,
19}
20
21impl core::fmt::Display for FormatPart {
22 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
23 f.write_str(match self {
24 Self::Filter => "filter and locator sections",
25 Self::Columnar => "columnar row groups",
26 })
27 }
28}
29
30/// Represents errors that can occur in the LSM-tree
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum Error {
34 /// I/O error
35 Io(crate::io::Error),
36
37 /// Decompression failed
38 Decompress(CompressionType),
39
40 /// The store declares an on-disk format version this release does not
41 /// read. The data is not damaged; see the `Display` form for the remedy.
42 InvalidVersion(u8),
43
44 /// A table carries a part in an on-disk format this release does not
45 /// read: its `BuRR` filter and locator sections, or its columnar row
46 /// groups.
47 ///
48 /// 6.0 replaced both outright. The filter's solution matrix is packed to
49 /// `r` bits per row instead of a full 64-bit word per row; a columnar row
50 /// group is a page directory and independently readable pages instead of
51 /// one block. No reader for either old layout ships, deliberately:
52 /// keeping one would put a legacy decode path on the read hot path for as
53 /// long as any cold level went un-rewritten.
54 ///
55 /// The store is intact and its data is not lost — the table has to be
56 /// rewritten by the offline converter before this release can open it.
57 /// Reported when the table is opened rather than from inside the first
58 /// read, so an operator learns what to do instead of seeing a parse fail
59 /// somewhere under a `get`.
60 ///
61 /// **On the converter not being in the tree yet.** The gate and the
62 /// converter are separate deliverables that block the same release, so no
63 /// published build ever refuses a store it has no tool for. Keeping a
64 /// migration-capable reader in the engine until the tool lands was
65 /// considered and rejected: a legacy decode path on the read hot path
66 /// would outlive the migration by however long the coldest level goes
67 /// un-rewritten, which for a bottom level can be indefinitely. The engine
68 /// carries one format; conversion is a one-way offline step.
69 UnsupportedFormat {
70 /// Which part of the table is in the unsupported format.
71 part: FormatPart,
72
73 /// The format the table carries, or `None` when the table predates
74 /// the stamp entirely (which means the original layout).
75 found: Option<u8>,
76
77 /// The only format this release reads.
78 expected: u8,
79 },
80
81 /// Some required files could not be recovered from disk
82 Unrecoverable,
83
84 /// The operation was aborted on a caller's cooperative cancellation
85 /// request ([`RecoveryProgress::request_cancel`]).
86 ///
87 /// A repair cancelled BEFORE its manifest commit left the directory
88 /// untouched (the scan is read-only), so a retry re-derives everything
89 /// from the same bytes; a cancel requested after the commit is ignored
90 /// rather than reported, since the rebuilt manifest is already durable.
91 ///
92 /// [`RecoveryProgress::request_cancel`]: crate::RecoveryProgress::request_cancel
93 Cancelled,
94
95 /// The read resolved into an extent that was physically EXCISED — a
96 /// hole-punched region that reads back as zeros.
97 ///
98 /// Tight-space reclaim punches consumed extents in place, and manifest
99 /// recovery may keep a table whose surviving blocks are intact around
100 /// such a hole rather than discard the whole file. Reading one of the
101 /// punched blocks is a genuine, permanent loss of exactly those rows, and
102 /// it is reported as such instead of being smuggled out as a checksum
103 /// mismatch (which reads as "the bytes rotted") or, worse, as "no such
104 /// key" — the latter would fall through to a superseded version in a
105 /// lower level and silently resurrect it.
106 ///
107 /// This is the engine's equivalent of a filesystem returning `EIO` for a
108 /// bad extent while the rest of the file keeps reading.
109 Excised {
110 /// Byte offset of the excised block within the file.
111 offset: u64,
112 },
113
114 /// Checksum mismatch
115 ChecksumMismatch {
116 /// Checksum of loaded block
117 got: Checksum,
118
119 /// Checksum that was saved in block header
120 expected: Checksum,
121 },
122
123 /// A memtable entry's per-KV digest, computed at insert under
124 /// [`KvChecksumComputePoint::AtInsert`](crate::runtime_config::KvChecksumComputePoint::AtInsert),
125 /// did not match a recompute over the entry's current bytes at flush.
126 ///
127 /// This is the memtable-residence RAM-corruption signal: the entry's
128 /// logical content (`value_type`, `seqno`, key, or value) changed while it
129 /// sat in the memtable, between insert and flush. Distinct from
130 /// [`Self::ChecksumMismatch`] (on-disk block bytes) — this catches a flip
131 /// that happens entirely in RAM, before any block is written.
132 MemtableKvChecksumMismatch {
133 /// Sequence number of the entry whose digest diverged (locates it).
134 seqno: u64,
135
136 /// Digest recomputed over the entry's current memtable bytes at flush.
137 got: u64,
138
139 /// Digest computed and stored when the entry was inserted.
140 expected: u64,
141 },
142
143 /// A memtable entry carried an insert-time per-KV digest
144 /// ([`KvChecksumComputePoint::AtInsert`](crate::runtime_config::KvChecksumComputePoint::AtInsert))
145 /// tagged with an algorithm `AtInsert` never stores: a non-4-byte or
146 /// unknown algorithm wire tag.
147 ///
148 /// `AtInsert` only ever writes a 4-byte algorithm tag (`Xxh3Low32` /
149 /// `Crc32c`), so a digest-bearing node tagged otherwise means the node's
150 /// algorithm metadata was corrupted in RAM during memtable residence.
151 /// Distinct from [`Self::MemtableKvChecksumMismatch`] (the digest value
152 /// diverged): here the algorithm itself is unusable, so the engine refuses
153 /// to "verify" the entry under the wrong algorithm rather than risk a
154 /// flipped tag passing the residence check.
155 MemtableKvChecksumCorruptAlgorithm {
156 /// Sequence number of the entry whose algorithm tag is invalid.
157 seqno: u64,
158
159 /// The invalid algorithm wire tag read from the node.
160 tag: u8,
161 },
162
163 /// A repair COMMITTED its rebuilt manifest, but a later step failed: the
164 /// repair's own post-commit cleanup, or the follow-up open inside
165 /// [`Config::open_or_repair`](crate::Config::open_or_repair).
166 ///
167 /// The report travels WITH the error, so an external-WAL consumer learns
168 /// its replay obligation
169 /// ([`RepairReport::wal_replay_scope`](crate::RepairReport::wal_replay_scope))
170 /// whatever the retry finds. Consume the report exactly as a successful
171 /// repair's, then retry the open (`cause` is typically transient). The
172 /// snapshot keeps owing the report until it is acknowledged, so the retry
173 /// hands it out again with the same
174 /// [`RepairReport::id`](crate::RepairReport::id): skip one already applied.
175 #[cfg(feature = "std")]
176 RepairedButUnopened {
177 /// The completed repair's report — not rederivable on a retry.
178 report: alloc::boxed::Box<crate::RepairReport>,
179
180 /// The follow-up open's failure.
181 cause: alloc::boxed::Box<Self>,
182 },
183
184 /// The on-disk tree's type does not match the type the configuration
185 /// requests: a standard open (no `kv_separation_opts`) of a KV-separated
186 /// (blob) tree, or the reverse.
187 ///
188 /// This is a CONFIGURATION error — fix the options and reopen — not
189 /// damage, so [`Config::open_or_repair`](crate::Config::open_or_repair)
190 /// propagates it instead of repairing: a rebuild under the mismatched
191 /// type would commit a manifest of the wrong tree shape (a `Standard`
192 /// rebuild of a blob tree strands every blob file behind SSTs full of
193 /// indirections, and the orphan sweep then deletes them).
194 TreeTypeMismatch {
195 /// The type the configuration requested.
196 requested: crate::TreeType,
197
198 /// The type the on-disk tree actually has.
199 actual: crate::TreeType,
200 },
201
202 /// Blob frame header CRC mismatch (V4 format).
203 /// Distinct from `ChecksumMismatch` which covers data payload checksums.
204 HeaderCrcMismatch {
205 /// CRC recomputed from header fields
206 recomputed: u32,
207
208 /// CRC stored in the blob frame header
209 stored: u32,
210 },
211
212 /// Invalid enum tag
213 InvalidTag((&'static str, u8)),
214
215 /// Invalid block trailer
216 InvalidTrailer,
217
218 /// Invalid block header
219 InvalidHeader(&'static str),
220
221 /// Data size (decompressed, on-disk, or requested) is invalid or exceeds a safety limit
222 DecompressedSizeTooLarge {
223 /// Size associated with the data being processed. This may come from
224 /// on-disk/in-memory metadata (e.g., header, block/value handle) or be
225 /// derived from caller input (e.g., a requested key or value length),
226 /// and may be zero, invalid, or over the configured limit.
227 declared: u64,
228
229 /// Maximum allowed size for the data or request being processed
230 limit: u64,
231 },
232
233 /// UTF-8 error
234 Utf8(core::str::Utf8Error),
235
236 /// Merge operator failed.
237 ///
238 /// No context payload — consistent with other unit variants
239 /// (`Unrecoverable`, `InvalidTrailer`). Operators should log
240 /// details before returning this error.
241 MergeOperator,
242
243 /// Encryption failed
244 Encrypt(&'static str),
245
246 /// Decryption failed
247 Decrypt(&'static str),
248
249 /// Comparator mismatch on tree reopen.
250 ///
251 /// The tree was created with a comparator whose [`crate::UserComparator::name`]
252 /// differs from the one supplied at reopen time.
253 ComparatorMismatch {
254 /// Comparator name persisted in the tree metadata.
255 stored: String,
256
257 /// Comparator name supplied by the caller.
258 supplied: &'static str,
259 },
260
261 /// Zstd dictionary required but not provided, or `dict_id` mismatch
262 ZstdDictMismatch {
263 /// Dictionary ID stored in the block/table metadata
264 expected: u32,
265
266 /// Dictionary ID provided by the caller (`None` if no dictionary supplied)
267 got: Option<u32>,
268 },
269
270 /// A dictionary the tree stores no longer hashes to the id it is filed
271 /// under, or on an encrypted tree its seal no longer opens: its bytes were
272 /// altered or truncated on disk.
273 ///
274 /// Damage rather than configuration, unlike [`Self::ZstdDictMismatch`]: the
275 /// id is the truncated hash of the content, so the file disagreeing with its
276 /// own name is decided by the bytes alone. A repair moves such a file aside
277 /// (see [`RepairReport::damaged_dictionaries`](crate::RepairReport::damaged_dictionaries)),
278 /// and [`Config::open_or_repair`](crate::Config::open_or_repair) engages one.
279 ZstdDictCorrupt {
280 /// The id the file is filed under.
281 id: u32,
282
283 /// The id its bytes hash to now; `None` when its seal no longer opens,
284 /// so there are no bytes to hash.
285 got: Option<u32>,
286 },
287
288 /// Per-record XXH3-64 mismatch inside a framed manifest section
289 /// (`tables` / `blob_files`). Distinct from
290 /// [`Error::ChecksumMismatch`] — same XXH3 family but a
291 /// different output width (XXH3-64 here vs XXH3-128 for
292 /// block-level payloads) on a different layer of the on-disk
293 /// format. Raised in every recovery mode, so an operator sees the
294 /// exact 64-bit digests that disagreed; the remedy is
295 /// [`Config::repair`](crate::Config::repair).
296 ManifestFrameChecksumMismatch {
297 /// SFA section the corrupt record was found in (e.g.
298 /// `"tables"`, `"blob_files"`). Static so this can be
299 /// compared without parsing message strings.
300 section: &'static str,
301 /// XXH3-64 digest the framing header claimed for the
302 /// record's payload.
303 expected: u64,
304 /// XXH3-64 digest the reader recomputed over the bytes
305 /// actually on disk.
306 got: u64,
307 },
308
309 /// Range tombstone block decode failure.
310 RangeTombstoneDecode {
311 /// Which field or validation failed (e.g. `start_len`, `start`, `seqno`, `interval`)
312 field: &'static str,
313
314 /// Byte offset within the block to the start of the field whose decoding failed
315 /// (captured before reading bytes for that field).
316 offset: u64,
317 },
318
319 /// A [`WriteBatch`](crate::WriteBatch) contains mixed operation types
320 /// (e.g. insert + remove) for the same user key.
321 ///
322 /// Mixed ops at the same logical version are rejected because the
323 /// memtable/skiplist ordering ties on `(user_key, seqno)` and does not
324 /// include `value_type` as a tie-breaker. That would otherwise make
325 /// equal-key entries with different operation types ambiguous to later
326 /// reads and merges, yielding tie-break-dependent "last write wins"
327 /// semantics.
328 MixedOperationBatch,
329
330 /// Tree was opened with `Config::page_ecc(true)` but this build of
331 /// the crate does not have the `page_ecc` cargo feature enabled.
332 /// The reader has no way to verify or recover Reed-Solomon parity
333 /// without the codec, so opening such a tree would silently
334 /// downgrade integrity guarantees — return this error instead.
335 PageEccUnsupported,
336
337 /// Block payload failed the XXH3 integrity check and the
338 /// attached Reed-Solomon parity trailer could not reconstruct
339 /// it (more shards are corrupted than the (4, 2) RS scheme
340 /// can recover). Surfaced ONLY by ECC-protected blocks
341 /// (the `ECC_PARITY` header flag set); a block written without parity
342 /// (`Config::page_ecc(false)`) on a checksum mismatch returns
343 /// [`Self::ChecksumMismatch`] instead, because there's no
344 /// parity to even attempt recovery from.
345 PageEccUnrecoverable {
346 /// XXH3 checksum recomputed from the on-disk bytes.
347 got: Checksum,
348 /// XXH3 checksum stored in the block header.
349 expected: Checksum,
350 },
351
352 /// Route-compatibility mismatch on reopen.
353 ///
354 /// Recovery found fewer tables on disk than the manifest expects, and all
355 /// missing tables are on levels not covered by any current
356 /// [`level_routes`](crate::Config::level_routes). This typically means a
357 /// previously configured route was removed, leaving its directory
358 /// unreachable.
359 ///
360 /// Re-adding the missing route(s) will usually resolve the error. If
361 /// missing tables are on levels that *are* covered by a current route,
362 /// recovery returns [`Unrecoverable`](Self::Unrecoverable) instead
363 /// (the SST files were genuinely lost).
364 RouteMismatch {
365 /// Number of tables listed in the manifest.
366 expected: usize,
367
368 /// Number of tables actually found across all configured routes.
369 found: usize,
370 },
371
372 /// Valid configuration / on-disk layout that this build does not
373 /// yet know how to process, or constructor input that violates a
374 /// documented invariant (e.g. `CompressionType::None` passed to
375 /// [`crate::table::block::CompressionContext::new`]). Distinct
376 /// from [`Error::Unrecoverable`] (signals corruption) and from
377 /// [`Error::Io`] with `ErrorKind::Unsupported` (which can also
378 /// surface from platform / backend limits); the `&'static str`
379 /// payload names the specific marker that triggered the rejection
380 /// (e.g. `"filter_tli"` for a partitioned filter SFA section,
381 /// `"compression-context-none"` for the constructor invariant) so
382 /// the caller can route the diagnostic without parsing message
383 /// strings.
384 FeatureUnsupported(&'static str),
385
386 /// The tree directory is already locked by another live instance.
387 ///
388 /// Returned by [`Config::open`](crate::Config::open) and
389 /// [`Config::repair`](crate::Config::repair) when the cross-process
390 /// directory lock (a `LOCK` file under the tree directory, held via an
391 /// advisory OS file lock) could not be acquired because another process
392 /// owns it. Holds the directory path as a display string for diagnostics.
393 /// Two processes mutating the same manifest would corrupt it, so the second
394 /// acquirer fails fast here. Disable the lock with
395 /// [`Config::with_directory_lock`](crate::Config::with_directory_lock) only
396 /// when exclusivity is enforced at a higher layer.
397 Locked(String),
398
399 /// Manifest footer / TOC / file-level discovery failure.
400 ///
401 /// Scoped to errors detected at or before the TOC is parsed —
402 /// i.e. everything the reader needs *before* it can answer
403 /// "where is section X". Section-content failures (a specific
404 /// section's Block fails verification) go through
405 /// [`ManifestSectionInvalid`](Error::ManifestSectionInvalid)
406 /// instead so callers like `Tree::open` can distinguish a
407 /// totally unreadable manifest from a per-section problem.
408 ///
409 /// Typical causes:
410 ///
411 /// - **Footer-payload structural failure:** unknown layout
412 /// version, oversized section count, empty/oversized section
413 /// name, invalid UTF-8, duplicate section name, footer
414 /// payload exceeds the 4 KiB reservation.
415 /// - **Tail / head-mirror double failure:** both the
416 /// tail-footer Block read and the head-mirror fallback
417 /// failed verification (XXH3 mismatch, AEAD decryption,
418 /// parse error). Per-path causes are logged at `error`
419 /// level and collapsed here.
420 /// - **TOC entry value corruption:** a TOC entry's
421 /// `block_offset + block_size` overflows `u64` or extends
422 /// past the end of the file. The TOC bytes are footer
423 /// payload, so a malformed TOC entry is a footer-level
424 /// issue even though it surfaces in
425 /// `ManifestArchiveReader::read_section`.
426 /// - **Trailing size-hint corruption:** the tail's 4-byte
427 /// footer-size hint is zero or exceeds
428 /// `HEAD_FOOTER_RESERVED_SIZE` (4 KiB), or the implied
429 /// `section_end` lands inside the head reservation.
430 /// - **Writer-side invariant breach:** `write_cursor` would
431 /// overflow `u64`, an in-memory section would exceed the
432 /// on-disk Block-size cap, etc.
433 /// - **No installed snapshot to recover:** recovery found no
434 /// `v{N}` in the tree folder (a missing `edits-{N}` log is
435 /// read as empty), so `Tree::open`'s outer
436 /// `Io(NotFound) => create_new` arm cannot mistake a
437 /// half-applied recovery / corrupted state for a clean
438 /// first-open.
439 /// - **Installed snapshot naming another id:** the snapshot's
440 /// own `version_id` section differs from the id in its name.
441 ManifestFooterInvalid(&'static str),
442
443 /// Manifest section content failed verification or matched no
444 /// TOC entry.
445 ///
446 /// Surfaced by `ManifestArchiveReader::read_section` (and the
447 /// helper that validates the inner Block header before
448 /// delegating to `Block::from_reader`). Distinct from
449 /// [`ManifestFooterInvalid`](Error::ManifestFooterInvalid)
450 /// because the footer / TOC loaded fine — the bad bytes are
451 /// localised to one section Block and a caller MAY route
452 /// recovery differently (e.g. skip the section vs. refuse
453 /// the whole manifest).
454 ///
455 /// Causes:
456 ///
457 /// - **Requested section name not in TOC:** the caller asked
458 /// for a section that the manifest doesn't declare.
459 /// - **Section Block header doesn't fit its outer buffer:**
460 /// the inner block's derived on-disk size (header + payload +
461 /// parity-if-flagged) exceeds the TOC-declared `block_size`.
462 /// Defence-in-depth against a forged TOC pointing at a too-small
463 /// slot.
464 /// - **Block decoded at the TOC offset has the wrong
465 /// `block_type`:** TOC says "section here" but the bytes
466 /// carry a non-`Manifest` Block. Defence-in-depth against
467 /// TOC-redirect attacks; once AAD-binding lands in
468 /// `encryption::block`, `Block::from_reader` will reject
469 /// this internally and the check here becomes belt-and-
470 /// braces.
471 ManifestSectionInvalid(&'static str),
472
473 /// The trailing record of the incremental manifest edit log is
474 /// incomplete or corrupt, and the active
475 /// [`ManifestRecoveryMode`](crate::config::ManifestRecoveryMode) does
476 /// not tolerate that defect, so the open aborts rather than silently
477 /// rolling the edit back.
478 ///
479 /// A clean end-of-log is never reported here: a crash exactly at a
480 /// record boundary is byte-identical to a pristine close, so that
481 /// case is always tolerated. This fires when bytes of a trailing
482 /// record are present but the record fails framing: a
483 /// power-loss-truncated append (rejected by
484 /// [`AbsoluteConsistency`](crate::config::ManifestRecoveryMode::AbsoluteConsistency),
485 /// rolled back by
486 /// [`TolerateCorruptedTailRecords`](crate::config::ManifestRecoveryMode::TolerateCorruptedTailRecords)),
487 /// or a fully-framed record whose checksum doesn't match (bit-rot) /
488 /// whose header is forged, which every mode rejects.
489 ///
490 /// Recover by running [`Config::repair`](crate::Config::repair), which
491 /// rebuilds a clean standalone snapshot from the table files (dropping
492 /// the edit log). A torn tail alone can instead be rolled back by
493 /// re-opening under `TolerateCorruptedTailRecords`.
494 TornManifestEditLog {
495 /// The trailing defect detected: `"truncated"` (partial record
496 /// from a power-loss-interrupted append), `"checksum-mismatch"`
497 /// (fully-framed record whose payload bit-rotted),
498 /// `"bad-header"` (implausible framing length), or
499 /// `"len-mismatch"` (record length disagrees with the expected
500 /// fixed size). Static so callers can branch without parsing
501 /// the message string.
502 kind: &'static str,
503 },
504
505 /// A write was declined by the storage admission gate because accepting it
506 /// could push the tree's live footprint past its effective budget.
507 ///
508 /// Only produced when [`storage_admission_check`](crate::runtime_config::RuntimeConfig::storage_admission_check)
509 /// is enabled. The predicate is computed, not latched: raising
510 /// [`storage_limit_bytes`](crate::runtime_config::RuntimeConfig::storage_limit_bytes),
511 /// freeing disk, or a compaction reclaiming space clears the read-only
512 /// state on the next check with no restart. Internal flush / compaction are
513 /// never gated (reserved headroom), so the engine can always reclaim space.
514 StorageFull {
515 /// Live on-disk bytes at the time of the check.
516 used: u64,
517
518 /// Effective byte budget that `used` (plus reserved headroom) exceeded.
519 limit: u64,
520 },
521
522 /// A read asked for a snapshot at or below the retention floor.
523 ///
524 /// Every snapshot above the floor is served from the current version. An
525 /// install that loses or rewrites what an older snapshot read raises the
526 /// floor past it: a compaction or flush that collected history below the
527 /// caller's GC watermark
528 /// ([`AbstractTree::major_compact`](crate::AbstractTree::major_compact)'s
529 /// `gc_watermark`), a filtering compaction, a table drop,
530 /// [`AbstractTree::clear`](crate::AbstractTree::clear). Serving such a
531 /// snapshot from what survived would silently answer with data it never
532 /// saw, so the read is refused. Snapshot `0` is the exception: it sees no
533 /// entry from any version and is always served (empty). The floor is
534 /// persisted with the manifest, so the refusal is the same on either side
535 /// of a reopen. A reader that resolved its version before the install is
536 /// not affected: it keeps reading that version until it drops it.
537 ///
538 /// Point reads return it directly; iterators yield it as their first and
539 /// only item. [`AbstractTree::oldest_retained_seqno`](crate::AbstractTree::oldest_retained_seqno)
540 /// exposes the boundary so a caller can validate a snapshot before
541 /// reading: a snapshot is servable iff it is `0` or strictly above that
542 /// seqno.
543 SnapshotBelowRetention {
544 /// The snapshot seqno the read asked for.
545 requested: SeqNo,
546
547 /// The retention floor; reads at `oldest_retained + 1` and above are
548 /// servable.
549 oldest_retained: SeqNo,
550 },
551
552 /// [`AbstractTree::lower_retention_floor`](crate::AbstractTree::lower_retention_floor)
553 /// was asked for a floor above the current one. Only GC and the
554 /// installs that drop data raise the floor; the request changed nothing.
555 RetentionFloorRaise {
556 /// The retention floor in force.
557 current: SeqNo,
558
559 /// The floor the call asked for.
560 requested: SeqNo,
561 },
562
563 /// [`AbstractTree::lower_retention_floor`](crate::AbstractTree::lower_retention_floor)
564 /// was asked for a floor above the tree's seqno counter: it would refuse
565 /// snapshots the counter has yet to hand out. The request changed nothing.
566 RetentionFloorAboveSeqno {
567 /// The counter's current seqno.
568 seqno: SeqNo,
569
570 /// The floor the call asked for.
571 requested: SeqNo,
572 },
573
574 /// A projected columnar scan cannot produce a field as its projection
575 /// declares: the field is absent from a row and declared an error, a
576 /// segment stores it under another type, a row value has to be read and
577 /// no projector is set, or the projector wrote a cell that does not fit.
578 ///
579 /// A caller-side mismatch between the projection and the data, not damage.
580 /// The payload names which of these it is.
581 Projection(&'static str),
582
583 /// A blob reference of a row written as cells cannot be used: a
584 /// [`BlobRef`](crate::blob_tree::field_row::BlobRef) read from another
585 /// key or another tree, one object in two cells of the row, a reference in
586 /// an ingested row, or a stale reference, whose object was moved or
587 /// released since it was read; or a reference-aware read finds the key's
588 /// version is not written as cells.
589 ///
590 /// A caller-side error, refused before anything is written. The payload
591 /// names which of these it is; only a stale reference is cured by reading
592 /// the key again.
593 BlobRef(&'static str),
594
595 /// A row written as cells is not one the caller could have meant: two
596 /// fields in one column, or a fixed-width field that is not its type's
597 /// width.
598 ///
599 /// A caller-side error, refused before anything is written. The payload
600 /// names which of these it is.
601 CellRow(&'static str),
602
603 /// A salvage could not write, finish or make durable its OUTPUT: the
604 /// failure lies with the destination, not with the bytes of the file
605 /// being salvaged.
606 ///
607 /// A recovery that met it stops rather than grade the SOURCE unreadable:
608 /// that would leave intact data out of the rebuilt manifest, and the
609 /// cleanup after the commit would remove it. Fix the destination
610 /// (its space, its device) and retry.
611 SalvageOutput(alloc::boxed::Box<Self>),
612
613 /// A manifest install failed after its edit or snapshot was written, so
614 /// a power loss may still make it the installed one: what it names is
615 /// kept on disk, and the next install writes a full snapshot over it.
616 ///
617 /// The operation that asked for the install (a flush, a compaction)
618 /// reports it as failed and may be retried; the tree reopened after a
619 /// crash holds either the state before it or the state it installed,
620 /// whole. The payload is the failure itself.
621 ///
622 /// From a repair, the report rides in the snapshot it describes: when that
623 /// snapshot turns out installed,
624 /// [`Config::open_or_repair`](crate::Config::open_or_repair) hands the
625 /// report out; when it does not, its own repair answers with a fresh one.
626 InstallOutcomeUnknown(alloc::boxed::Box<Self>),
627}
628
629impl core::fmt::Display for Error {
630 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
631 // The format refusals get prose rather than their Debug form: their
632 // remedy lies outside this process (a separate tool, or another
633 // release), and an operator who sees one needs to be told that rather
634 // than left to infer it from a struct dump. Everything else stays
635 // `{self:?}`.
636 if let Self::InvalidVersion(found) = self {
637 let current = u8::from(crate::FormatVersion::V6);
638 write!(
639 f,
640 "LsmTreeError: this store is in on-disk format {found}, but this \
641 release reads only format {current}. ",
642 )?;
643 // Only the format immediately before the current one has a
644 // converter; older and newer stores are read by the release that
645 // wrote them.
646 return if found.checked_add(1) == Some(current) {
647 write!(
648 f,
649 "The data is intact: run the offline converter over the store, \
650 then open it again.",
651 )
652 } else {
653 write!(f, "Open it with the release that wrote it.")
654 };
655 }
656 if let Self::UnsupportedFormat {
657 part,
658 found,
659 expected,
660 } = self
661 {
662 write!(f, "LsmTreeError: this store's {part} are in ")?;
663 match found {
664 Some(v) => write!(f, "format {v}")?,
665 None => write!(f, "the original unstamped format")?,
666 }
667 return write!(
668 f,
669 ", but this release reads only format {expected}. The data is \
670 intact: run the offline converter over the store to rewrite \
671 its {part}, then open it again.",
672 );
673 }
674 write!(f, "LsmTreeError: {self:?}")
675 }
676}
677
678impl core::error::Error for Error {
679 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
680 match self {
681 Self::Io(e) => Some(e),
682 // The variant is explicitly a causal wrapper: generic error-chain
683 // logging and transient-retry classifiers must be able to reach
684 // the post-commit step's own failure through the standard chain.
685 #[cfg(feature = "std")]
686 Self::RepairedButUnopened { cause, .. } => Some(&**cause),
687 Self::SalvageOutput(cause) | Self::InstallOutcomeUnknown(cause) => Some(&**cause),
688 _ => None,
689 }
690 }
691}
692
693impl From<crate::sfa::Error> for Error {
694 fn from(value: crate::sfa::Error) -> Self {
695 match value {
696 crate::sfa::Error::Io(e) => Self::from(e),
697 crate::sfa::Error::ChecksumMismatch { got, expected } => {
698 log::error!("Archive ToC checksum mismatch");
699 Self::ChecksumMismatch {
700 got: got.into(),
701 expected: expected.into(),
702 }
703 }
704 crate::sfa::Error::InvalidHeader => {
705 log::error!("Invalid archive header");
706 Self::Unrecoverable
707 }
708 crate::sfa::Error::InvalidVersion => {
709 log::error!("Invalid archive version");
710 Self::Unrecoverable
711 }
712 crate::sfa::Error::UnsupportedChecksumType => {
713 log::error!("Invalid archive checksum type");
714 Self::Unrecoverable
715 }
716 }
717 }
718}
719
720impl Error {
721 /// Whether this failure says something about the ENVIRONMENT or the
722 /// CALLER's configuration rather than about the bytes on disk — the class
723 /// every recovery path must PROPAGATE instead of recording as damage.
724 ///
725 /// Recording one of these as damage commits a manifest that omits the file
726 /// and then removes it, turning a fixable mistake into permanent loss;
727 /// propagating lets the operator fix the environment (or supply the right
728 /// key / dictionary) and re-run with everything still on disk.
729 ///
730 /// - [`Self::Io`] of an environmental kind: the interrupted-syscall
731 /// retryables, plus access failures that do not implicate the bytes
732 /// (`PermissionDenied`, `StorageFull`, `QuotaExceeded`,
733 /// `ReadOnlyFilesystem`, `OutOfMemory`).
734 /// - [`Self::Decrypt`]: an AEAD failure is exactly what a missing or wrong
735 /// key produces on perfectly healthy ciphertext.
736 /// - [`Self::ZstdDictMismatch`]: the persisted descriptor names a
737 /// dictionary the caller did not supply, or supplied a different one.
738 /// - [`Self::UnsupportedFormat`]: the persisted descriptor names a format
739 /// this binary does not read. Exactly the same shape as the dictionary
740 /// mismatch — healthy bytes, wrong environment — and the classification
741 /// matters most for repair, which grades a table it cannot recover as
742 /// damaged and leaves it out of the rebuilt manifest. Without this arm,
743 /// repairing a legacy store would DELETE every filtered or columnar
744 /// table in it over an error whose whole meaning is that the data is
745 /// intact and awaiting conversion.
746 ///
747 /// A failure that DOES implicate the bytes (a bad sector, a structural
748 /// decode failure) is not in this class: a retry cannot fix it, and the
749 /// recovery paths grade that file instead of aborting over it.
750 #[must_use]
751 pub(crate) fn is_environmental(&self) -> bool {
752 match self {
753 Self::Io(io) => io.kind().is_environmental(),
754 Self::Decrypt(_) => true,
755 #[cfg(zstd_any)]
756 Self::ZstdDictMismatch { .. } => true,
757 Self::UnsupportedFormat { .. } => true,
758 // The destination failed, whatever its kind: the source's bytes
759 // are not implicated.
760 Self::SalvageOutput(_) => true,
761 Self::InstallOutcomeUnknown(cause) => cause.is_environmental(),
762 _ => false,
763 }
764 }
765}
766
767// The `Io` variant carries `crate::io::Error` (the no_std-capable I/O error),
768// so this bridge is a direct wrap. Std file-I/O paths surface `std::io::Error`;
769// the std-gated bridge below folds those through `crate::io::Error`.
770impl From<crate::io::Error> for Error {
771 fn from(value: crate::io::Error) -> Self {
772 Self::Io(value)
773 }
774}
775
776#[cfg(feature = "std")]
777impl From<std::io::Error> for Error {
778 fn from(value: std::io::Error) -> Self {
779 Self::Io(value.into())
780 }
781}
782
783/// Tree result
784pub type Result<T> = core::result::Result<T, Error>;
785
786#[cfg(test)]
787mod tests;