Skip to main content

turbo_persistence/
compression.rs

1#[cfg(not(miri))]
2use std::cell::RefCell;
3use std::{mem::MaybeUninit, rc::Rc, sync::Arc};
4
5#[cfg(not(miri))]
6use anyhow::Context;
7use anyhow::{Result, ensure};
8#[cfg(not(miri))]
9use lz4_flex::block::{
10    CompressTable, compress_into_with_table, decompress_into, get_maximum_output_size,
11};
12
13/// Compression algorithm used for a family's SST blocks and blob values.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15#[repr(u8)]
16pub enum Compression {
17    /// Fast LZ4 compression using the default acceleration level.
18    #[default]
19    Lz4 = 0,
20    /// Zstandard compression at level 3.
21    Zstd3 = 1,
22}
23
24#[cfg(not(miri))]
25thread_local! {
26    /// Reuse lz4_flex's large hash table across independent blocks. Starting large improves
27    /// compression speed and produces faster-to-decode streams for typical persistence blocks.
28    static LZ4_COMPRESS_TABLE: RefCell<CompressTable> = RefCell::new(CompressTable::large());
29
30    /// Zstd decompression contexts are reusable and relatively expensive to create. Keep one per
31    /// worker thread to avoid allocation on every block read without a global lock.
32    static ZSTD_DECOMPRESSOR: RefCell<zstd::bulk::Decompressor<'static>> = RefCell::new(
33        zstd::bulk::Decompressor::new().expect("zstd decompressor initialization should succeed")
34    );
35}
36
37/// Decompresses `block` into `dest`, verifying the output length matches `expected_len`.
38fn decompress_block(
39    compression: Compression,
40    block: &[u8],
41    dest: &mut [u8],
42    expected_len: u32,
43) -> Result<()> {
44    debug_assert!(
45        expected_len > 0,
46        "decompress_block called with uncompressed_length=0; uncompressed blocks are served \
47         directly from their backing"
48    );
49    #[cfg(not(miri))]
50    {
51        let bytes_written = match compression {
52            Compression::Lz4 => decompress_into(block, dest).map_err(anyhow::Error::from),
53            Compression::Zstd3 => ZSTD_DECOMPRESSOR.with_borrow_mut(|decompressor| {
54                decompressor
55                    .decompress_to_buffer(block, dest)
56                    .map_err(anyhow::Error::from)
57            }),
58        }
59        .with_context(|| {
60            format!(
61                "Failed to decompress {compression:?} block ({} bytes compressed, {} bytes \
62                 uncompressed)",
63                block.len(),
64                expected_len
65            )
66        })?;
67        ensure!(
68            bytes_written == expected_len as usize,
69            "Decompressed length does not match expected length: decompressed {bytes_written} \
70             bytes, expected {expected_len}"
71        );
72    }
73    #[cfg(miri)]
74    {
75        // Compression is skipped under Miri, so Miri-created blob payloads are verbatim.
76        let _ = compression;
77        ensure!(
78            block.len() == expected_len as usize,
79            "Miri builds skip compression, so a compressed block cannot be read under Miri"
80        );
81        dest.copy_from_slice(block);
82    }
83    Ok(())
84}
85
86/// Decompresses a block into an Arc allocation.
87///
88/// The caller must ensure `uncompressed_length > 0` (i.e., the block is actually compressed).
89/// Uncompressed blocks should be handled via zero-copy mmap slices before calling this.
90pub(crate) fn decompress_into_arc(
91    compression: Compression,
92    uncompressed_length: u32,
93    block: &[u8],
94) -> Result<Arc<[u8]>> {
95    // Allocate directly into an Arc to avoid a copy. The buffer is uninitialized;
96    // decompression will overwrite it completely (verified by decompress_block).
97    let buffer: Arc<[MaybeUninit<u8>]> = Arc::new_uninit_slice(uncompressed_length as usize);
98    // Safety: decompression will fully initialize the buffer (verified by the length check in
99    // decompress_block).
100    let mut buffer = unsafe { buffer.assume_init() };
101    // We just created this Arc so refcount is 1; get_mut always succeeds.
102    let dest = Arc::get_mut(&mut buffer).expect("Arc refcount should be 1");
103    decompress_block(compression, block, dest, uncompressed_length)?;
104    Ok(buffer)
105}
106
107/// Like [`decompress_into_arc`] but returns an `Rc<[u8]>` for thread-local use.
108pub(crate) fn decompress_into_rc(
109    compression: Compression,
110    uncompressed_length: u32,
111    block: &[u8],
112) -> Result<Rc<[u8]>> {
113    let buffer: Rc<[MaybeUninit<u8>]> = Rc::new_uninit_slice(uncompressed_length as usize);
114    // Safety: decompression will fully initialize the buffer (verified by the length check in
115    // decompress_block).
116    let mut buffer = unsafe { buffer.assume_init() };
117    let dest = Rc::get_mut(&mut buffer).expect("Rc refcount should be 1");
118    decompress_block(compression, block, dest, uncompressed_length)?;
119    Ok(buffer)
120}
121
122/// Computes a CRC32 checksum of a byte slice.
123pub fn checksum_block(data: &[u8]) -> u32 {
124    crc32fast::hash(data)
125}
126
127/// Reusable compressor for a stream of blocks using the same family configuration.
128pub(crate) struct Compressor {
129    compression: Compression,
130    #[cfg(not(miri))]
131    zstd: Option<zstd::bulk::Compressor<'static>>,
132}
133
134impl Compressor {
135    pub(crate) fn new(compression: Compression) -> Result<Self> {
136        #[cfg(not(miri))]
137        let zstd = match compression {
138            Compression::Zstd3 => {
139                Some(zstd::bulk::Compressor::new(3).context("Failed to create zstd compressor")?)
140            }
141            Compression::Lz4 => None,
142        };
143        Ok(Self {
144            compression,
145            #[cfg(not(miri))]
146            zstd,
147        })
148    }
149
150    /// Compresses `block` into reusable storage, replacing its contents.
151    #[tracing::instrument(level = "trace", skip_all)]
152    pub(crate) fn compress_into_buffer(
153        &mut self,
154        block: &[u8],
155        buffer: &mut Vec<u8>,
156    ) -> Result<()> {
157        buffer.clear();
158        #[cfg(not(miri))]
159        match self.compression {
160            Compression::Lz4 => {
161                let max_output_size = get_maximum_output_size(block.len());
162                buffer.reserve(max_output_size);
163                // SAFETY: `reserve` guarantees at least `max_output_size` writable bytes from
164                // `as_mut_ptr`. lz4_flex is built without `safe-encode`; its `SliceSink` explicitly
165                // supports possibly uninitialized output and initializes every byte before
166                // advancing the returned length. The Vec remains logically empty until compression
167                // succeeds, then `set_len` exposes exactly that initialized prefix.
168                let output =
169                    unsafe { std::slice::from_raw_parts_mut(buffer.as_mut_ptr(), max_output_size) };
170                let compressed_len = LZ4_COMPRESS_TABLE
171                    .with_borrow_mut(|table| compress_into_with_table(block, output, table))
172                    .context("LZ4 compression failed")?;
173                // SAFETY: `compress_into_with_table` initialized this many bytes in `buffer` above.
174                unsafe { buffer.set_len(compressed_len) };
175            }
176            Compression::Zstd3 => {
177                buffer.reserve(zstd::zstd_safe::compress_bound(block.len()));
178                self.zstd
179                    .as_mut()
180                    .expect("zstd compressor not initialized")
181                    .compress_to_buffer(block, buffer)
182                    .context("zstd compression failed")?;
183            }
184        }
185        #[cfg(miri)]
186        {
187            // Compression is deliberately skipped under Miri. This avoids native Zstd, and using
188            // the same raw representation for both algorithms keeps blob reads on the matching
189            // copy path above. The caller's savings check stores SST blocks as uncompressed.
190            let _ = self.compression;
191            buffer.extend_from_slice(block);
192        }
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn compression_round_trips() {
203        let input = b"turbo persistence compression ".repeat(1024);
204        for compression in [Compression::Lz4, Compression::Zstd3] {
205            let mut compressor = Compressor::new(compression).unwrap();
206            let mut compressed = Vec::new();
207            compressor
208                .compress_into_buffer(&input, &mut compressed)
209                .unwrap();
210            let output = decompress_into_arc(compression, input.len() as u32, &compressed).unwrap();
211            assert_eq!(&*output, input);
212        }
213    }
214}