Skip to main content

lsm_tree/compression/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5#[cfg(feature = "zstd")]
6mod zstd_backend;
7
8use crate::coding::{Decode, Encode};
9use crate::io::{Read, ReadBytesExt, Write, WriteBytesExt};
10
11#[cfg(zstd_any)]
12use alloc::sync::Arc;
13
14#[cfg(feature = "zstd")]
15use once_cell::race::OnceBox;
16
17/// Zstd compression backend operations.
18///
19/// Abstracts the zstd implementation so callsites are independent of the
20/// underlying crate. Enabled by the `zstd` feature (pure Rust, no C
21/// dependencies). Produces RFC 8878 compliant zstd frames.
22#[cfg(zstd_any)]
23pub trait CompressionProvider {
24    /// Compress `data` at the given zstd level (1–22).
25    ///
26    /// `two_pass_seed` selects whether levels 19-22 run the `btultra2` seed
27    /// pass; it has no effect below 19, where another strategy applies. See
28    /// [`crate::runtime_config::RuntimeConfig::zstd_two_pass_seed`].
29    fn compress(data: &[u8], level: i32, two_pass_seed: bool) -> crate::Result<Vec<u8>>;
30
31    /// Compress `data`, additionally returning the inner zstd-block layout of
32    /// the produced frame: the cumulative decompressed END offset of each inner
33    /// block (a monotonically increasing prefix sum whose last entry equals the
34    /// total decompressed size). A reader can binary-search this array to map a
35    /// decompressed byte offset to the inner-block index covering it, then
36    /// partial-decode only the covering blocks (see
37    /// [`FrameDecoder::decode_blocks_partial`](structured_zstd::decoding::FrameDecoder::decode_blocks_partial)).
38    ///
39    /// The layout is returned **empty** when the frame is a single inner block
40    /// (nothing to skip, so the caller persists no per-block table) or when the
41    /// backend could not capture it. The compressed bytes are identical to
42    /// [`compress`](Self::compress); only the side-channel layout differs.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if compression fails.
47    fn compress_with_layout(
48        data: &[u8],
49        level: i32,
50        two_pass_seed: bool,
51    ) -> crate::Result<(Vec<u8>, Vec<u32>)>;
52
53    /// Decompress a zstd frame, pre-allocating `capacity` bytes.
54    fn decompress(data: &[u8], capacity: usize) -> crate::Result<Vec<u8>>;
55
56    /// Compress `data` using a zstd dictionary.
57    ///
58    /// `dict` may hold either a finalized zstd dictionary (header bytes
59    /// `37 A4 30 EC`, i.e. little-endian integer `0xEC30A437`, followed by
60    /// entropy tables and content, as `zstd --train` writes it) or raw content
61    /// bytes (bare bytes used as LZ77 history). It is prepared for the encoder
62    /// once and shared across calls and threads, so no call parses or copies it.
63    ///
64    /// `two_pass_seed` carries the same meaning as on [`compress`](Self::compress).
65    fn compress_with_dict(
66        data: &[u8],
67        level: i32,
68        dict: &ZstdDictionary,
69        two_pass_seed: bool,
70    ) -> crate::Result<Vec<u8>>;
71
72    /// Decompress a zstd frame that was compressed with a dictionary.
73    ///
74    /// `dict` provides the raw dictionary bytes and a 64-bit fingerprint used
75    /// as the TLS cache key. Implementations cache the parsed decoder in
76    /// thread-local storage keyed by that fingerprint to avoid re-parsing the
77    /// dictionary on every call.
78    fn decompress_with_dict(
79        data: &[u8],
80        dict: &ZstdDictionary,
81        capacity: usize,
82    ) -> crate::Result<Vec<u8>>;
83}
84
85/// The active zstd backend (pure Rust via `structured-zstd`).
86#[cfg(feature = "zstd")]
87pub type ZstdBackend = zstd_backend::ZstdProvider;
88
89/// A zstd dictionary to compress small blocks against.
90///
91/// Zstd dictionaries significantly improve compression ratios for blocks
92/// in the 4–64 KiB range typical of LSM-trees, especially when data has
93/// recurring patterns (e.g., structured keys, repeated prefixes,
94/// JSON/MessagePack values).
95///
96/// Training happens outside this crate: [`ZstdDictionary::new`] takes the
97/// dictionary bytes, it does not derive them from samples. Produce them with
98/// `zstd --train` (or any zstd dictionary trainer), or hand it representative
99/// content directly as a raw content dictionary.
100///
101/// The dictionary is identified by a 32-bit ID derived from its content
102/// (truncated xxh3 hash). This ID is stored alongside compressed blocks
103/// so readers can detect dictionary mismatches.
104///
105/// # Example
106///
107/// ```ignore
108/// use lsm_tree::ZstdDictionary;
109///
110/// // Already-trained dictionary bytes, e.g. read from the file
111/// // `zstd --train` wrote.
112/// let dict = ZstdDictionary::new(&dictionary_bytes);
113/// ```
114#[cfg(zstd_any)]
115pub struct ZstdDictionary {
116    /// Full 64-bit xxh3 hash used as the collision-resistant cache key for the
117    /// thread-local `FrameDecoder`. The public `id() -> u32` method returns
118    /// the lower 32 bits for external consumers.
119    id: u64,
120    raw: Arc<[u8]>,
121    /// Lazily-parsed shared `DictionaryHandle` (Arc-backed inside structured-zstd).
122    /// Populated on first decompress call and reused across all subsequent calls
123    /// and all threads — eliminates the per-thread dictionary re-parse the TLS
124    /// `FrameDecoder` cache used to incur on every miss.
125    /// `OnceBox::get_or_try_init` guarantees one successful parse across
126    /// racing threads via a single CAS on the slot pointer: the winner's
127    /// `Box<DictionaryHandle>` becomes the stable `&T`, racing losers drop
128    /// their unused `Box` allocations and read the winner's value on the
129    /// next iteration. No auxiliary mutex is needed because the slot is
130    /// lock-free; the only contention is the brief CAS window during the
131    /// cold-start race. This keeps the `new()` constructor infallible AND
132    /// preserves the single-parse contract.
133    #[cfg(feature = "zstd")]
134    prepared: Arc<OnceBox<structured_zstd::decoding::DictionaryHandle>>,
135    /// The encoder-side twin of `prepared` (zstd's `CDict` beside the `DDict`):
136    /// parsed once, with the entropy tables it seeds, and shared by every
137    /// thread's compressor as a reference-counted handle. The two sides own
138    /// their dictionary content separately by design, so this is the one
139    /// encoder copy of it for the dictionary's lifetime.
140    #[cfg(feature = "zstd")]
141    encoder: Arc<OnceBox<structured_zstd::encoding::EncoderDictionary>>,
142}
143
144#[cfg(zstd_any)]
145impl Clone for ZstdDictionary {
146    fn clone(&self) -> Self {
147        Self {
148            id: self.id,
149            raw: Arc::clone(&self.raw),
150            #[cfg(feature = "zstd")]
151            prepared: Arc::clone(&self.prepared),
152            #[cfg(feature = "zstd")]
153            encoder: Arc::clone(&self.encoder),
154        }
155    }
156}
157
158/// Two dictionaries are equal when their full 64-bit xxh3 fingerprints agree.
159/// Equality is defined by the 64-bit `id` field; hash collisions between
160/// dictionaries with different raw bytes are theoretically possible but
161/// extremely unlikely given the xxh3-64 collision probability.
162#[cfg(zstd_any)]
163impl PartialEq for ZstdDictionary {
164    fn eq(&self, other: &Self) -> bool {
165        self.id == other.id
166    }
167}
168
169#[cfg(zstd_any)]
170impl Eq for ZstdDictionary {}
171
172#[cfg(zstd_any)]
173impl ZstdDictionary {
174    /// Creates a new dictionary handle from raw bytes.
175    ///
176    /// `raw` may be either:
177    ///
178    /// * A **finalized zstd dictionary** — bytes starting with the magic
179    ///   `37 A4 30 EC` (as produced by `zstd --train`; accessible via
180    ///   [`ZstdDictionary::raw`] for persistence and interop).  The backend
181    ///   parses it with the full entropy-table decoder.
182    /// * A **raw content dictionary** — arbitrary bytes used as LZ77 history
183    ///   (no magic header).  Useful when the caller controls the training data
184    ///   and does not need the full entropy-table overhead.
185    ///
186    /// Both forms are accepted by [`CompressionProvider::compress_with_dict`]
187    /// and [`CompressionProvider::decompress_with_dict`].
188    ///
189    /// The handle stores the full 64-bit xxh3 hash of `raw` internally.
190    /// [`Self::id`] returns the lower 32 bits for external consumers
191    /// (config validation, frame header); `id64` (crate-internal) exposes the
192    /// full fingerprint for use as a cache key.
193    #[must_use]
194    pub fn new(raw: &[u8]) -> Self {
195        Self {
196            id: compute_dict_id(raw),
197            raw: Arc::from(raw),
198            #[cfg(feature = "zstd")]
199            prepared: Arc::new(OnceBox::new()),
200            #[cfg(feature = "zstd")]
201            encoder: Arc::new(OnceBox::new()),
202        }
203    }
204
205    /// The same dictionary with `id` forced, for exercising an id COLLISION.
206    ///
207    /// The id is the hash of the content, so two different dictionaries sharing
208    /// one is a collision of the 32-bit truncation — reachable in principle,
209    /// unsearchable in a test. This is the only way to construct that state, and
210    /// it needs the private field, so it lives with the type.
211    ///
212    /// The prepared encoder and decoder carry the id into the frames they
213    /// write and pin, so the copy prepares its own rather than sharing the
214    /// original's.
215    #[cfg(test)]
216    #[must_use]
217    pub(crate) fn with_id_for_test(&self, id: u32) -> Self {
218        Self {
219            id: u64::from(id),
220            raw: Arc::clone(&self.raw),
221            #[cfg(feature = "zstd")]
222            prepared: Arc::new(OnceBox::new()),
223            #[cfg(feature = "zstd")]
224            encoder: Arc::new(OnceBox::new()),
225        }
226    }
227
228    /// The zstd frame-header id this dictionary's frames carry: the lower 32
229    /// bits of the fingerprint, clamped to at least 1 because a raw-content
230    /// dictionary with id 0 would put no id on the wire. Shared by the encoder
231    /// and decoder sides so both derive it the same way.
232    #[cfg(feature = "zstd")]
233    pub(crate) fn frame_id(&self) -> u32 {
234        self.id().max(1)
235    }
236
237    /// Returns the shared encoder dictionary, preparing it on first call and
238    /// reusing it on every later one, across threads.
239    ///
240    /// A finalized dictionary (magic `37 A4 30 EC`) is parsed for the encoder
241    /// only; anything else is raw content, given [`Self::frame_id`] so the
242    /// frames record the id the reader pins. Cloning the result is a reference
243    /// count, so attaching it to a compressor copies nothing. A parse failure
244    /// is not cached: the next call retries.
245    #[cfg(feature = "zstd")]
246    pub(crate) fn prepared_encoder(
247        &self,
248    ) -> crate::Result<structured_zstd::encoding::EncoderDictionary> {
249        use structured_zstd::decoding::{DICTIONARY_MAGIC, Dictionary};
250        use structured_zstd::encoding::EncoderDictionary;
251
252        self.encoder
253            .get_or_try_init(|| -> crate::Result<Box<EncoderDictionary>> {
254                let dictionary = if self.raw.starts_with(&DICTIONARY_MAGIC) {
255                    EncoderDictionary::from_bytes(&self.raw)
256                } else {
257                    Dictionary::from_raw_content(self.frame_id(), self.raw.to_vec())
258                        .map(EncoderDictionary::from_dictionary)
259                }
260                .map_err(|e| crate::Error::Io(crate::io::Error::other(e.to_string())))?;
261                Ok(Box::new(dictionary))
262            })
263            .cloned()
264    }
265
266    /// Returns the shared pre-parsed `DictionaryHandle`, parsing on first call
267    /// and reusing the cached handle on every subsequent call (across threads).
268    ///
269    /// The handle wraps an `Arc<Dictionary>` inside structured-zstd, so cloning
270    /// it is an atomic refcount bump — cheap enough to use on every decompress
271    /// call. Frame decoders register the dictionary via
272    /// `FrameDecoder::add_dict_handle`, which shares the same `Arc` rather than
273    /// cloning the underlying entropy tables.
274    ///
275    /// On the first call we attempt finalized-dict parsing (magic bytes
276    /// `37 A4 30 EC`); buffers without that prefix are treated as raw-content
277    /// dictionaries via `Dictionary::from_raw_content` with the same synthetic
278    /// 32-bit id formula the compressor uses (`xxh3(raw) as u32, clamped ≥ 1`).
279    /// Parse failures are NOT cached — the next caller will retry — but the
280    /// raw bytes are immutable for the dictionary's lifetime so a successful
281    /// parse on one thread is permanent.
282    #[cfg(feature = "zstd")]
283    pub(crate) fn prepared_handle(
284        &self,
285    ) -> crate::Result<structured_zstd::decoding::DictionaryHandle> {
286        use structured_zstd::decoding::{DICTIONARY_MAGIC, Dictionary, DictionaryHandle};
287
288        // `OnceBox::get_or_try_init` is the canonical single-init-
289        // across-racers primitive: the closure runs at most once
290        // globally regardless of contention; concurrent callers race on
291        // a single CAS and the losers drop their unused `Box` while the
292        // winners' value becomes the stable `&T`. The fast path (cached
293        // value) is lock-free; the slow path runs exactly once per
294        // `ZstdDictionary` lifetime even under heavy cold-start
295        // contention. On a parse failure the OnceBox stays empty and
296        // the next caller retries from scratch — preserving the
297        // retry-on-failure contract pinned by the rejection test.
298        // `Box::new(handle)` is the OnceBox API requirement: the slot
299        // owns a heap allocation rather than the value inline, which
300        // is what lets the type stay no-std + alloc compatible.
301        self.prepared
302            .get_or_try_init(|| -> crate::Result<Box<DictionaryHandle>> {
303                let handle = if self.raw.starts_with(&DICTIONARY_MAGIC) {
304                    DictionaryHandle::decode_dict(&self.raw)
305                        .map_err(|e| crate::Error::Io(crate::io::Error::other(e.to_string())))?
306                } else {
307                    let dict = Dictionary::from_raw_content(self.frame_id(), self.raw.to_vec())
308                        .map_err(|e| crate::Error::Io(crate::io::Error::other(e.to_string())))?;
309                    DictionaryHandle::from_dictionary(dict)
310                };
311                Ok(Box::new(handle))
312            })
313            .cloned()
314    }
315
316    /// Returns a 32-bit fingerprint derived from the dictionary content.
317    ///
318    /// The fingerprint is the lower 32 bits of the xxh3-64 hash of the raw
319    /// dictionary bytes.  It is stable for a given byte sequence and is
320    /// intended for config validation (matching a `CompressionType::ZstdDict`
321    /// `dict_id` field against the supplied `ZstdDictionary`) and external
322    /// interop.
323    ///
324    /// The value may theoretically be `0` (probability ≈ 1/2³²). Backends
325    /// that embed a dict ID in the zstd frame header (where id=0 is reserved)
326    /// are responsible for clamping to at least 1 themselves.  Config
327    /// validation is unaffected: both sides derive the ID from the same bytes
328    /// and therefore agree even in the zero case.
329    #[must_use]
330    #[expect(
331        clippy::cast_possible_truncation,
332        reason = "intentional: public API returns 32-bit fingerprint"
333    )]
334    pub fn id(&self) -> u32 {
335        self.id as u32
336    }
337
338    /// Returns the full 64-bit xxh3 fingerprint used as a collision-resistant
339    /// cache key inside the TLS decoder.
340    #[cfg(feature = "zstd")]
341    #[must_use]
342    pub(crate) fn id64(&self) -> u64 {
343        self.id
344    }
345
346    /// Returns the raw dictionary bytes.
347    #[must_use]
348    pub fn raw(&self) -> &[u8] {
349        &self.raw
350    }
351}
352
353#[cfg(zstd_any)]
354impl core::fmt::Debug for ZstdDictionary {
355    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
356        f.debug_struct("ZstdDictionary")
357            .field("id", &format_args!("{:#018x}", self.id))
358            .field("size", &self.raw.len())
359            .finish_non_exhaustive() // `prepared` cache omitted — implementation detail
360    }
361}
362
363/// Compute the full 64-bit xxh3 dictionary fingerprint.
364///
365/// The full 64-bit value is used as the collision-resistant cache key inside
366/// the pure Rust backend's thread-local `FrameDecoder`. The public `id()`
367/// method returns only the lower 32 bits for backward-compatible display.
368#[cfg(zstd_any)]
369fn compute_dict_id(raw: &[u8]) -> u64 {
370    xxhash_rust::xxh3::xxh3_64(raw)
371}
372
373/// Every dictionary a tree can decompress against, keyed by the id its tables
374/// already record.
375///
376/// A table names the dictionary it was written with; this is what turns that
377/// name back into bytes. Holding SEVERAL is what lets one tree mix data written
378/// under different dictionaries, which in turn is what lets a dictionary be
379/// introduced or replaced on a tree that already holds data: the tables written
380/// before keep resolving to the dictionary they used.
381///
382/// Cheap to clone (one `Arc` bump): every reader that opens a table takes a
383/// handle, and the set changes only on registration.
384#[cfg(zstd_any)]
385#[derive(Clone, Default)]
386pub struct ZstdDictionaries {
387    /// Immutable map, replaced wholesale on registration. Registrations are
388    /// rare and the map is small (a handful of dictionaries at most), so
389    /// copy-on-write beats a lock on a path every table open reads.
390    by_id: Option<Arc<crate::HashMap<u32, Arc<ZstdDictionary>>>>,
391}
392
393#[cfg(zstd_any)]
394impl ZstdDictionaries {
395    /// An empty set: a tree that compresses against no dictionary.
396    #[must_use]
397    pub fn new() -> Self {
398        Self { by_id: None }
399    }
400
401    /// The dictionary `id` names, or `None` when this tree does not hold it.
402    #[must_use]
403    pub fn get(&self, id: u32) -> Option<&Arc<ZstdDictionary>> {
404        self.by_id.as_ref()?.get(&id)
405    }
406
407    /// Whether this tree holds no dictionary at all.
408    #[must_use]
409    pub fn is_empty(&self) -> bool {
410        self.by_id.as_ref().is_none_or(|m| m.is_empty())
411    }
412
413    /// How many dictionaries this tree holds.
414    #[must_use]
415    pub fn len(&self) -> usize {
416        self.by_id.as_ref().map_or(0, |m| m.len())
417    }
418
419    /// The ids held, in no particular order.
420    pub fn ids(&self) -> impl Iterator<Item = u32> + '_ {
421        self.by_id.iter().flat_map(|m| m.keys().copied())
422    }
423
424    /// The dictionaries held, in no particular order.
425    pub fn iter(&self) -> impl Iterator<Item = &Arc<ZstdDictionary>> + '_ {
426        self.by_id.iter().flat_map(|m| m.values())
427    }
428
429    /// Adds `dict` under its own id, returning the extended set.
430    ///
431    /// Re-adding an id already present is a no-op rather than a replacement:
432    /// the id is derived from the bytes, so a second dictionary claiming an
433    /// existing id has the same content or is a hash collision, and in neither
434    /// case may the tables written against the first one be re-pointed.
435    #[must_use]
436    pub fn with(&self, dict: Arc<ZstdDictionary>) -> Self {
437        let id = dict.id();
438        if self.get(id).is_some() {
439            return self.clone();
440        }
441        let mut next = self
442            .by_id
443            .as_ref()
444            .map_or_else(crate::HashMap::default, |m| (**m).clone());
445        next.insert(id, dict);
446        Self {
447            by_id: Some(Arc::new(next)),
448        }
449    }
450
451    /// The dictionary `compression` names, if it names one and the set holds
452    /// it.
453    ///
454    /// The lookup a file does ONCE, when its handle is built: a table and a
455    /// blob file each record one descriptor, and the resolved dictionary is
456    /// then pinned for the handle's life rather than resolved per read. That is
457    /// what keeps a reader that captured a version able to decode after the set
458    /// has moved on.
459    ///
460    /// # Errors
461    ///
462    /// [`crate::Error::ZstdDictMismatch`] when the descriptor names an id the
463    /// set does not hold. Fail-fast, exactly as a table's recovery does: the
464    /// file cannot decode a single value that way, so the id is named here
465    /// while an operator can still restore it, rather than on the first read
466    /// of each value far from the cause.
467    pub fn for_compression(
468        &self,
469        compression: CompressionType,
470    ) -> crate::Result<Option<Arc<ZstdDictionary>>> {
471        match compression {
472            CompressionType::ZstdDict { dict_id, .. } => match self.get(dict_id) {
473                Some(dict) => Ok(Some(dict.clone())),
474                None => Err(crate::Error::ZstdDictMismatch {
475                    expected: dict_id,
476                    got: None,
477                }),
478            },
479            _ => Ok(None),
480        }
481    }
482
483    /// Removes `id`, returning the reduced set. Removing an absent id is a
484    /// no-op.
485    #[must_use]
486    pub fn without(&self, id: u32) -> Self {
487        if self.get(id).is_none() {
488            return self.clone();
489        }
490        let mut next = self
491            .by_id
492            .as_ref()
493            .map_or_else(crate::HashMap::default, |m| (**m).clone());
494        next.remove(&id);
495        Self {
496            by_id: (!next.is_empty()).then(|| Arc::new(next)),
497        }
498    }
499}
500
501#[cfg(zstd_any)]
502impl core::fmt::Debug for ZstdDictionaries {
503    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
504        f.debug_struct("ZstdDictionaries")
505            .field("len", &self.len())
506            .finish_non_exhaustive()
507    }
508}
509
510/// Compression algorithm to use
511#[derive(Copy, Clone, Debug, Eq, PartialEq)]
512#[non_exhaustive]
513pub enum CompressionType {
514    /// No compression
515    ///
516    /// Not recommended.
517    None,
518
519    /// LZ4 compression
520    ///
521    /// Recommended for use cases with a focus
522    /// on speed over compression ratio.
523    #[cfg(feature = "lz4")]
524    Lz4,
525
526    /// Zstd compression
527    ///
528    /// Provides significantly better compression ratios than LZ4
529    /// with reasonable decompression speed (~1.5 GB/s).
530    ///
531    /// Compression level can be adjusted (1-22, default 3):
532    /// - 1 optimizes for speed
533    /// - 3 is a good default (recommended)
534    /// - 9+ optimizes for compression ratio
535    ///
536    /// Recommended for cold/archival data where compression ratio
537    /// matters more than raw speed.
538    // NOTE: Uses i32 (not a validated newtype) to match upstream's public API and
539    // the zstd crate's compress(data, level: i32) signature. zstd accepts negative
540    // "fast" levels and 0 (= default) as well as 1..=22; the on-disk format stores
541    // the level in one signed byte, so the persistable range is -128..=22. Validated
542    // levels are produced by CompressionType::zstd() and Decode::decode_from; direct
543    // construction via CompressionType::Zstd(level) must uphold the -128..=22 invariant.
544    #[cfg(zstd_any)]
545    Zstd(i32),
546
547    /// Zstd compression with a pre-trained dictionary
548    ///
549    /// Uses a pre-trained dictionary for significantly better compression
550    /// ratios on small blocks (4–64 KiB), especially when data has recurring
551    /// patterns.
552    ///
553    /// `level` is the compression level (1–22), `dict_id` identifies the
554    /// dictionary (truncated xxh3 hash of the dictionary bytes). A tree must
555    /// hold the dictionary before a policy names it. Supply it through
556    /// [`Config::zstd_dictionary`](crate::Config::zstd_dictionary) or
557    /// [`KvSeparationOptions::dict`](crate::KvSeparationOptions::dict), or
558    /// register it with
559    /// [`Tree::register_zstd_dictionary`](crate::Tree::register_zstd_dictionary)
560    /// or [`BlobTree::register_zstd_dictionary`](crate::BlobTree::register_zstd_dictionary).
561    #[cfg(zstd_any)]
562    ZstdDict {
563        /// Compression level (1–22)
564        level: i32,
565
566        /// Dictionary fingerprint for mismatch detection
567        dict_id: u32,
568    },
569}
570
571impl CompressionType {
572    /// Bytes this codec can produce for `len` input bytes at most. A block
573    /// keeps its compressed form even when it came out larger than its input.
574    #[must_use]
575    pub(crate) const fn compressed_len_bound(self, len: usize) -> usize {
576        match self {
577            Self::None => len,
578            #[cfg(feature = "lz4")]
579            Self::Lz4 => lz4_flex::block::get_maximum_output_size(len),
580            #[cfg(zstd_any)]
581            Self::Zstd(_) | Self::ZstdDict { .. } => structured_zstd::encoding::compress_bound(len),
582        }
583    }
584
585    /// Returns the zstd dictionary id encoded in this compression
586    /// configuration, or `0` when no dictionary applies. Used to
587    /// populate [`crate::table::block::BlockIdentity::dict_id`]
588    /// from a `CompressionType` at the call site without each
589    /// caller re-doing the `ZstdDict { dict_id, .. }` destructure.
590    #[must_use]
591    pub fn dict_id(&self) -> u32 {
592        #[cfg(zstd_any)]
593        if let Self::ZstdDict { dict_id, .. } = self {
594            return *dict_id;
595        }
596        0
597    }
598
599    /// Validate a zstd compression level.
600    ///
601    /// Accepts levels in the range `-128..=22` (zstd negative "fast" levels and
602    /// `0` = default included; the on-disk format persists the level in one
603    /// signed byte) and returns an error otherwise.
604    #[cfg(zstd_any)]
605    fn validate_zstd_level(level: i32) -> crate::Result<()> {
606        // zstd accepts negative "fast" levels (down to a very negative minimum)
607        // plus 0 (= default) and 1..=22. The on-disk format stores the level as a
608        // single signed byte, so the persistable range is `i8::MIN..=22`; a level
609        // below `i8::MIN` is rejected here rather than silently truncated by the
610        // `as i8` cast in `encode_into`.
611        if !(i32::from(i8::MIN)..=22).contains(&level) {
612            // NOTE: Uses Error::other (not ErrorKind::InvalidInput) to match
613            // upstream's error style and minimize fork divergence.
614            return Err(crate::Error::Io(crate::io::Error::other(format!(
615                "invalid zstd compression level {level}, expected -128..=22"
616            ))));
617        }
618        Ok(())
619    }
620
621    /// Create a zstd compression configuration with a checked level.
622    ///
623    /// This is the recommended way to construct a `CompressionType::Zstd`
624    /// value, as it validates the level before any I/O occurs.
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if `level` is outside the valid range `-128..=22` (zstd
629    /// negative "fast" levels and `0` = default are accepted; the on-disk format
630    /// stores the level in one signed byte).
631    #[cfg(zstd_any)]
632    pub fn zstd(level: i32) -> crate::Result<Self> {
633        Self::validate_zstd_level(level)?;
634        Ok(Self::Zstd(level))
635    }
636
637    /// Create a zstd dictionary compression configuration with checked level.
638    ///
639    /// The `dict_id` should come from [`ZstdDictionary::id`] to ensure
640    /// consistency between the compression type stored on disk and the
641    /// dictionary used at runtime.
642    ///
643    /// # Errors
644    ///
645    /// Returns an error if `level` is outside the valid range `-128..=22` (zstd
646    /// negative "fast" levels and `0` = default are accepted; the on-disk format
647    /// stores the level in one signed byte).
648    #[cfg(zstd_any)]
649    pub fn zstd_dict(level: i32, dict_id: u32) -> crate::Result<Self> {
650        Self::validate_zstd_level(level)?;
651        Ok(Self::ZstdDict { level, dict_id })
652    }
653}
654
655impl core::fmt::Display for CompressionType {
656    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
657        write!(
658            f,
659            "{}",
660            match self {
661                Self::None => "none",
662
663                #[cfg(feature = "lz4")]
664                Self::Lz4 => "lz4",
665
666                #[cfg(zstd_any)]
667                Self::Zstd(_) => "zstd",
668
669                #[cfg(zstd_any)]
670                Self::ZstdDict { .. } => "zstd+dict",
671            }
672        )
673    }
674}
675
676impl Encode for CompressionType {
677    fn encode_into<W: Write>(&self, writer: &mut W) -> Result<(), crate::Error> {
678        match self {
679            Self::None => {
680                writer.write_u8(0)?;
681            }
682
683            #[cfg(feature = "lz4")]
684            Self::Lz4 => {
685                writer.write_u8(1)?;
686            }
687
688            #[cfg(zstd_any)]
689            Self::Zstd(level) => {
690                writer.write_u8(3)?;
691                // Catch invalid levels in debug builds (e.g. direct Zstd(999) construction).
692                // Not a runtime error — encoding must stay infallible for encode_into_vec().
693                debug_assert!(
694                    (i32::from(i8::MIN)..=22).contains(level),
695                    "zstd level {level} outside valid range -128..=22"
696                );
697                #[expect(
698                    clippy::cast_possible_truncation,
699                    reason = "level range -128..=22 maps exactly to i8"
700                )]
701                writer.write_i8(*level as i8)?;
702            }
703
704            #[cfg(zstd_any)]
705            Self::ZstdDict { level, dict_id } => {
706                writer.write_u8(4)?;
707                debug_assert!(
708                    (i32::from(i8::MIN)..=22).contains(level),
709                    "zstd level {level} outside valid range -128..=22"
710                );
711                #[expect(
712                    clippy::cast_possible_truncation,
713                    reason = "level range -128..=22 maps exactly to i8"
714                )]
715                writer.write_i8(*level as i8)?;
716                crate::io::WriteBytesExt::write_u32::<crate::io::LittleEndian>(writer, *dict_id)?;
717            }
718        }
719
720        Ok(())
721    }
722}
723
724impl Decode for CompressionType {
725    fn decode_from<R: Read>(reader: &mut R) -> Result<Self, crate::Error> {
726        let tag = reader.read_u8()?;
727
728        match tag {
729            0 => Ok(Self::None),
730
731            #[cfg(feature = "lz4")]
732            1 => Ok(Self::Lz4),
733
734            #[cfg(zstd_any)]
735            3 => {
736                let level = i32::from(reader.read_i8()?);
737                // Reuse the shared validation logic to ensure consistent checks.
738                Self::validate_zstd_level(level)?;
739                Ok(Self::Zstd(level))
740            }
741
742            #[cfg(zstd_any)]
743            4 => {
744                let level = i32::from(reader.read_i8()?);
745                Self::validate_zstd_level(level)?;
746                let dict_id = crate::io::ReadBytesExt::read_u32::<crate::io::LittleEndian>(reader)?;
747                Ok(Self::ZstdDict { level, dict_id })
748            }
749
750            tag => Err(crate::Error::InvalidTag(("CompressionType", tag))),
751        }
752    }
753}
754
755#[cfg(test)]
756#[allow(
757    clippy::unwrap_used,
758    clippy::indexing_slicing,
759    clippy::useless_vec,
760    clippy::expect_used,
761    reason = "test code"
762)]
763mod tests;