use std::cell::Cell;
use std::sync::Arc;
use compact_str::CompactString;
pub(crate) const MAX_DICTIONARY_BYTES: usize = 64 * 1024;
pub(crate) const MAX_RATIO_PERCENT: usize = 90;
const KEEP_SCRATCH_BYTES: usize = 1024 * 1024;
thread_local! {
static SCRATCH: Cell<Vec<u8>> = const { Cell::new(Vec::new()) };
}
#[doc(hidden)]
pub struct CompressionDictionary {
bytes: Box<[u8]>,
}
impl CompressionDictionary {
pub(crate) fn new(mut bytes: Vec<u8>) -> Option<Arc<CompressionDictionary>> {
if bytes.len() > MAX_DICTIONARY_BYTES {
bytes.drain(..bytes.len() - MAX_DICTIONARY_BYTES);
}
(bytes.len() >= 16).then(|| {
Arc::new(CompressionDictionary {
bytes: bytes.into_boxed_slice(),
})
})
}
pub fn len(&self) -> usize {
self.bytes.len()
}
pub fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
pub(crate) fn bytes(&self) -> &[u8] {
&self.bytes
}
}
impl std::fmt::Debug for CompressionDictionary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CompressionDictionary({} bytes)", self.bytes.len())
}
}
#[doc(hidden)]
pub struct CompressedText {
len: u32,
dict: Option<Arc<CompressionDictionary>>,
bytes: Box<[u8]>,
}
impl CompressedText {
pub(crate) fn compress(
text: &str,
dict: Option<&Arc<CompressionDictionary>>,
) -> Option<CompressedText> {
let plain = text.as_bytes();
let len = u32::try_from(plain.len()).ok()?;
let mut out = vec![0u8; lz4_flex::block::get_maximum_output_size(plain.len())];
let written = match dict {
Some(dict) => lz4_flex::block::compress_into_with_dict(plain, &mut out, dict.bytes()),
None => lz4_flex::block::compress_into(plain, &mut out),
}
.ok()?;
if written.saturating_mul(100) > plain.len().saturating_mul(MAX_RATIO_PERCENT) {
return None;
}
out.truncate(written);
let compressed = CompressedText {
len,
dict: dict.cloned(),
bytes: out.into_boxed_slice(),
};
let mut check = Vec::new();
if compressed.decompress_into(&mut check) && check == plain {
Some(compressed)
} else {
tracing::error!(
len = plain.len(),
"compressed property: the round trip check failed; the value stays plain"
);
None
}
}
pub fn plain_len(&self) -> usize {
self.len as usize
}
pub fn stored_len(&self) -> usize {
self.bytes.len()
}
pub fn dictionary(&self) -> Option<&Arc<CompressionDictionary>> {
self.dict.as_ref()
}
fn decompress_into(&self, out: &mut Vec<u8>) -> bool {
out.clear();
out.resize(self.len as usize, 0);
let result = match &self.dict {
Some(dict) => {
lz4_flex::block::decompress_into_with_dict(&self.bytes, out, dict.bytes())
}
None => lz4_flex::block::decompress_into(&self.bytes, out),
};
matches!(result, Ok(n) if n == self.len as usize)
}
#[inline(never)]
pub fn with_text<R>(&self, f: impl FnOnce(&str) -> R) -> R {
let mut buffer = SCRATCH.with(Cell::take);
let text = if self.decompress_into(&mut buffer) {
match std::str::from_utf8(&buffer) {
Ok(text) => text,
Err(_) => {
tracing::error!("compressed property: decompressed bytes are not UTF-8");
""
}
}
} else {
tracing::error!("compressed property: the value does not decompress");
""
};
let result = f(text);
if buffer.capacity() <= KEEP_SCRATCH_BYTES {
SCRATCH.with(|scratch| {
let kept = scratch.take();
scratch.set(if kept.capacity() > buffer.capacity() {
kept
} else {
buffer
});
});
}
result
}
#[inline(never)]
pub fn text(&self) -> CompactString {
self.with_text(|text| CompactString::new(text))
}
pub(crate) fn logical_eq(&self, other: &CompressedText) -> bool {
if self.len != other.len {
return false;
}
let same_dict = match (&self.dict, &other.dict) {
(None, None) => true,
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
_ => false,
};
if same_dict && self.bytes == other.bytes {
return true;
}
self.with_text(|a| other.with_text(|b| a == b))
}
pub(crate) fn eq_str(&self, text: &str) -> bool {
self.len as usize == text.len() && self.with_text(|plain| plain == text)
}
pub(crate) fn heap_bytes(&self) -> u64 {
(std::mem::size_of::<CompressedText>() + self.bytes.len()) as u64
}
}
impl Clone for CompressedText {
#[inline(never)]
fn clone(&self) -> Self {
CompressedText {
len: self.len,
dict: self.dict.clone(),
bytes: self.bytes.clone(),
}
}
}
impl std::fmt::Debug for CompressedText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.with_text(|text| write!(f, "\"{text}\""))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(len: usize) -> String {
let words = [
"career ",
"engineer ",
"Rust ",
"graph ",
"database ",
"team ",
];
let mut out = String::new();
let mut i = 0;
while out.len() < len {
out.push_str(words[i % words.len()]);
i += 1;
}
out.truncate(len);
out
}
#[test]
fn round_trips_ascii_and_multibyte_text() {
for text in [sample(1000), "Grüße, 東京, 🎉 ".repeat(40)] {
let compressed = CompressedText::compress(&text, None).expect("compressible");
assert_eq!(compressed.plain_len(), text.len());
assert!(compressed.stored_len() < text.len());
assert_eq!(compressed.text(), text);
assert!(compressed.eq_str(&text));
}
}
#[test]
fn incompressible_text_stays_plain() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let text: String = (0..2000)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
char::from(b'!' + (state % 90) as u8)
})
.collect();
assert!(CompressedText::compress(&text, None).is_none());
assert!(CompressedText::compress("", None).is_none());
}
#[test]
fn dictionary_values_decompress_on_their_own() {
let dict = CompressionDictionary::new(sample(8000).into_bytes()).expect("dictionary");
let text = sample(600);
let with = CompressedText::compress(&text, Some(&dict)).expect("compressible");
let without = CompressedText::compress(&text, None).expect("compressible");
assert!(with.stored_len() <= without.stored_len());
assert_eq!(with.text(), text);
assert!(with.logical_eq(&without));
assert!(with.with_text(|a| without.with_text(|b| a == b)));
}
#[test]
fn small_samples_make_no_dictionary() {
assert!(CompressionDictionary::new(b"short".to_vec()).is_none());
let big = CompressionDictionary::new(vec![b'x'; MAX_DICTIONARY_BYTES + 10]).expect("dict");
assert_eq!(big.len(), MAX_DICTIONARY_BYTES);
}
}