coordinode-lsm-tree 7.5.0

Embedded LSM-tree storage engine in pure Rust, no C/C++ dependency. MVCC snapshots, BuRR filters, zstd dictionary compression, columnar PAX blocks, AES-256-GCM at rest, self-healing per-block ECC, compaction on a near-full disk, no_std support.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-present, fjall-rs
// Copyright (c) 2026-present, Dmitry Prudnikov

use crate::{Checksum, CompressionType, SeqNo};
#[cfg(not(feature = "std"))]
use alloc::string::String;

/// The part of a table whose on-disk format [`Error::UnsupportedFormat`]
/// refers to.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum FormatPart {
    /// The `BuRR` filter and retrieval-locator sections, which share one wire
    /// format and one version stamp.
    Filter,
    /// The columnar row groups: a page directory followed by its pages.
    Columnar,
}

impl core::fmt::Display for FormatPart {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(match self {
            Self::Filter => "filter and locator sections",
            Self::Columnar => "columnar row groups",
        })
    }
}

/// Represents errors that can occur in the LSM-tree
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
    /// I/O error
    Io(crate::io::Error),

    /// Decompression failed
    Decompress(CompressionType),

    /// The store declares an on-disk format version this release does not
    /// read. The data is not damaged; see the `Display` form for the remedy.
    InvalidVersion(u8),

    /// A table carries a part in an on-disk format this release does not
    /// read: its `BuRR` filter and locator sections, or its columnar row
    /// groups.
    ///
    /// 6.0 replaced both outright. The filter's solution matrix is packed to
    /// `r` bits per row instead of a full 64-bit word per row; a columnar row
    /// group is a page directory and independently readable pages instead of
    /// one block. No reader for either old layout ships, deliberately:
    /// keeping one would put a legacy decode path on the read hot path for as
    /// long as any cold level went un-rewritten.
    ///
    /// The store is intact and its data is not lost — the table has to be
    /// rewritten by the offline converter before this release can open it.
    /// Reported when the table is opened rather than from inside the first
    /// read, so an operator learns what to do instead of seeing a parse fail
    /// somewhere under a `get`.
    ///
    /// **On the converter not being in the tree yet.** The gate and the
    /// converter are separate deliverables that block the same release, so no
    /// published build ever refuses a store it has no tool for. Keeping a
    /// migration-capable reader in the engine until the tool lands was
    /// considered and rejected: a legacy decode path on the read hot path
    /// would outlive the migration by however long the coldest level goes
    /// un-rewritten, which for a bottom level can be indefinitely. The engine
    /// carries one format; conversion is a one-way offline step.
    UnsupportedFormat {
        /// Which part of the table is in the unsupported format.
        part: FormatPart,

        /// The format the table carries, or `None` when the table predates
        /// the stamp entirely (which means the original layout).
        found: Option<u8>,

        /// The only format this release reads.
        expected: u8,
    },

    /// Some required files could not be recovered from disk
    Unrecoverable,

    /// The operation was aborted on a caller's cooperative cancellation
    /// request ([`RecoveryProgress::request_cancel`]).
    ///
    /// A repair cancelled BEFORE its manifest commit left the directory
    /// untouched (the scan is read-only), so a retry re-derives everything
    /// from the same bytes; a cancel requested after the commit is ignored
    /// rather than reported, since the rebuilt manifest is already durable.
    ///
    /// [`RecoveryProgress::request_cancel`]: crate::RecoveryProgress::request_cancel
    Cancelled,

    /// The read resolved into an extent that was physically EXCISED — a
    /// hole-punched region that reads back as zeros.
    ///
    /// Tight-space reclaim punches consumed extents in place, and manifest
    /// recovery may keep a table whose surviving blocks are intact around
    /// such a hole rather than discard the whole file. Reading one of the
    /// punched blocks is a genuine, permanent loss of exactly those rows, and
    /// it is reported as such instead of being smuggled out as a checksum
    /// mismatch (which reads as "the bytes rotted") or, worse, as "no such
    /// key" — the latter would fall through to a superseded version in a
    /// lower level and silently resurrect it.
    ///
    /// This is the engine's equivalent of a filesystem returning `EIO` for a
    /// bad extent while the rest of the file keeps reading.
    Excised {
        /// Byte offset of the excised block within the file.
        offset: u64,
    },

    /// Checksum mismatch
    ChecksumMismatch {
        /// Checksum of loaded block
        got: Checksum,

        /// Checksum that was saved in block header
        expected: Checksum,
    },

    /// A memtable entry's per-KV digest, computed at insert under
    /// [`KvChecksumComputePoint::AtInsert`](crate::runtime_config::KvChecksumComputePoint::AtInsert),
    /// did not match a recompute over the entry's current bytes at flush.
    ///
    /// This is the memtable-residence RAM-corruption signal: the entry's
    /// logical content (`value_type`, `seqno`, key, or value) changed while it
    /// sat in the memtable, between insert and flush. Distinct from
    /// [`Self::ChecksumMismatch`] (on-disk block bytes) — this catches a flip
    /// that happens entirely in RAM, before any block is written.
    MemtableKvChecksumMismatch {
        /// Sequence number of the entry whose digest diverged (locates it).
        seqno: u64,

        /// Digest recomputed over the entry's current memtable bytes at flush.
        got: u64,

        /// Digest computed and stored when the entry was inserted.
        expected: u64,
    },

    /// A memtable entry carried an insert-time per-KV digest
    /// ([`KvChecksumComputePoint::AtInsert`](crate::runtime_config::KvChecksumComputePoint::AtInsert))
    /// tagged with an algorithm `AtInsert` never stores: a non-4-byte or
    /// unknown algorithm wire tag.
    ///
    /// `AtInsert` only ever writes a 4-byte algorithm tag (`Xxh3Low32` /
    /// `Crc32c`), so a digest-bearing node tagged otherwise means the node's
    /// algorithm metadata was corrupted in RAM during memtable residence.
    /// Distinct from [`Self::MemtableKvChecksumMismatch`] (the digest value
    /// diverged): here the algorithm itself is unusable, so the engine refuses
    /// to "verify" the entry under the wrong algorithm rather than risk a
    /// flipped tag passing the residence check.
    MemtableKvChecksumCorruptAlgorithm {
        /// Sequence number of the entry whose algorithm tag is invalid.
        seqno: u64,

        /// The invalid algorithm wire tag read from the node.
        tag: u8,
    },

    /// A repair COMMITTED its rebuilt manifest, but a later step failed: the
    /// repair's own post-commit cleanup, or the follow-up open inside
    /// [`Config::open_or_repair`](crate::Config::open_or_repair).
    ///
    /// The report travels WITH the error, so an external-WAL consumer learns
    /// its replay obligation
    /// ([`RepairReport::wal_replay_scope`](crate::RepairReport::wal_replay_scope))
    /// whatever the retry finds. Consume the report exactly as a successful
    /// repair's, then retry the open (`cause` is typically transient). The
    /// snapshot keeps owing the report until it is acknowledged, so the retry
    /// hands it out again with the same
    /// [`RepairReport::id`](crate::RepairReport::id): skip one already applied.
    #[cfg(feature = "std")]
    RepairedButUnopened {
        /// The completed repair's report — not rederivable on a retry.
        report: alloc::boxed::Box<crate::RepairReport>,

        /// The follow-up open's failure.
        cause: alloc::boxed::Box<Self>,
    },

    /// The on-disk tree's type does not match the type the configuration
    /// requests: a standard open (no `kv_separation_opts`) of a KV-separated
    /// (blob) tree, or the reverse.
    ///
    /// This is a CONFIGURATION error — fix the options and reopen — not
    /// damage, so [`Config::open_or_repair`](crate::Config::open_or_repair)
    /// propagates it instead of repairing: a rebuild under the mismatched
    /// type would commit a manifest of the wrong tree shape (a `Standard`
    /// rebuild of a blob tree strands every blob file behind SSTs full of
    /// indirections, and the orphan sweep then deletes them).
    TreeTypeMismatch {
        /// The type the configuration requested.
        requested: crate::TreeType,

        /// The type the on-disk tree actually has.
        actual: crate::TreeType,
    },

    /// Blob frame header CRC mismatch (V4 format).
    /// Distinct from `ChecksumMismatch` which covers data payload checksums.
    HeaderCrcMismatch {
        /// CRC recomputed from header fields
        recomputed: u32,

        /// CRC stored in the blob frame header
        stored: u32,
    },

    /// Invalid enum tag
    InvalidTag((&'static str, u8)),

    /// Invalid block trailer
    InvalidTrailer,

    /// Invalid block header
    InvalidHeader(&'static str),

    /// Data size (decompressed, on-disk, or requested) is invalid or exceeds a safety limit
    DecompressedSizeTooLarge {
        /// Size associated with the data being processed. This may come from
        /// on-disk/in-memory metadata (e.g., header, block/value handle) or be
        /// derived from caller input (e.g., a requested key or value length),
        /// and may be zero, invalid, or over the configured limit.
        declared: u64,

        /// Maximum allowed size for the data or request being processed
        limit: u64,
    },

    /// UTF-8 error
    Utf8(core::str::Utf8Error),

    /// Merge operator failed.
    ///
    /// No context payload — consistent with other unit variants
    /// (`Unrecoverable`, `InvalidTrailer`). Operators should log
    /// details before returning this error.
    MergeOperator,

    /// Encryption failed
    Encrypt(&'static str),

    /// Decryption failed
    Decrypt(&'static str),

    /// Comparator mismatch on tree reopen.
    ///
    /// The tree was created with a comparator whose [`crate::UserComparator::name`]
    /// differs from the one supplied at reopen time.
    ComparatorMismatch {
        /// Comparator name persisted in the tree metadata.
        stored: String,

        /// Comparator name supplied by the caller.
        supplied: &'static str,
    },

    /// Zstd dictionary required but not provided, or `dict_id` mismatch
    ZstdDictMismatch {
        /// Dictionary ID stored in the block/table metadata
        expected: u32,

        /// Dictionary ID provided by the caller (`None` if no dictionary supplied)
        got: Option<u32>,
    },

    /// A dictionary the tree stores no longer hashes to the id it is filed
    /// under, or on an encrypted tree its seal no longer opens: its bytes were
    /// altered or truncated on disk.
    ///
    /// Damage rather than configuration, unlike [`Self::ZstdDictMismatch`]: the
    /// id is the truncated hash of the content, so the file disagreeing with its
    /// own name is decided by the bytes alone. A repair moves such a file aside
    /// (see [`RepairReport::damaged_dictionaries`](crate::RepairReport::damaged_dictionaries)),
    /// and [`Config::open_or_repair`](crate::Config::open_or_repair) engages one.
    ZstdDictCorrupt {
        /// The id the file is filed under.
        id: u32,

        /// The id its bytes hash to now; `None` when its seal no longer opens,
        /// so there are no bytes to hash.
        got: Option<u32>,
    },

    /// Per-record XXH3-64 mismatch inside a framed manifest section
    /// (`tables` / `blob_files`). Distinct from
    /// [`Error::ChecksumMismatch`] — same XXH3 family but a
    /// different output width (XXH3-64 here vs XXH3-128 for
    /// block-level payloads) on a different layer of the on-disk
    /// format. Raised in every recovery mode, so an operator sees the
    /// exact 64-bit digests that disagreed; the remedy is
    /// [`Config::repair`](crate::Config::repair).
    ManifestFrameChecksumMismatch {
        /// SFA section the corrupt record was found in (e.g.
        /// `"tables"`, `"blob_files"`). Static so this can be
        /// compared without parsing message strings.
        section: &'static str,
        /// XXH3-64 digest the framing header claimed for the
        /// record's payload.
        expected: u64,
        /// XXH3-64 digest the reader recomputed over the bytes
        /// actually on disk.
        got: u64,
    },

    /// Range tombstone block decode failure.
    RangeTombstoneDecode {
        /// Which field or validation failed (e.g. `start_len`, `start`, `seqno`, `interval`)
        field: &'static str,

        /// Byte offset within the block to the start of the field whose decoding failed
        /// (captured before reading bytes for that field).
        offset: u64,
    },

    /// A [`WriteBatch`](crate::WriteBatch) contains mixed operation types
    /// (e.g. insert + remove) for the same user key.
    ///
    /// Mixed ops at the same logical version are rejected because the
    /// memtable/skiplist ordering ties on `(user_key, seqno)` and does not
    /// include `value_type` as a tie-breaker. That would otherwise make
    /// equal-key entries with different operation types ambiguous to later
    /// reads and merges, yielding tie-break-dependent "last write wins"
    /// semantics.
    MixedOperationBatch,

    /// Tree was opened with `Config::page_ecc(true)` but this build of
    /// the crate does not have the `page_ecc` cargo feature enabled.
    /// The reader has no way to verify or recover Reed-Solomon parity
    /// without the codec, so opening such a tree would silently
    /// downgrade integrity guarantees — return this error instead.
    PageEccUnsupported,

    /// Block payload failed the XXH3 integrity check and the
    /// attached Reed-Solomon parity trailer could not reconstruct
    /// it (more shards are corrupted than the (4, 2) RS scheme
    /// can recover). Surfaced ONLY by ECC-protected blocks
    /// (the `ECC_PARITY` header flag set); a block written without parity
    /// (`Config::page_ecc(false)`) on a checksum mismatch returns
    /// [`Self::ChecksumMismatch`] instead, because there's no
    /// parity to even attempt recovery from.
    PageEccUnrecoverable {
        /// XXH3 checksum recomputed from the on-disk bytes.
        got: Checksum,
        /// XXH3 checksum stored in the block header.
        expected: Checksum,
    },

    /// Route-compatibility mismatch on reopen.
    ///
    /// Recovery found fewer tables on disk than the manifest expects, and all
    /// missing tables are on levels not covered by any current
    /// [`level_routes`](crate::Config::level_routes).  This typically means a
    /// previously configured route was removed, leaving its directory
    /// unreachable.
    ///
    /// Re-adding the missing route(s) will usually resolve the error.  If
    /// missing tables are on levels that *are* covered by a current route,
    /// recovery returns [`Unrecoverable`](Self::Unrecoverable) instead
    /// (the SST files were genuinely lost).
    RouteMismatch {
        /// Number of tables listed in the manifest.
        expected: usize,

        /// Number of tables actually found across all configured routes.
        found: usize,
    },

    /// Valid configuration / on-disk layout that this build does not
    /// yet know how to process, or constructor input that violates a
    /// documented invariant (e.g. `CompressionType::None` passed to
    /// [`crate::table::block::CompressionContext::new`]). Distinct
    /// from [`Error::Unrecoverable`] (signals corruption) and from
    /// [`Error::Io`] with `ErrorKind::Unsupported` (which can also
    /// surface from platform / backend limits); the `&'static str`
    /// payload names the specific marker that triggered the rejection
    /// (e.g. `"filter_tli"` for a partitioned filter SFA section,
    /// `"compression-context-none"` for the constructor invariant) so
    /// the caller can route the diagnostic without parsing message
    /// strings.
    FeatureUnsupported(&'static str),

    /// The tree directory is already locked by another live instance.
    ///
    /// Returned by [`Config::open`](crate::Config::open) and
    /// [`Config::repair`](crate::Config::repair) when the cross-process
    /// directory lock (a `LOCK` file under the tree directory, held via an
    /// advisory OS file lock) could not be acquired because another process
    /// owns it. Holds the directory path as a display string for diagnostics.
    /// Two processes mutating the same manifest would corrupt it, so the second
    /// acquirer fails fast here. Disable the lock with
    /// [`Config::with_directory_lock`](crate::Config::with_directory_lock) only
    /// when exclusivity is enforced at a higher layer.
    Locked(String),

    /// Manifest footer / TOC / file-level discovery failure.
    ///
    /// Scoped to errors detected at or before the TOC is parsed —
    /// i.e. everything the reader needs *before* it can answer
    /// "where is section X". Section-content failures (a specific
    /// section's Block fails verification) go through
    /// [`ManifestSectionInvalid`](Error::ManifestSectionInvalid)
    /// instead so callers like `Tree::open` can distinguish a
    /// totally unreadable manifest from a per-section problem.
    ///
    /// Typical causes:
    ///
    /// - **Footer-payload structural failure:** unknown layout
    ///   version, oversized section count, empty/oversized section
    ///   name, invalid UTF-8, duplicate section name, footer
    ///   payload exceeds the 4 KiB reservation.
    /// - **Tail / head-mirror double failure:** both the
    ///   tail-footer Block read and the head-mirror fallback
    ///   failed verification (XXH3 mismatch, AEAD decryption,
    ///   parse error). Per-path causes are logged at `error`
    ///   level and collapsed here.
    /// - **TOC entry value corruption:** a TOC entry's
    ///   `block_offset + block_size` overflows `u64` or extends
    ///   past the end of the file. The TOC bytes are footer
    ///   payload, so a malformed TOC entry is a footer-level
    ///   issue even though it surfaces in
    ///   `ManifestArchiveReader::read_section`.
    /// - **Trailing size-hint corruption:** the tail's 4-byte
    ///   footer-size hint is zero or exceeds
    ///   `HEAD_FOOTER_RESERVED_SIZE` (4 KiB), or the implied
    ///   `section_end` lands inside the head reservation.
    /// - **Writer-side invariant breach:** `write_cursor` would
    ///   overflow `u64`, an in-memory section would exceed the
    ///   on-disk Block-size cap, etc.
    /// - **No installed snapshot to recover:** recovery found no
    ///   `v{N}` in the tree folder (a missing `edits-{N}` log is
    ///   read as empty), so `Tree::open`'s outer
    ///   `Io(NotFound) => create_new` arm cannot mistake a
    ///   half-applied recovery / corrupted state for a clean
    ///   first-open.
    /// - **Installed snapshot naming another id:** the snapshot's
    ///   own `version_id` section differs from the id in its name.
    ManifestFooterInvalid(&'static str),

    /// Manifest section content failed verification or matched no
    /// TOC entry.
    ///
    /// Surfaced by `ManifestArchiveReader::read_section` (and the
    /// helper that validates the inner Block header before
    /// delegating to `Block::from_reader`). Distinct from
    /// [`ManifestFooterInvalid`](Error::ManifestFooterInvalid)
    /// because the footer / TOC loaded fine — the bad bytes are
    /// localised to one section Block and a caller MAY route
    /// recovery differently (e.g. skip the section vs. refuse
    /// the whole manifest).
    ///
    /// Causes:
    ///
    /// - **Requested section name not in TOC:** the caller asked
    ///   for a section that the manifest doesn't declare.
    /// - **Section Block header doesn't fit its outer buffer:**
    ///   the inner block's derived on-disk size (header + payload +
    ///   parity-if-flagged) exceeds the TOC-declared `block_size`.
    ///   Defence-in-depth against a forged TOC pointing at a too-small
    ///   slot.
    /// - **Block decoded at the TOC offset has the wrong
    ///   `block_type`:** TOC says "section here" but the bytes
    ///   carry a non-`Manifest` Block. Defence-in-depth against
    ///   TOC-redirect attacks; once AAD-binding lands in
    ///   `encryption::block`, `Block::from_reader` will reject
    ///   this internally and the check here becomes belt-and-
    ///   braces.
    ManifestSectionInvalid(&'static str),

    /// The trailing record of the incremental manifest edit log is
    /// incomplete or corrupt, and the active
    /// [`ManifestRecoveryMode`](crate::config::ManifestRecoveryMode) does
    /// not tolerate that defect, so the open aborts rather than silently
    /// rolling the edit back.
    ///
    /// A clean end-of-log is never reported here: a crash exactly at a
    /// record boundary is byte-identical to a pristine close, so that
    /// case is always tolerated. This fires when bytes of a trailing
    /// record are present but the record fails framing: a
    /// power-loss-truncated append (rejected by
    /// [`AbsoluteConsistency`](crate::config::ManifestRecoveryMode::AbsoluteConsistency),
    /// rolled back by
    /// [`TolerateCorruptedTailRecords`](crate::config::ManifestRecoveryMode::TolerateCorruptedTailRecords)),
    /// or a fully-framed record whose checksum doesn't match (bit-rot) /
    /// whose header is forged, which every mode rejects.
    ///
    /// Recover by running [`Config::repair`](crate::Config::repair), which
    /// rebuilds a clean standalone snapshot from the table files (dropping
    /// the edit log). A torn tail alone can instead be rolled back by
    /// re-opening under `TolerateCorruptedTailRecords`.
    TornManifestEditLog {
        /// The trailing defect detected: `"truncated"` (partial record
        /// from a power-loss-interrupted append), `"checksum-mismatch"`
        /// (fully-framed record whose payload bit-rotted),
        /// `"bad-header"` (implausible framing length), or
        /// `"len-mismatch"` (record length disagrees with the expected
        /// fixed size). Static so callers can branch without parsing
        /// the message string.
        kind: &'static str,
    },

    /// A write was declined by the storage admission gate because accepting it
    /// could push the tree's live footprint past its effective budget.
    ///
    /// Only produced when [`storage_admission_check`](crate::runtime_config::RuntimeConfig::storage_admission_check)
    /// is enabled. The predicate is computed, not latched: raising
    /// [`storage_limit_bytes`](crate::runtime_config::RuntimeConfig::storage_limit_bytes),
    /// freeing disk, or a compaction reclaiming space clears the read-only
    /// state on the next check with no restart. Internal flush / compaction are
    /// never gated (reserved headroom), so the engine can always reclaim space.
    StorageFull {
        /// Live on-disk bytes at the time of the check.
        used: u64,

        /// Effective byte budget that `used` (plus reserved headroom) exceeded.
        limit: u64,
    },

    /// A read asked for a snapshot at or below the retention floor.
    ///
    /// Every snapshot above the floor is served from the current version. An
    /// install that loses or rewrites what an older snapshot read raises the
    /// floor past it: a compaction or flush that collected history below the
    /// caller's GC watermark
    /// ([`AbstractTree::major_compact`](crate::AbstractTree::major_compact)'s
    /// `gc_watermark`), a filtering compaction, a table drop,
    /// [`AbstractTree::clear`](crate::AbstractTree::clear). Serving such a
    /// snapshot from what survived would silently answer with data it never
    /// saw, so the read is refused. Snapshot `0` is the exception: it sees no
    /// entry from any version and is always served (empty). The floor is
    /// persisted with the manifest, so the refusal is the same on either side
    /// of a reopen. A reader that resolved its version before the install is
    /// not affected: it keeps reading that version until it drops it.
    ///
    /// Point reads return it directly; iterators yield it as their first and
    /// only item. [`AbstractTree::oldest_retained_seqno`](crate::AbstractTree::oldest_retained_seqno)
    /// exposes the boundary so a caller can validate a snapshot before
    /// reading: a snapshot is servable iff it is `0` or strictly above that
    /// seqno.
    SnapshotBelowRetention {
        /// The snapshot seqno the read asked for.
        requested: SeqNo,

        /// The retention floor; reads at `oldest_retained + 1` and above are
        /// servable.
        oldest_retained: SeqNo,
    },

    /// [`AbstractTree::lower_retention_floor`](crate::AbstractTree::lower_retention_floor)
    /// was asked for a floor above the current one. Only GC and the
    /// installs that drop data raise the floor; the request changed nothing.
    RetentionFloorRaise {
        /// The retention floor in force.
        current: SeqNo,

        /// The floor the call asked for.
        requested: SeqNo,
    },

    /// [`AbstractTree::lower_retention_floor`](crate::AbstractTree::lower_retention_floor)
    /// was asked for a floor above the tree's seqno counter: it would refuse
    /// snapshots the counter has yet to hand out. The request changed nothing.
    RetentionFloorAboveSeqno {
        /// The counter's current seqno.
        seqno: SeqNo,

        /// The floor the call asked for.
        requested: SeqNo,
    },

    /// A projected columnar scan cannot produce a field as its projection
    /// declares: the field is absent from a row and declared an error, a
    /// segment stores it under another type, a row value has to be read and
    /// no projector is set, or the projector wrote a cell that does not fit.
    ///
    /// A caller-side mismatch between the projection and the data, not damage.
    /// The payload names which of these it is.
    Projection(&'static str),

    /// A blob reference of a row written as cells cannot be used: a
    /// [`BlobRef`](crate::blob_tree::field_row::BlobRef) read from another
    /// key or another tree, one object in two cells of the row, a reference in
    /// an ingested row, or a stale reference, whose object was moved or
    /// released since it was read; or a reference-aware read finds the key's
    /// version is not written as cells.
    ///
    /// A caller-side error, refused before anything is written. The payload
    /// names which of these it is; only a stale reference is cured by reading
    /// the key again.
    BlobRef(&'static str),

    /// A row written as cells is not one the caller could have meant: two
    /// fields in one column, or a fixed-width field that is not its type's
    /// width.
    ///
    /// A caller-side error, refused before anything is written. The payload
    /// names which of these it is.
    CellRow(&'static str),

    /// A salvage could not write, finish or make durable its OUTPUT: the
    /// failure lies with the destination, not with the bytes of the file
    /// being salvaged.
    ///
    /// A recovery that met it stops rather than grade the SOURCE unreadable:
    /// that would leave intact data out of the rebuilt manifest, and the
    /// cleanup after the commit would remove it. Fix the destination
    /// (its space, its device) and retry.
    SalvageOutput(alloc::boxed::Box<Self>),

    /// A manifest install failed after its edit or snapshot was written, so
    /// a power loss may still make it the installed one: what it names is
    /// kept on disk, and the next install writes a full snapshot over it.
    ///
    /// The operation that asked for the install (a flush, a compaction)
    /// reports it as failed and may be retried; the tree reopened after a
    /// crash holds either the state before it or the state it installed,
    /// whole. The payload is the failure itself.
    ///
    /// From a repair, the report rides in the snapshot it describes: when that
    /// snapshot turns out installed,
    /// [`Config::open_or_repair`](crate::Config::open_or_repair) hands the
    /// report out; when it does not, its own repair answers with a fresh one.
    InstallOutcomeUnknown(alloc::boxed::Box<Self>),
}

impl core::fmt::Display for Error {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        // The format refusals get prose rather than their Debug form: their
        // remedy lies outside this process (a separate tool, or another
        // release), and an operator who sees one needs to be told that rather
        // than left to infer it from a struct dump. Everything else stays
        // `{self:?}`.
        if let Self::InvalidVersion(found) = self {
            let current = u8::from(crate::FormatVersion::V6);
            write!(
                f,
                "LsmTreeError: this store is in on-disk format {found}, but this \
                 release reads only format {current}. ",
            )?;
            // Only the format immediately before the current one has a
            // converter; older and newer stores are read by the release that
            // wrote them.
            return if found.checked_add(1) == Some(current) {
                write!(
                    f,
                    "The data is intact: run the offline converter over the store, \
                     then open it again.",
                )
            } else {
                write!(f, "Open it with the release that wrote it.")
            };
        }
        if let Self::UnsupportedFormat {
            part,
            found,
            expected,
        } = self
        {
            write!(f, "LsmTreeError: this store's {part} are in ")?;
            match found {
                Some(v) => write!(f, "format {v}")?,
                None => write!(f, "the original unstamped format")?,
            }
            return write!(
                f,
                ", but this release reads only format {expected}. The data is \
                 intact: run the offline converter over the store to rewrite \
                 its {part}, then open it again.",
            );
        }
        write!(f, "LsmTreeError: {self:?}")
    }
}

impl core::error::Error for Error {
    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
        match self {
            Self::Io(e) => Some(e),
            // The variant is explicitly a causal wrapper: generic error-chain
            // logging and transient-retry classifiers must be able to reach
            // the post-commit step's own failure through the standard chain.
            #[cfg(feature = "std")]
            Self::RepairedButUnopened { cause, .. } => Some(&**cause),
            Self::SalvageOutput(cause) | Self::InstallOutcomeUnknown(cause) => Some(&**cause),
            _ => None,
        }
    }
}

impl From<crate::sfa::Error> for Error {
    fn from(value: crate::sfa::Error) -> Self {
        match value {
            crate::sfa::Error::Io(e) => Self::from(e),
            crate::sfa::Error::ChecksumMismatch { got, expected } => {
                log::error!("Archive ToC checksum mismatch");
                Self::ChecksumMismatch {
                    got: got.into(),
                    expected: expected.into(),
                }
            }
            crate::sfa::Error::InvalidHeader => {
                log::error!("Invalid archive header");
                Self::Unrecoverable
            }
            crate::sfa::Error::InvalidVersion => {
                log::error!("Invalid archive version");
                Self::Unrecoverable
            }
            crate::sfa::Error::UnsupportedChecksumType => {
                log::error!("Invalid archive checksum type");
                Self::Unrecoverable
            }
        }
    }
}

impl Error {
    /// Whether this failure says something about the ENVIRONMENT or the
    /// CALLER's configuration rather than about the bytes on disk — the class
    /// every recovery path must PROPAGATE instead of recording as damage.
    ///
    /// Recording one of these as damage commits a manifest that omits the file
    /// and then removes it, turning a fixable mistake into permanent loss;
    /// propagating lets the operator fix the environment (or supply the right
    /// key / dictionary) and re-run with everything still on disk.
    ///
    /// - [`Self::Io`] of an environmental kind: the interrupted-syscall
    ///   retryables, plus access failures that do not implicate the bytes
    ///   (`PermissionDenied`, `StorageFull`, `QuotaExceeded`,
    ///   `ReadOnlyFilesystem`, `OutOfMemory`).
    /// - [`Self::Decrypt`]: an AEAD failure is exactly what a missing or wrong
    ///   key produces on perfectly healthy ciphertext.
    /// - [`Self::ZstdDictMismatch`]: the persisted descriptor names a
    ///   dictionary the caller did not supply, or supplied a different one.
    /// - [`Self::UnsupportedFormat`]: the persisted descriptor names a format
    ///   this binary does not read. Exactly the same shape as the dictionary
    ///   mismatch — healthy bytes, wrong environment — and the classification
    ///   matters most for repair, which grades a table it cannot recover as
    ///   damaged and leaves it out of the rebuilt manifest. Without this arm,
    ///   repairing a legacy store would DELETE every filtered or columnar
    ///   table in it over an error whose whole meaning is that the data is
    ///   intact and awaiting conversion.
    ///
    /// A failure that DOES implicate the bytes (a bad sector, a structural
    /// decode failure) is not in this class: a retry cannot fix it, and the
    /// recovery paths grade that file instead of aborting over it.
    #[must_use]
    pub(crate) fn is_environmental(&self) -> bool {
        match self {
            Self::Io(io) => io.kind().is_environmental(),
            Self::Decrypt(_) => true,
            #[cfg(zstd_any)]
            Self::ZstdDictMismatch { .. } => true,
            Self::UnsupportedFormat { .. } => true,
            // The destination failed, whatever its kind: the source's bytes
            // are not implicated.
            Self::SalvageOutput(_) => true,
            Self::InstallOutcomeUnknown(cause) => cause.is_environmental(),
            _ => false,
        }
    }
}

// The `Io` variant carries `crate::io::Error` (the no_std-capable I/O error),
// so this bridge is a direct wrap. Std file-I/O paths surface `std::io::Error`;
// the std-gated bridge below folds those through `crate::io::Error`.
impl From<crate::io::Error> for Error {
    fn from(value: crate::io::Error) -> Self {
        Self::Io(value)
    }
}

#[cfg(feature = "std")]
impl From<std::io::Error> for Error {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value.into())
    }
}

/// Tree result
pub type Result<T> = core::result::Result<T, Error>;

#[cfg(test)]
mod tests;