Skip to main content

hermes_core/compression/
zstd.rs

1//! Zstd compression backend with dictionary support
2//!
3//! For static indexes, we use:
4//! - Maximum compression level (22) for best compression ratio
5//! - Trained dictionaries for even better compression of similar documents
6//! - Larger block sizes to improve compression efficiency
7
8use std::io;
9use std::io::Read;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12static NEXT_DICTIONARY_ID: AtomicU64 = AtomicU64::new(1);
13
14/// Compression level (1-22 for zstd)
15#[derive(Debug, Clone, Copy)]
16pub struct CompressionLevel(pub i32);
17
18impl CompressionLevel {
19    /// Fast compression (level 1)
20    pub const FAST: Self = Self(1);
21    /// Default compression (level 3)
22    pub const DEFAULT: Self = Self(3);
23    /// Better compression (level 9)
24    pub const BETTER: Self = Self(9);
25    /// Best compression (level 19)
26    pub const BEST: Self = Self(19);
27    /// Maximum compression (level 22) - slowest but smallest
28    pub const MAX: Self = Self(22);
29}
30
31impl Default for CompressionLevel {
32    fn default() -> Self {
33        Self::FAST // Level 3: good balance of speed and compression
34    }
35}
36
37/// Trained Zstd dictionary for improved compression
38#[derive(Clone)]
39pub struct CompressionDict {
40    raw_dict: crate::directories::OwnedBytes,
41    /// Stable across clones and never derived from an allocator address. The
42    /// thread-local codec caches can outlive a dictionary, so raw pointers are
43    /// vulnerable to allocator ABA reuse.
44    cache_id: u64,
45}
46
47impl CompressionDict {
48    /// Train a dictionary from sample data
49    ///
50    /// For best results, provide many small samples (e.g., serialized documents)
51    /// The dictionary size should typically be 16KB-112KB
52    pub fn train(samples: &[&[u8]], dict_size: usize) -> io::Result<Self> {
53        let raw_dict = zstd::dict::from_samples(samples, dict_size).map_err(io::Error::other)?;
54        Ok(Self {
55            raw_dict: crate::directories::OwnedBytes::new(raw_dict),
56            cache_id: NEXT_DICTIONARY_ID.fetch_add(1, Ordering::Relaxed),
57        })
58    }
59
60    /// Create dictionary from raw bytes (for loading saved dictionaries)
61    pub fn from_bytes(bytes: Vec<u8>) -> Self {
62        Self {
63            raw_dict: crate::directories::OwnedBytes::new(bytes),
64            cache_id: NEXT_DICTIONARY_ID.fetch_add(1, Ordering::Relaxed),
65        }
66    }
67
68    /// Create dictionary from OwnedBytes (zero-copy for mmap)
69    pub fn from_owned_bytes(bytes: crate::directories::OwnedBytes) -> Self {
70        Self {
71            raw_dict: bytes,
72            cache_id: NEXT_DICTIONARY_ID.fetch_add(1, Ordering::Relaxed),
73        }
74    }
75
76    /// Get raw dictionary bytes (for saving)
77    pub fn as_bytes(&self) -> &[u8] {
78        self.raw_dict.as_slice()
79    }
80
81    /// Dictionary size in bytes
82    pub fn len(&self) -> usize {
83        self.raw_dict.len()
84    }
85
86    /// Check if dictionary is empty
87    pub fn is_empty(&self) -> bool {
88        self.raw_dict.is_empty()
89    }
90
91    #[inline]
92    fn cache_id(&self) -> u64 {
93        self.cache_id
94    }
95}
96
97/// Compress data using Zstd
98///
99/// Uses a thread-local bulk compressor to avoid per-call encoder allocation.
100/// Only rebuilds when the compression level changes.
101pub fn compress(data: &[u8], level: CompressionLevel) -> io::Result<Vec<u8>> {
102    thread_local! {
103        static COMPRESSOR: std::cell::RefCell<Option<(i32, zstd::bulk::Compressor<'static>)>> =
104            const { std::cell::RefCell::new(None) };
105    }
106    COMPRESSOR.with(|cell| {
107        let mut slot = cell.borrow_mut();
108        if slot.as_ref().is_none_or(|(l, _)| *l != level.0) {
109            let cmp = zstd::bulk::Compressor::new(level.0).map_err(io::Error::other)?;
110            *slot = Some((level.0, cmp));
111        }
112        slot.as_mut()
113            .unwrap()
114            .1
115            .compress(data)
116            .map_err(io::Error::other)
117    })
118}
119
120/// Compress data using Zstd with a trained dictionary
121///
122/// Caches the dictionary compressor in a thread-local, keyed by dictionary
123/// pointer + compression level. Only rebuilt when dict or level changes.
124pub fn compress_with_dict(
125    data: &[u8],
126    level: CompressionLevel,
127    dict: &CompressionDict,
128) -> io::Result<Vec<u8>> {
129    thread_local! {
130        static DICT_CMP: std::cell::RefCell<Option<(u64, i32, zstd::bulk::Compressor<'static>)>> =
131            const { std::cell::RefCell::new(None) };
132    }
133    let dict_key = dict.cache_id();
134
135    DICT_CMP.with(|cell| {
136        let mut slot = cell.borrow_mut();
137        if slot
138            .as_ref()
139            .is_none_or(|(k, l, _)| *k != dict_key || *l != level.0)
140        {
141            let cmp = zstd::bulk::Compressor::with_dictionary(level.0, dict.as_bytes())
142                .map_err(io::Error::other)?;
143            *slot = Some((dict_key, level.0, cmp));
144        }
145        slot.as_mut()
146            .unwrap()
147            .2
148            .compress(data)
149            .map_err(io::Error::other)
150    })
151}
152
153/// Capacity hint for bulk decompressor (covers typical 256KB store blocks).
154/// Blocks that decompress larger than this fall back to streaming decode.
155const DECOMPRESS_CAPACITY: usize = 512 * 1024;
156
157/// Decompress data using Zstd
158///
159/// Fast path: reuses a thread-local bulk `Decompressor` with a 512KB
160/// capacity hint. Falls back to streaming decode for oversized blocks.
161pub fn decompress(data: &[u8]) -> io::Result<Vec<u8>> {
162    thread_local! {
163        static DECOMPRESSOR: std::cell::RefCell<zstd::bulk::Decompressor<'static>> =
164            std::cell::RefCell::new(zstd::bulk::Decompressor::new().unwrap());
165    }
166    DECOMPRESSOR.with(|dc| {
167        dc.borrow_mut()
168            .decompress(data, DECOMPRESS_CAPACITY)
169            .or_else(|_| zstd::decode_all(data))
170    })
171}
172
173/// Decompress while rejecting output larger than `max_output` bytes.
174///
175/// Index files are trusted only after validation. Using an explicit bound at
176/// compressed block boundaries prevents a tiny corrupt frame from expanding
177/// until the process runs out of memory.
178pub fn decompress_limited(data: &[u8], max_output: usize) -> io::Result<Vec<u8>> {
179    thread_local! {
180        static DECOMPRESSOR: std::cell::RefCell<zstd::bulk::Decompressor<'static>> =
181            std::cell::RefCell::new(zstd::bulk::Decompressor::new().unwrap());
182    }
183    DECOMPRESSOR.with(|dc| {
184        dc.borrow_mut().decompress(data, max_output).or_else(|_| {
185            let decoder = zstd::Decoder::new(data)?;
186            read_limited(decoder, max_output)
187        })
188    })
189}
190
191/// Decompress data using Zstd with a trained dictionary
192///
193/// Caches the dictionary decompressor in a thread-local, keyed by the
194/// dictionary's data pointer. Since a given `AsyncStoreReader` always holds
195/// the same `CompressionDict` (behind `Arc<OwnedBytes>`), the pointer is
196/// stable for the reader's lifetime. The decompressor is only rebuilt when
197/// a different dictionary is encountered (e.g., switching between segments).
198pub fn decompress_with_dict(data: &[u8], dict: &CompressionDict) -> io::Result<Vec<u8>> {
199    thread_local! {
200        static DICT_DC: std::cell::RefCell<Option<(u64, zstd::bulk::Decompressor<'static>)>> =
201            const { std::cell::RefCell::new(None) };
202    }
203    // Use the raw dict slice pointer as a stable identity key.
204    let dict_key = dict.cache_id();
205
206    DICT_DC.with(|cell| {
207        let mut slot = cell.borrow_mut();
208        // Rebuild decompressor only if dict changed
209        if slot.as_ref().is_none_or(|(k, _)| *k != dict_key) {
210            let dc = zstd::bulk::Decompressor::with_dictionary(dict.as_bytes())
211                .map_err(io::Error::other)?;
212            *slot = Some((dict_key, dc));
213        }
214        slot.as_mut()
215            .unwrap()
216            .1
217            .decompress(data, DECOMPRESS_CAPACITY)
218            .or_else(|_| {
219                let mut decoder = zstd::Decoder::with_dictionary(data, dict.as_bytes())?;
220                let mut output = Vec::new();
221                io::Read::read_to_end(&mut decoder, &mut output)?;
222                Ok(output)
223            })
224    })
225}
226
227/// Dictionary variant of [`decompress_limited`].
228pub fn decompress_with_dict_limited(
229    data: &[u8],
230    dict: &CompressionDict,
231    max_output: usize,
232) -> io::Result<Vec<u8>> {
233    thread_local! {
234        static DICT_DC: std::cell::RefCell<Option<(u64, zstd::bulk::Decompressor<'static>)>> =
235            const { std::cell::RefCell::new(None) };
236    }
237    let dict_key = dict.cache_id();
238
239    DICT_DC.with(|cell| {
240        let mut slot = cell.borrow_mut();
241        if slot.as_ref().is_none_or(|(key, _)| *key != dict_key) {
242            let dc = zstd::bulk::Decompressor::with_dictionary(dict.as_bytes())
243                .map_err(io::Error::other)?;
244            *slot = Some((dict_key, dc));
245        }
246        slot.as_mut()
247            .unwrap()
248            .1
249            .decompress(data, max_output)
250            .or_else(|_| {
251                let decoder = zstd::Decoder::with_dictionary(data, dict.as_bytes())?;
252                read_limited(decoder, max_output)
253            })
254    })
255}
256
257fn read_limited(mut reader: impl Read, max_output: usize) -> io::Result<Vec<u8>> {
258    let read_limit = u64::try_from(max_output)
259        .unwrap_or(u64::MAX)
260        .saturating_add(1);
261    let initial_capacity = max_output.min(DECOMPRESS_CAPACITY);
262    let mut output = Vec::with_capacity(initial_capacity);
263    reader.by_ref().take(read_limit).read_to_end(&mut output)?;
264    if output.len() > max_output {
265        return Err(io::Error::new(
266            io::ErrorKind::InvalidData,
267            "decompressed data exceeds configured limit",
268        ));
269    }
270    Ok(output)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_roundtrip() {
279        let data = b"Hello, World! This is a test of compression.".repeat(100);
280        let compressed = compress(&data, CompressionLevel::default()).unwrap();
281        let decompressed = decompress(&compressed).unwrap();
282        assert_eq!(data, decompressed.as_slice());
283        assert!(compressed.len() < data.len());
284    }
285
286    #[test]
287    fn test_empty_data() {
288        let data: &[u8] = &[];
289        let compressed = compress(data, CompressionLevel::default()).unwrap();
290        let decompressed = decompress(&compressed).unwrap();
291        assert!(decompressed.is_empty());
292    }
293
294    #[test]
295    fn test_compression_levels() {
296        let data = b"Test data for compression levels".repeat(100);
297        for level in [1, 3, 9, 19] {
298            let compressed = compress(&data, CompressionLevel(level)).unwrap();
299            let decompressed = decompress(&compressed).unwrap();
300            assert_eq!(data.as_slice(), decompressed.as_slice());
301        }
302    }
303
304    #[test]
305    fn test_limited_decompression_rejects_oversized_output() {
306        let data = vec![7u8; 4096];
307        let compressed = compress(&data, CompressionLevel::default()).unwrap();
308        assert!(decompress_limited(&compressed, 1024).is_err());
309        assert_eq!(decompress_limited(&compressed, data.len()).unwrap(), data);
310    }
311
312    #[test]
313    fn test_dictionary_cache_identity_is_stable_and_unique() {
314        let first = CompressionDict::from_bytes(b"first dictionary material".repeat(16));
315        let first_clone = first.clone();
316        let second = CompressionDict::from_bytes(b"second dictionary material".repeat(16));
317        assert_eq!(first.cache_id(), first_clone.cache_id());
318        assert_ne!(first.cache_id(), second.cache_id());
319
320        let payload = b"dictionary cache switches must rebuild their codec state".repeat(64);
321        for dict in [&first, &second, &first_clone] {
322            let compressed =
323                compress_with_dict(&payload, CompressionLevel::default(), dict).unwrap();
324            assert_eq!(
325                decompress_with_dict_limited(&compressed, dict, payload.len()).unwrap(),
326                payload
327            );
328        }
329    }
330}