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;