#[cfg(test)]
use crate::error::Result;
#[cfg(test)]
use std::path::{Path, PathBuf};
pub const CRC_CHUNK_SIZE: usize = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CrcTrailer {
None,
EmptyFinalChunk,
}
pub(crate) fn assemble_crc_bytes(chunk_crcs: &[u32], trailer: CrcTrailer) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + chunk_crcs.len() * 4 + 4);
out.extend_from_slice(&(CRC_CHUNK_SIZE as i32).to_be_bytes());
for crc in chunk_crcs {
out.extend_from_slice(&crc.to_be_bytes());
}
if matches!(trailer, CrcTrailer::EmptyFinalChunk) && !chunk_crcs.is_empty() {
out.extend_from_slice(&0u32.to_be_bytes());
}
out
}
#[derive(Debug, Default)]
pub(crate) struct StreamingCrc {
whole: crc32fast::Hasher,
chunk: crc32fast::Hasher,
chunk_filled: usize,
chunk_crcs: Vec<u32>,
}
impl StreamingCrc {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn update(&mut self, bytes: &[u8]) {
self.whole.update(bytes);
let mut remaining = bytes;
while !remaining.is_empty() {
let take = (CRC_CHUNK_SIZE - self.chunk_filled).min(remaining.len());
self.chunk.update(&remaining[..take]);
self.chunk_filled += take;
remaining = &remaining[take..];
if self.chunk_filled == CRC_CHUNK_SIZE {
let done = std::mem::replace(&mut self.chunk, crc32fast::Hasher::new());
self.chunk_crcs.push(done.finalize());
self.chunk_filled = 0;
}
}
}
pub(crate) fn finalize(mut self) -> (u32, Vec<u32>) {
if self.chunk_filled > 0 {
self.chunk_crcs.push(self.chunk.finalize());
}
(self.whole.finalize(), self.chunk_crcs)
}
}
#[cfg(test)]
pub(super) async fn build_crc_bytes(data_path: &Path, trailer: CrcTrailer) -> Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(data_path).await?;
crate::storage::sstable::work_counters::add_data_db_checksum_full_read();
let mut chunk_crcs = Vec::new();
let mut buffer = vec![0u8; CRC_CHUNK_SIZE];
let mut filled = 0usize;
loop {
let n = file.read(&mut buffer[filled..]).await?;
if n == 0 {
if filled > 0 {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&buffer[..filled]);
chunk_crcs.push(hasher.finalize());
}
break;
}
filled += n;
if filled == CRC_CHUNK_SIZE {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&buffer[..filled]);
chunk_crcs.push(hasher.finalize());
filled = 0;
}
}
Ok(assemble_crc_bytes(&chunk_crcs, trailer))
}
#[cfg(test)]
pub(crate) async fn write_crc_db(
data_path: &Path,
crc_path: PathBuf,
trailer: CrcTrailer,
) -> Result<PathBuf> {
let bytes = build_crc_bytes(data_path, trailer).await?;
tokio::fs::write(&crc_path, bytes).await?;
Ok(crc_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn empty_data_yields_header_only() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
tokio::fs::write(&data, b"").await.expect("write data");
let bytes = build_crc_bytes(&data, CrcTrailer::None)
.await
.expect("crc bytes");
assert_eq!(bytes, (CRC_CHUNK_SIZE as i32).to_be_bytes().to_vec());
}
#[tokio::test]
async fn empty_data_with_compaction_trailer_stays_header_only() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
tokio::fs::write(&data, b"").await.expect("write data");
let header_only = (CRC_CHUNK_SIZE as i32).to_be_bytes().to_vec();
let flush = build_crc_bytes(&data, CrcTrailer::None)
.await
.expect("flush crc bytes");
let compaction = build_crc_bytes(&data, CrcTrailer::EmptyFinalChunk)
.await
.expect("compaction crc bytes");
assert_eq!(
flush, header_only,
"empty Data.db on the flush path is header-only"
);
assert_eq!(
compaction, header_only,
"empty Data.db on the compaction path stays header-only (no trailing 00000000)"
);
}
#[tokio::test]
async fn compaction_trailer_appends_one_empty_chunk_crc32_zero() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
let payload = b"compaction crc.db trailer parity";
tokio::fs::write(&data, payload).await.expect("write data");
let flush = build_crc_bytes(&data, CrcTrailer::None)
.await
.expect("flush crc bytes");
let compaction = build_crc_bytes(&data, CrcTrailer::EmptyFinalChunk)
.await
.expect("compaction crc bytes");
assert_eq!(flush.len(), 8, "flush = header + 1 chunk crc");
assert_eq!(
compaction.len(),
flush.len() + 4,
"compaction appends one trailing chunk crc"
);
assert_eq!(
&compaction[..flush.len()],
&flush[..],
"compaction must be flush bytes plus the trailer (flush output unchanged)"
);
assert_eq!(
&compaction[flush.len()..],
&[0u8, 0, 0, 0],
"trailing chunk crc32 of an empty buffer is 0"
);
}
#[tokio::test]
async fn single_chunk_crc_matches_whole_file_crc32() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
let payload = b"hello cqlite crc.db parity";
tokio::fs::write(&data, payload).await.expect("write data");
let bytes = build_crc_bytes(&data, CrcTrailer::None)
.await
.expect("crc bytes");
assert_eq!(bytes.len(), 8, "header + 1 crc");
let header = i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
assert_eq!(header, CRC_CHUNK_SIZE as i32);
let stored = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let mut hasher = crc32fast::Hasher::new();
hasher.update(payload);
assert_eq!(stored, hasher.finalize());
}
#[tokio::test]
async fn multi_chunk_emits_one_crc_per_chunk() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
let len = CRC_CHUNK_SIZE * 2 + CRC_CHUNK_SIZE / 2;
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
tokio::fs::write(&data, &payload).await.expect("write data");
let bytes = build_crc_bytes(&data, CrcTrailer::None)
.await
.expect("crc bytes");
assert_eq!(bytes.len(), 4 + 3 * 4);
let mut pos = 4;
for chunk in payload.chunks(CRC_CHUNK_SIZE) {
let stored =
u32::from_be_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]]);
let mut hasher = crc32fast::Hasher::new();
hasher.update(chunk);
assert_eq!(stored, hasher.finalize());
pos += 4;
}
}
async fn assert_incremental_matches_reread(payload: &[u8], trailer: CrcTrailer, split: usize) {
use crate::storage::sstable::writer::SSTableWriter;
let mut acc = StreamingCrc::new();
for run in payload.chunks(split.max(1)) {
acc.update(run);
}
let (inc_digest, inc_chunk_crcs) = acc.finalize();
let inc_crc_db = assemble_crc_bytes(&inc_chunk_crcs, trailer);
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
tokio::fs::write(&data, payload).await.expect("write data");
let reread_crc_db = build_crc_bytes(&data, trailer)
.await
.expect("build_crc_bytes");
let reread_digest = SSTableWriter::compute_crc32(&data)
.await
.expect("compute_crc32");
assert_eq!(
inc_crc_db, reread_crc_db,
"incremental CRC.db must equal re-read oracle (len={}, trailer={trailer:?}, split={split})",
payload.len()
);
assert_eq!(
inc_digest,
reread_digest,
"incremental Digest.crc32 must equal re-read oracle (len={}, split={split})",
payload.len()
);
}
#[tokio::test]
async fn incremental_checksums_match_reread_oracle_all_sizes() {
let sizes = [
100usize,
CRC_CHUNK_SIZE,
CRC_CHUNK_SIZE + 100,
CRC_CHUNK_SIZE * 2,
CRC_CHUNK_SIZE * 2 + 100,
];
let splits = [1usize, 7_000, CRC_CHUNK_SIZE, usize::MAX];
for &len in &sizes {
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
for &trailer in &[CrcTrailer::None, CrcTrailer::EmptyFinalChunk] {
for &split in &splits {
assert_incremental_matches_reread(&payload, trailer, split).await;
}
}
}
}
#[tokio::test]
async fn incremental_empty_matches_reread_oracle() {
assert_incremental_matches_reread(&[], CrcTrailer::None, 1).await;
assert_incremental_matches_reread(&[], CrcTrailer::EmptyFinalChunk, 1).await;
}
}