Skip to main content

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;