From eab95a9156c4d5c65fcbb8b414728f40d84da752 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:55:42 +0000 Subject: [PATCH 1/8] Add CDC chunk records to blob meta format and CDC dedup build mode Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- Cargo.lock | 7 + nydus-format/src/blob/metadata.rs | 410 ++++++++++++++++++++++++++++-- nydus-format/src/blob/mod.rs | 6 +- nydus/Cargo.toml | 1 + nydus/src/bin/nydus/build.rs | 19 ++ nydus/src/build/blob_chunk.rs | 126 ++++++++- nydus/src/build/merge.rs | 1 + nydus/src/build/mod.rs | 12 + 8 files changed, 543 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb2a1f3eccc..454c7da28a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1375,6 +1375,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastcdc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf51ceb43e96afbfe4dd5c6f6082af5dfd60e220820b8123792d61963f2ce6bc" + [[package]] name = "fastrand" version = "2.4.1" @@ -2838,6 +2844,7 @@ dependencies = [ "blake3", "clap", "crc32c", + "fastcdc", "fuser", "http-body-util", "hyper", diff --git a/nydus-format/src/blob/metadata.rs b/nydus-format/src/blob/metadata.rs index 47be896226e..55481ba80c0 100644 --- a/nydus-format/src/blob/metadata.rs +++ b/nydus-format/src/blob/metadata.rs @@ -39,9 +39,10 @@ const BLOB_METADATA_MAX_BLOCK_BITS: u8 = 19; const BLOB_METADATA_HEADER_CRC32_OFFSET: usize = 16; /// Bytes of the header actually carrying fields; the rest of the 4 KiB /// header block is a reserved compat area (writer-zeroed, reader-ignored). -const BLOB_METADATA_HEADER_FIELD_BYTES: usize = 56; +const BLOB_METADATA_HEADER_FIELD_BYTES: usize = 64; const BLOB_METADATA_GROUP_RESERVED: [u8; 6] = [0u8; 6]; const BLOB_METADATA_CHUNK_RESERVED: u32 = 0; +const BLOB_METADATA_CDC_CHUNK_RESERVED: u32 = 0; bitflags! { /// Feature bits, split EROFS-style: the low 16 bits are **incompatible** @@ -55,6 +56,13 @@ bitflags! { pub struct BlobMetadataFlags: u32 { const COMPRESSOR_ZSTD = 1 << 0; const DIGESTER_BLAKE3 = 1 << 1; + /// Incompat: the chunk table holds variable-size CDC (content-defined + /// chunking) records ([`BlobMetadataCdcChunk`], 56 bytes each) instead + /// of fixed-size [`BlobMetadataChunk`] records. Groups then describe + /// the deduplicated *unique* data stream, while EROFS inode chunk + /// indexes keep pointing into the dense *logical* address space whose + /// size is carried by the header `logical_block_count` field. + const CHUNK_CDC = 1 << 2; } } @@ -159,6 +167,12 @@ pub struct BlobMetadataHeader { /// `chunk_block_bits`. The read path maps a block to its group with /// `block >> group_block_bits`. group_block_bits: u8, + /// Size of the dense logical (deduplicated-view) address space in 4 KiB + /// blocks. Only meaningful when [`BlobMetadataFlags::CHUNK_CDC`] is set: + /// with dedup the logical space referenced by EROFS chunk indexes is + /// larger than the unique data space described by the groups, so it can + /// no longer be derived from the group table. Zero for non-CDC blobs. + logical_block_count: u64, } const _: () = assert!(size_of::() == BLOB_METADATA_HEADER_FIELD_BYTES); @@ -177,6 +191,7 @@ impl Default for BlobMetadataHeader { group_count: 0, chunk_block_bits: BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT.trailing_zeros() as u8, group_block_bits: BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT.trailing_zeros() as u8, + logical_block_count: 0, } } } @@ -239,8 +254,28 @@ impl BlobMetadataHeader { self.groups_offset } + /// Whether the chunk table holds variable-size CDC records. + pub fn is_cdc(&self) -> bool { + self.flags().contains(BlobMetadataFlags::CHUNK_CDC) + } + + /// Size of the dense logical address space in 4 KiB blocks (CDC blobs + /// only; zero otherwise). + pub fn logical_block_count(&self) -> u64 { + self.logical_block_count + } + + /// On-disk size of one chunk record, depending on the chunk table kind. + fn chunk_record_size(&self) -> u64 { + if self.is_cdc() { + size_of::() as u64 + } else { + size_of::() as u64 + } + } + pub fn chunk_bytes(&self) -> u64 { - self.chunk_count as u64 * size_of::() as u64 + self.chunk_count as u64 * self.chunk_record_size() } pub fn group_bytes(&self) -> u64 { @@ -264,11 +299,16 @@ impl BlobMetadataHeader { self.chunks_offset = BLOB_METADATA_HEADER_SIZE; self.groups_offset = self .chunks_offset - .checked_add(chunk_count as u64 * size_of::() as u64) + .checked_add(chunk_count as u64 * self.chunk_record_size()) .ok_or_else(|| Error::Overflow("blob meta group offset overflow".to_string()))?; Ok(()) } + fn set_cdc(&mut self, logical_block_count: u64) { + self.flags |= BlobMetadataFlags::CHUNK_CDC.bits(); + self.logical_block_count = logical_block_count; + } + fn set_chunk_block_count(&mut self, blocks: u32) -> Result<()> { self.chunk_block_bits = block_count_to_bits(blocks, "chunk")?; Ok(()) @@ -318,7 +358,9 @@ impl BlobMetadataHeader { self.groups_offset ))); } - if self.chunks_offset % align_of::() as u64 != 0 { + if self.chunks_offset % align_of::() as u64 != 0 + || self.chunks_offset % align_of::() as u64 != 0 + { return Err(Error::InvalidImage( "blob meta chunks offset is not aligned".to_string(), )); @@ -328,6 +370,17 @@ impl BlobMetadataHeader { "blob meta groups offset is not aligned".to_string(), )); } + if self.is_cdc() { + if self.logical_block_count == 0 && self.chunk_count != 0 { + return Err(Error::InvalidImage( + "blob meta CDC logical block count must be non-zero".to_string(), + )); + } + } else if self.logical_block_count != 0 { + return Err(Error::InvalidImage( + "blob meta logical block count requires the CDC flag".to_string(), + )); + } Ok(()) } @@ -362,7 +415,8 @@ impl BlobMetadataHeader { data[48] = self.chunk_block_bits; data[49] = self.group_block_bits; // data[50..56] stays zero: reserved after the two u8 exponents. - // data[56..4096] stays zero: reserved header tail. + data[56..64].copy_from_slice(&self.logical_block_count.to_le_bytes()); + // data[64..4096] stays zero: reserved header tail. data } @@ -385,6 +439,7 @@ impl BlobMetadataHeader { reader.read_exact(&mut pad)?; bits }, + logical_block_count: read_u64_from(reader)?, }; // The rest of the header block is reserved for future compat fields. // Writers zero it, but readers deliberately do not enforce that @@ -704,9 +759,130 @@ impl BlobMetadataChunk { } } +/// A variable-size content-defined (CDC) chunk record — 56 bytes on disk, +/// present when [`BlobMetadataFlags::CHUNK_CDC`] is set. +/// +/// A CDC record maps one byte range of the dense *logical* address space +/// (what EROFS inode chunk indexes point at) to the byte range holding its +/// deduplicated content in the *unique* data stream (what the groups +/// compress). Several records may share the same unique range — that is the +/// deduplication. Records are sorted by logical offset and never overlap; +/// logical bytes not covered by any record (file-tail block padding and +/// holes) read as zeros. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BlobMetadataCdcChunk { + digest: [u8; 32], + logical_byte_offset: u64, + unique_byte_offset: u64, + size: u32, + reserved: u32, +} + +const _: () = assert!(size_of::() == 56); + +impl BlobMetadataCdcChunk { + pub fn new( + digest: [u8; 32], + logical_byte_offset: u64, + unique_byte_offset: u64, + size: u32, + ) -> Result { + let chunk = Self { + digest, + logical_byte_offset, + unique_byte_offset, + size, + reserved: BLOB_METADATA_CDC_CHUNK_RESERVED, + }; + chunk.validate()?; + Ok(chunk) + } + + pub fn digest(&self) -> &[u8; 32] { + &self.digest + } + + /// Byte offset of this chunk within the dense logical address space. + pub fn logical_byte_offset(&self) -> u64 { + self.logical_byte_offset + } + + /// Byte offset of this chunk's content within the unique data stream + /// described by the groups. + pub fn unique_byte_offset(&self) -> u64 { + self.unique_byte_offset + } + + pub fn size(&self) -> u32 { + self.size + } + + pub fn logical_byte_end(&self) -> u64 { + self.logical_byte_offset + self.size as u64 + } + + pub fn unique_byte_end(&self) -> u64 { + self.unique_byte_offset + self.size as u64 + } + + pub fn write_to(&self, writer: &mut dyn Write) -> Result<()> { + self.validate()?; + writer.write_all(&self.to_bytes())?; + Ok(()) + } + + fn to_bytes(self) -> [u8; 56] { + let mut data = [0u8; 56]; + data[0..32].copy_from_slice(&self.digest); + data[32..40].copy_from_slice(&self.logical_byte_offset.to_le_bytes()); + data[40..48].copy_from_slice(&self.unique_byte_offset.to_le_bytes()); + data[48..52].copy_from_slice(&self.size.to_le_bytes()); + data[52..56].copy_from_slice(&self.reserved.to_le_bytes()); + data + } + + pub fn read_from(reader: &mut dyn Read) -> Result { + let chunk = Self { + digest: read_digest(reader)?, + logical_byte_offset: read_u64_from(reader)?, + unique_byte_offset: read_u64_from(reader)?, + size: read_u32_from(reader)?, + reserved: read_u32_from(reader)?, + }; + chunk.validate()?; + Ok(chunk) + } + + fn validate(&self) -> Result<()> { + if self.size == 0 { + return Err(Error::InvalidImage( + "blob meta CDC chunk size must be non-zero".to_string(), + )); + } + self.logical_byte_offset + .checked_add(self.size as u64) + .ok_or_else(|| { + Error::Overflow("blob meta CDC chunk logical byte range overflow".to_string()) + })?; + self.unique_byte_offset + .checked_add(self.size as u64) + .ok_or_else(|| { + Error::Overflow("blob meta CDC chunk unique byte range overflow".to_string()) + })?; + if self.reserved != BLOB_METADATA_CDC_CHUNK_RESERVED { + return Err(Error::InvalidImage( + "blob meta CDC chunk reserved field must be zero".to_string(), + )); + } + Ok(()) + } +} + enum BlobMetadataStorage { Owned { chunks: Vec, + cdc_chunks: Vec, groups: Vec, }, Mapped(Mmap), @@ -750,7 +926,43 @@ impl BlobMetadata { let mut blob_metadata = Self { header, blob_id, - storage: BlobMetadataStorage::Owned { chunks, groups }, + storage: BlobMetadataStorage::Owned { + chunks, + cdc_chunks: Vec::new(), + groups, + }, + }; + blob_metadata.header.crc32 = blob_metadata.compute_crc32(); + Ok(blob_metadata) + } + + /// Construct CDC blob metadata: groups describe the deduplicated unique + /// data stream, `cdc_chunks` map the dense logical address space (of + /// `logical_block_count` 4 KiB blocks) onto it. + pub fn from_cdc_parts( + blob_id: [u8; SHA256_DIGEST_SIZE], + chunk_block_count: u32, + compressor: BlobMetadataCompressor, + groups: Vec, + cdc_chunks: Vec, + logical_block_count: u64, + ) -> Result { + let mut header = BlobMetadataHeader::default(); + header.set_chunk_block_count(chunk_block_count)?; + header.set_compressor(compressor); + header.set_cdc(logical_block_count); + header.set_counts_and_offsets(cdc_chunks.len() as u32, groups.len() as u32)?; + header.group_block_bits = infer_group_block_bits(&groups)?; + validate_groups(&groups, header.group_block_count())?; + validate_cdc_chunks(&groups, &cdc_chunks, logical_block_count)?; + let mut blob_metadata = Self { + header, + blob_id, + storage: BlobMetadataStorage::Owned { + chunks: Vec::new(), + cdc_chunks, + groups, + }, }; blob_metadata.header.crc32 = blob_metadata.compute_crc32(); Ok(blob_metadata) @@ -761,13 +973,24 @@ impl BlobMetadata { for group in self.groups() { groups.push(group.with_compressed_byte_offset_bias(bias)?); } - Self::from_parts_with_options( - self.blob_id, - self.chunk_block_count(), - self.compressor(), - groups, - self.chunks().to_vec(), - ) + if self.is_cdc() { + Self::from_cdc_parts( + self.blob_id, + self.chunk_block_count(), + self.compressor(), + groups, + self.cdc_chunks().to_vec(), + self.header.logical_block_count(), + ) + } else { + Self::from_parts_with_options( + self.blob_id, + self.chunk_block_count(), + self.compressor(), + groups, + self.chunks().to_vec(), + ) + } } pub fn header(&self) -> &BlobMetadataHeader { @@ -803,12 +1026,48 @@ impl BlobMetadata { } pub fn chunks(&self) -> &[BlobMetadataChunk] { + if self.is_cdc() { + return &[]; + } match &self.storage { BlobMetadataStorage::Owned { chunks, .. } => chunks, BlobMetadataStorage::Mapped(mmap) => mapped_chunks(mmap, &self.header), } } + /// The CDC chunk records, sorted by logical byte offset. Empty when the + /// blob is not CDC. + pub fn cdc_chunks(&self) -> &[BlobMetadataCdcChunk] { + if !self.is_cdc() { + return &[]; + } + match &self.storage { + BlobMetadataStorage::Owned { cdc_chunks, .. } => cdc_chunks, + BlobMetadataStorage::Mapped(mmap) => mapped_cdc_chunks(mmap, &self.header), + } + } + + /// Whether the chunk table holds variable-size CDC dedup records. + pub fn is_cdc(&self) -> bool { + self.header.is_cdc() + } + + /// Indexes of the CDC records overlapping the logical byte range + /// `[offset, end)`, via binary search over the sorted record table. The + /// range may include logical gaps (padding/holes) not covered by any + /// record; those bytes read as zeros. + pub fn cdc_chunks_overlapping(&self, offset: u64, end: u64) -> std::ops::Range { + let records = self.cdc_chunks(); + if offset >= end { + return 0..0; + } + // First record whose logical end is past `offset`. + let first = records.partition_point(|record| record.logical_byte_end() <= offset); + // First record starting at or past `end`. + let last = records.partition_point(|record| record.logical_byte_offset() < end); + first..last + } + pub fn groups(&self) -> &[BlobMetadataGroup] { match &self.storage { BlobMetadataStorage::Owned { groups, .. } => groups, @@ -854,6 +1113,19 @@ impl BlobMetadata { groups_total_uncompressed_size(self.groups()) } + /// Size in bytes of the dense logical address space that EROFS chunk + /// indexes point into — what the cache data file must be sized to. For a + /// CDC blob this is the header's logical block count (dedup makes it + /// larger than the unique data described by the groups); otherwise the + /// logical and unique spaces coincide. + pub fn logical_uncompressed_size(&self) -> u64 { + if self.is_cdc() { + self.header.logical_block_count() * EROFS_BLOCK_SIZE as u64 + } else { + self.total_uncompressed_size() + } + } + pub fn total_compressed_size(&self) -> u64 { groups_total_compressed_size(self.groups()) } @@ -873,6 +1145,9 @@ impl BlobMetadata { for chunk in self.chunks() { crc32 = crc32c_append(crc32, &chunk.to_bytes()); } + for chunk in self.cdc_chunks() { + crc32 = crc32c_append(crc32, &chunk.to_bytes()); + } for group in self.groups() { crc32 = crc32c_append(crc32, &group.to_bytes()); } @@ -889,6 +1164,9 @@ impl BlobMetadata { for chunk in self.chunks() { chunk.write_to(writer)?; } + for chunk in self.cdc_chunks() { + chunk.write_to(writer)?; + } for group in self.groups() { group.write_to(writer)?; } @@ -937,13 +1215,25 @@ impl BlobMetadata { validate_blob_metadata_crc32(data, &header)?; } - let mut chunks = Vec::with_capacity(header.chunk_count() as usize); + let mut chunks = Vec::new(); + let mut cdc_chunks = Vec::new(); cursor.set_position(header.chunks_offset()); - for index in 0..header.chunk_count() as usize { - chunks.push( - BlobMetadataChunk::read_from(&mut cursor) - .with_context(|| format!("failed to read blob meta chunk {index}"))?, - ); + if header.is_cdc() { + cdc_chunks.reserve(header.chunk_count() as usize); + for index in 0..header.chunk_count() as usize { + cdc_chunks.push( + BlobMetadataCdcChunk::read_from(&mut cursor) + .with_context(|| format!("failed to read blob meta CDC chunk {index}"))?, + ); + } + } else { + chunks.reserve(header.chunk_count() as usize); + for index in 0..header.chunk_count() as usize { + chunks.push( + BlobMetadataChunk::read_from(&mut cursor) + .with_context(|| format!("failed to read blob meta chunk {index}"))?, + ); + } } let mut groups = Vec::with_capacity(header.group_count() as usize); @@ -954,11 +1244,20 @@ impl BlobMetadata { .with_context(|| format!("failed to read blob meta group {index}"))?, ); } - validate_tables(&groups, &chunks, header.group_block_count())?; + if header.is_cdc() { + validate_groups(&groups, header.group_block_count())?; + validate_cdc_chunks(&groups, &cdc_chunks, header.logical_block_count())?; + } else { + validate_tables(&groups, &chunks, header.group_block_count())?; + } Ok(Self { header, blob_id, - storage: BlobMetadataStorage::Owned { chunks, groups }, + storage: BlobMetadataStorage::Owned { + chunks, + cdc_chunks, + groups, + }, }) } @@ -988,11 +1287,20 @@ impl BlobMetadata { if check_crc32 { validate_blob_metadata_crc32(&mmap, &header)?; } - validate_tables( - mapped_groups(&mmap, &header), - mapped_chunks(&mmap, &header), - header.group_block_count(), - )?; + if header.is_cdc() { + validate_groups(mapped_groups(&mmap, &header), header.group_block_count())?; + validate_cdc_chunks( + mapped_groups(&mmap, &header), + mapped_cdc_chunks(&mmap, &header), + header.logical_block_count(), + )?; + } else { + validate_tables( + mapped_groups(&mmap, &header), + mapped_chunks(&mmap, &header), + header.group_block_count(), + )?; + } Ok(Self { header, blob_id: [0u8; SHA256_DIGEST_SIZE], @@ -1206,6 +1514,45 @@ fn validate_chunks(groups: &[BlobMetadataGroup], chunks: &[BlobMetadataChunk]) - Ok(()) } +fn validate_cdc_chunks( + groups: &[BlobMetadataGroup], + chunks: &[BlobMetadataCdcChunk], + logical_block_count: u64, +) -> Result<()> { + let unique_size = groups_total_uncompressed_size(groups); + let logical_size = logical_block_count + .checked_mul(EROFS_BLOCK_SIZE as u64) + .ok_or_else(|| Error::Overflow("blob meta logical byte size overflow".to_string()))?; + let mut previous_logical_end = 0u64; + for (index, chunk) in chunks.iter().enumerate() { + chunk + .validate() + .with_context(|| format!("invalid blob meta CDC chunk {index}"))?; + // Records are sorted by logical offset and never overlap; gaps are + // allowed (padding/holes read as zeros). + if chunk.logical_byte_offset() < previous_logical_end { + return Err(Error::InvalidImage(format!( + "blob meta CDC chunks must be sorted and non-overlapping at index {index}" + ))); + } + if chunk.logical_byte_end() > logical_size { + return Err(Error::InvalidImage(format!( + "blob meta CDC chunk {index} exceeds the logical byte range" + ))); + } + // The referenced unique bytes must lie inside the group-described + // unique data stream (shared ranges are the point of dedup, so no + // uniqueness is enforced there). + if chunk.unique_byte_end() > unique_size { + return Err(Error::InvalidImage(format!( + "blob meta CDC chunk {index} exceeds the unique byte range" + ))); + } + previous_logical_end = chunk.logical_byte_end(); + } + Ok(()) +} + fn groups_total_uncompressed_size(groups: &[BlobMetadataGroup]) -> u64 { groups .last() @@ -1228,6 +1575,17 @@ fn mapped_chunks<'a>(data: &'a [u8], header: &BlobMetadataHeader) -> &'a [BlobMe unsafe { std::slice::from_raw_parts(ptr, header.chunk_count() as usize) } } +fn mapped_cdc_chunks<'a>( + data: &'a [u8], + header: &BlobMetadataHeader, +) -> &'a [BlobMetadataCdcChunk] { + let offset = header.chunks_offset() as usize; + let byte_len = header.chunk_count() as usize * size_of::(); + let bytes = &data[offset..offset + byte_len]; + let ptr = bytes.as_ptr().cast::(); + unsafe { std::slice::from_raw_parts(ptr, header.chunk_count() as usize) } +} + fn mapped_groups<'a>(data: &'a [u8], header: &BlobMetadataHeader) -> &'a [BlobMetadataGroup] { let offset = header.groups_offset() as usize; let byte_len = header.group_count() as usize * size_of::(); diff --git a/nydus-format/src/blob/mod.rs b/nydus-format/src/blob/mod.rs index f4ac9b1e3cc..c3fedcf1da9 100644 --- a/nydus-format/src/blob/mod.rs +++ b/nydus-format/src/blob/mod.rs @@ -11,9 +11,9 @@ pub mod validate; pub use footer::NYDUS_BLOB_FOOTER_ALIGNMENT; pub use footer::{BlobFooter, NYDUS_BLOB_FOOTER_SIZE}; pub use metadata::{ - BlobMetadata, BlobMetadataChunk, BlobMetadataCompressor, BlobMetadataDigester, - BlobMetadataGroup, BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, BLOB_METADATA_DEFAULT_CHUNK_SIZE, - BLOB_METADATA_SUFFIX, + BlobMetadata, BlobMetadataCdcChunk, BlobMetadataChunk, BlobMetadataCompressor, + BlobMetadataDigester, BlobMetadataGroup, BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, + BLOB_METADATA_DEFAULT_CHUNK_SIZE, BLOB_METADATA_SUFFIX, }; use std::io::Write; diff --git a/nydus/Cargo.toml b/nydus/Cargo.toml index 8bae579413f..6b5bd53a523 100644 --- a/nydus/Cargo.toml +++ b/nydus/Cargo.toml @@ -51,6 +51,7 @@ ublk = ["dep:libublk"] [dependencies] blake3 = "1" +fastcdc = "3" clap = { version = "4", features = ["derive"], optional = true } crc32c = "0.6" fuser = { version = "0.17", optional = true } diff --git a/nydus/src/bin/nydus/build.rs b/nydus/src/bin/nydus/build.rs index 6a3786c9502..ddcf516a29e 100644 --- a/nydus/src/bin/nydus/build.rs +++ b/nydus/src/bin/nydus/build.rs @@ -79,6 +79,13 @@ pub struct BuildArgs { #[arg(long, value_enum, default_value_t = Compressor::Zstd)] pub compressor: Compressor, + /// Enable content-defined chunking (FastCDC) deduplication: file data is + /// split at content-defined cut points and duplicate pieces are stored + /// only once, within and across files/layers. Produces blobs with the + /// `CHUNK_CDC` incompat blob meta flag. + #[arg(long)] + pub cdc: bool, + #[command(flatten)] pub log: cli_common::CommandLogArgs, @@ -194,6 +201,7 @@ fn run_dir_to_nydus(args: BuildArgs) -> Result<()> { compressor: args.compressor.into(), exclude: &exclude, standalone_bootstrap: args.bootstrap.is_some(), + cdc: args.cdc, }; // Fail on invalid chunk/compress geometry before creating output files. options.validate()?; @@ -230,6 +238,17 @@ fn run_dir_to_nydus(args: BuildArgs) -> Result<()> { blob_metadata_path: &blob_metadata_path, bootstrap_path: args.bootstrap.as_deref(), }); + if let Some((logical, unique)) = image.cdc_dedup_stats { + let saved = logical.saturating_sub(unique); + let percent = if logical > 0 { + saved as f64 * 100.0 / logical as f64 + } else { + 0.0 + }; + println!( + " cdc_dedup: logical {logical} bytes, unique {unique} bytes, saved {saved} bytes ({percent:.1}%)" + ); + } Ok(()) } diff --git a/nydus/src/build/blob_chunk.rs b/nydus/src/build/blob_chunk.rs index adff7035a5c..42522ba7cea 100644 --- a/nydus/src/build/blob_chunk.rs +++ b/nydus/src/build/blob_chunk.rs @@ -1,17 +1,25 @@ use crc32c::crc32c; use nydus_error::{Context, Error, Result}; use nydus_format::blob::{ - BlobMetadata, BlobMetadataChunk, BlobMetadataCompressor, BlobMetadataGroup, - BLOB_METADATA_DEFAULT_CHUNK_SIZE, + BlobMetadata, BlobMetadataCdcChunk, BlobMetadataChunk, BlobMetadataCompressor, + BlobMetadataGroup, BLOB_METADATA_DEFAULT_CHUNK_SIZE, }; use nydus_format::erofs::{ErofsChunkAddr, EROFS_BLOB_ID_SIZE, EROFS_BLOCK_SIZE, EROFS_NULL_ADDR}; use nydus_format::utils::round_up; use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::fs::File; use std::io::{Read, Write}; use std::mem; use std::path::Path; +/// FastCDC (v2020) cut-point parameters used for CDC chunk dedup, chosen from +/// dedup experiments on real images (node/golang/python/pytorch): min 4 KiB, +/// average 16 KiB, max 64 KiB gave the best dedup/metadata trade-off. +pub const CDC_MIN_CHUNK_SIZE: u32 = 4096; +pub const CDC_AVG_CHUNK_SIZE: u32 = 16384; +pub const CDC_MAX_CHUNK_SIZE: u32 = 65536; + /// Manages writing chunk data to a separate blob device. pub struct BlobWriter { file: File, @@ -25,6 +33,19 @@ pub struct BlobWriter { group_buffer: Vec, blob_metadata_groups: Vec, blob_metadata_chunks: Vec, + cdc: bool, + cdc_chunks: Vec, + // blake3 digest -> byte offset in the unique data stream. Sizes need not + // be stored: FastCDC cut points are content-defined, so equal content + // yields equal digests only for equal-size pieces (and blake3 collisions + // across sizes are not a practical concern). + cdc_dedup: HashMap<[u8; 32], u64>, + // Bytes appended to the unique (group) data stream so far, before the + // final block padding added by `finish`. + cdc_unique_len: u64, + // Real data bytes fed through the CDC splitter (excludes elided zero + // chunks and tail-block padding), for dedup statistics. + cdc_logical_len: u64, } const MAX_COMPRESSED_SIZE_PERCENT: u128 = 70; @@ -99,9 +120,32 @@ impl BlobWriter { group_buffer: Vec::with_capacity(group_size as usize), blob_metadata_groups: Vec::new(), blob_metadata_chunks: Vec::new(), + cdc: false, + cdc_chunks: Vec::new(), + cdc_dedup: HashMap::new(), + cdc_unique_len: 0, + cdc_logical_len: 0, }) } + /// Enable content-defined chunking: file data is split at FastCDC cut + /// points, deduplicated by blake3 digest, and only unique bytes enter the + /// group data stream. Must be called before any data is written. + pub fn with_cdc(mut self) -> Self { + self.cdc = true; + self + } + + pub fn is_cdc(&self) -> bool { + self.cdc + } + + /// `(logical_bytes, unique_bytes)` fed through the CDC splitter so far; + /// the difference is the data removed by deduplication. + pub fn cdc_dedup_stats(&self) -> (u64, u64) { + (self.cdc_logical_len, self.cdc_unique_len) + } + pub fn total_blocks(&self) -> u64 { self.next_blkaddr } @@ -133,14 +177,25 @@ impl BlobWriter { blob_id: [u8; EROFS_BLOB_ID_SIZE], source_offset_bias: u64, ) -> Result { - Ok(BlobMetadata::from_parts_with_options( - blob_id, - self.file_chunk_size / EROFS_BLOCK_SIZE, - self.compressor, - self.blob_metadata_groups.clone(), - self.blob_metadata_chunks.clone(), - )? - .with_compressed_offset_bias(source_offset_bias)?) + let blob_metadata = if self.cdc { + BlobMetadata::from_cdc_parts( + blob_id, + self.file_chunk_size / EROFS_BLOCK_SIZE, + self.compressor, + self.blob_metadata_groups.clone(), + self.cdc_chunks.clone(), + self.next_blkaddr, + )? + } else { + BlobMetadata::from_parts_with_options( + blob_id, + self.file_chunk_size / EROFS_BLOCK_SIZE, + self.compressor, + self.blob_metadata_groups.clone(), + self.blob_metadata_chunks.clone(), + )? + }; + Ok(blob_metadata.with_compressed_offset_bias(source_offset_bias)?) } pub fn write_blob_metadata( @@ -156,6 +211,13 @@ impl BlobWriter { } pub fn finish(&mut self) -> Result<()> { + // In CDC mode the unique data stream is byte granular, so the tail + // group must be zero padded to a whole block before it is flushed + // (groups always describe whole uncompressed blocks). + if self.cdc && !self.group_buffer.is_empty() { + let padded = round_up(self.group_buffer.len(), EROFS_BLOCK_SIZE as usize); + self.group_buffer.resize(padded, 0); + } self.flush_group()?; self.file.flush().context("failed to flush blob device") } @@ -223,6 +285,17 @@ impl BlobWriter { Error::Overflow(format!("blob meta chunk block count exceeds u32: {err}")) })?; + if self.cdc { + // CDC mode: the chunk still occupies `block_count` logical blocks + // (EROFS chunk indexes are untouched), but its real bytes are + // split at content-defined cut points and deduplicated; only + // unique pieces enter the group stream. The tail-block padding is + // never stored: uncovered logical bytes read back as zeros. + self.next_blkaddr += block_count as u64; + self.append_cdc_pieces(data, addr * EROFS_BLOCK_SIZE as u64)?; + return Ok(addr); + } + // Block-aligned chunk payload: real bytes followed by zero padding only // in its final block. let mut uncompressed = vec![0u8; write_len]; @@ -242,6 +315,39 @@ impl BlobWriter { Ok(addr) } + /// Split one fixed chunk's real bytes at FastCDC cut points, recording a + /// CDC record per piece and appending only never-seen-before pieces to + /// the group (unique data) stream. + fn append_cdc_pieces(&mut self, data: &[u8], logical_byte_base: u64) -> Result<()> { + for cut in fastcdc::v2020::FastCDC::new( + data, + CDC_MIN_CHUNK_SIZE, + CDC_AVG_CHUNK_SIZE, + CDC_MAX_CHUNK_SIZE, + ) { + let piece = &data[cut.offset..cut.offset + cut.length]; + let digest = *blake3::hash(piece).as_bytes(); + let unique_byte_offset = match self.cdc_dedup.get(&digest) { + Some(offset) => *offset, + None => { + let offset = self.cdc_unique_len; + self.append_to_group_stream(piece)?; + self.cdc_unique_len += cut.length as u64; + self.cdc_dedup.insert(digest, offset); + offset + } + }; + self.cdc_logical_len += cut.length as u64; + self.cdc_chunks.push(BlobMetadataCdcChunk::new( + digest, + logical_byte_base + cut.offset as u64, + unique_byte_offset, + cut.length as u32, + )?); + } + Ok(()) + } + /// Append block-aligned data to the current group, flushing whenever it /// fills to the group size. A chunk may straddle a group boundary, so groups /// are pure block runs of exactly `group_size` (except the last). diff --git a/nydus/src/build/merge.rs b/nydus/src/build/merge.rs index 1bf0a10fce9..e6086a4d9a8 100644 --- a/nydus/src/build/merge.rs +++ b/nydus/src/build/merge.rs @@ -796,6 +796,7 @@ mod tests { compressor: BlobMetadataCompressor::None, exclude: &exclude, standalone_bootstrap: false, + cdc: false, }, fs::File::create(&blob_path).unwrap(), ) diff --git a/nydus/src/build/mod.rs b/nydus/src/build/mod.rs index 7a83a213f2d..53adb0b07bf 100644 --- a/nydus/src/build/mod.rs +++ b/nydus/src/build/mod.rs @@ -49,6 +49,10 @@ pub struct DirImageOptions<'a> { /// Also render a standalone bootstrap whose device slot references the /// full blob digest, returned in [`DirImage::standalone_bootstrap`]. pub standalone_bootstrap: bool, + /// Split file data at content-defined (FastCDC) cut points and + /// deduplicate equal pieces so only unique bytes are stored. Marks the + /// blob meta with the `CHUNK_CDC` incompat flag. + pub cdc: bool, } /// The result of [`build_dir_image`]: the digests, blob meta and footer of @@ -61,6 +65,9 @@ pub struct DirImage { pub blob_metadata: BlobMetadata, pub footer: BlobFooter, pub standalone_bootstrap: Option>, + /// `(logical_bytes, unique_bytes)` seen by the CDC splitter when CDC was + /// enabled; the difference is the data removed by deduplication. + pub cdc_dedup_stats: Option<(u64, u64)>, } impl DirImageOptions<'_> { @@ -120,6 +127,9 @@ pub fn build_dir_image(options: &DirImageOptions<'_>, blob_out: File) -> Result< options.compress_size, options.compressor, )?; + if options.cdc { + blob_writer = blob_writer.with_cdc(); + } let mut inodes = build_tree( options.source, &mut blob_writer, @@ -147,6 +157,7 @@ pub fn build_dir_image(options: &DirImageOptions<'_>, blob_out: File) -> Result< let compressed_data_size = blob_writer.data_size(); let blob_metadata = blob_writer.blob_metadata(blob_id, 0)?; + let cdc_dedup_stats = options.cdc.then(|| blob_writer.cdc_dedup_stats()); let (blob_file, full_blob_hasher) = blob_writer.into_file_and_data_hasher(); let mut blob_writer_stream = HashingWriter::new(BufWriter::new(blob_file), full_blob_hasher); @@ -181,6 +192,7 @@ pub fn build_dir_image(options: &DirImageOptions<'_>, blob_out: File) -> Result< blob_metadata, footer, standalone_bootstrap, + cdc_dedup_stats, }) } From 9e07297dde07fc5423d15692f3148afeb9057715 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:58:54 +0000 Subject: [PATCH 2/8] CDC runtime read path in LocalBlobCache; reject CDC blobs in optimize Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- nydus-storage/src/cache/local.rs | 218 ++++++++++++++++++++++++++++++- nydus/src/optimize/mod.rs | 8 ++ 2 files changed, 220 insertions(+), 6 deletions(-) diff --git a/nydus-storage/src/cache/local.rs b/nydus-storage/src/cache/local.rs index 1122e89b97c..9a9eb68d1f3 100644 --- a/nydus-storage/src/cache/local.rs +++ b/nydus-storage/src/cache/local.rs @@ -130,14 +130,25 @@ impl LocalBlobCache { let cache_data_path = cache_dir.join(format!("{cache_key_hex}.blob.data")); - let groupmap_path = cache_dir.join(format!("{cache_key_hex}.group.map")); + // CDC blobs track readiness per CDC chunk record (the unit filled into + // the logical cache space); fixed blobs track readiness per group. + let readiness_map_path = if blob_metadata.is_cdc() { + cache_dir.join(format!("{cache_key_hex}.chunk.map")) + } else { + cache_dir.join(format!("{cache_key_hex}.group.map")) + }; + let readiness_count = if blob_metadata.is_cdc() { + blob_metadata.chunk_count() + } else { + blob_metadata.group_count() + }; // The group_map is only meaningful together with the cache data file it // describes: a leftover group_map whose data file has been removed // would claim groups are ready while reads hit sparse zeros. Note this // before creating the data file below, which would otherwise mask it. // (Removing the map while keeping the data is the safe direction and // needs no handling.) - let stale_groupmap = groupmap_path.exists() && !cache_data_path.exists(); + let stale_groupmap = readiness_map_path.exists() && !cache_data_path.exists(); // Create the cache data file eagerly, before the group_map, so that // "group_map file exists => data file exists" holds and the check above @@ -148,10 +159,10 @@ impl LocalBlobCache { .create(true) .truncate(false) .open(&cache_data_path)?; - data_file.set_len(blob_metadata.total_uncompressed_size())?; + data_file.set_len(blob_metadata.logical_uncompressed_size())?; drop(data_file); - let group_map = GroupMap::open(&groupmap_path, blob_metadata.group_count())?; + let group_map = GroupMap::open(&readiness_map_path, readiness_count)?; if stale_groupmap { // Reset in place rather than unlinking: handles already mapping // this file observe the reset, whereas a replacement inode would @@ -159,7 +170,7 @@ impl LocalBlobCache { group_map.reset()?; warn!( "stale group_map without cache data file, reset: {}", - groupmap_path.display() + readiness_map_path.display() ); } @@ -205,7 +216,7 @@ impl LocalBlobCache { .truncate(false) .open(&self.cache_data_path)?, ); - file.set_len(self.blob_metadata.total_uncompressed_size())?; + file.set_len(self.blob_metadata.logical_uncompressed_size())?; nydus_telemetry::metrics::inc_cache_opened_files(); *cache_file = Some(file.clone()); Ok(file) @@ -318,6 +329,138 @@ impl LocalBlobCache { result } + /// Ensure every CDC chunk record in `records` (indexes into the sorted + /// record table) has its bytes decoded into the cache file at its logical + /// offset. `memo` deduplicates group decodes across the records of one + /// call, since consecutive records usually reference the same group. + fn ensure_cdc_records(&self, records: Range, cache_file: &File) -> io::Result<()> { + let chunks = self.blob_metadata.cdc_chunks(); + let mut memo: HashMap> = HashMap::new(); + for index in records { + self.ensure_cdc_record(index, &chunks[index], cache_file, &mut memo)?; + } + Ok(()) + } + + /// The CDC analogue of `ensure_group`: single-flight per record within the + /// process, cross-process claim per record, then decode + publish. + fn ensure_cdc_record( + &self, + record_index: usize, + chunk: &nydus_format::blob::BlobMetadataCdcChunk, + cache_file: &File, + memo: &mut HashMap>, + ) -> io::Result<()> { + if self.group_map.is_ready(record_index)? { + nydus_telemetry::metrics::inc_cache_hit_group(); + return Ok(()); + } + + let (flight, leader) = { + let mut inflight = self.inflight_groups.lock().unwrap(); + match inflight.get(&record_index) { + Some(flight) => (flight.clone(), false), + None => { + let flight = Arc::new(GroupFlight::new()); + inflight.insert(record_index, flight.clone()); + (flight, true) + } + } + }; + if !leader { + return flight.wait(); + } + + let _guard = LeaderGuard { + flight: flight.clone(), + group_index: record_index, + inflight: &self.inflight_groups, + }; + + let result = (|| { + if self.group_map.is_ready(record_index)? { + nydus_telemetry::metrics::inc_cache_hit_group(); + return Ok(()); + } + + let _claim = self.group_locks.acquire(record_index); + if self.group_map.is_ready(record_index)? { + nydus_telemetry::metrics::inc_cache_hit_group(); + return Ok(()); + } + + self.fill_cdc_record(chunk, cache_file, memo, ReadKind::OnDemand)?; + self.group_map.set_ready(record_index)?; + nydus_telemetry::metrics::inc_cache_ondemand_fill_group(); + Ok(()) + })(); + + flight.complete(&result); + result + } + + /// Decode the group(s) covering `chunk`'s unique byte range (through + /// `memo`) and write the record's bytes into the cache file at the + /// record's logical byte offset. Does not touch the readiness map. + fn fill_cdc_record( + &self, + chunk: &nydus_format::blob::BlobMetadataCdcChunk, + cache_file: &File, + memo: &mut HashMap>, + kind: ReadKind, + ) -> io::Result<()> { + let unique_offset = chunk.unique_byte_offset(); + let unique_end = chunk.unique_byte_end(); + let first = self + .blob_metadata + .group_index_for_byte_offset(unique_offset) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "blob meta group not found"))?; + let last = self + .blob_metadata + .group_index_for_byte_offset(unique_end - 1) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "blob meta group not found"))?; + + // Callers walk records in (mostly) increasing unique offset order, so + // groups below the current record's first group are never needed + // again; dropping them bounds the memo to the record's group span. + memo.retain(|group_index, _| *group_index >= first); + + let mut bytes = vec![0u8; chunk.size() as usize]; + for group_index in first..=last { + let group = *self.blob_metadata.group_at(group_index).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "blob meta group not found") + })?; + if !memo.contains_key(&group_index) { + if let Some(recorder) = self.trace_recorder.as_ref() { + recorder.record_group_access(self.blob_index, group_index as u32); + } else { + crate::access_trace::record_group_access(self.blob_index, group_index as u32); + } + let mut buffers = GroupBuffers::default(); + let decoded = fetch_decode_validate_group_into( + &self.blob_id, + &self.blob_metadata, + &self.backend, + &group, + &mut buffers, + kind, + )?; + memo.insert(group_index, decoded.to_vec()); + } + let decoded = &memo[&group_index]; + let group_offset = group.uncompressed_byte_offset(); + let copy_start = unique_offset.max(group_offset); + let copy_end = unique_end.min(group.uncompressed_byte_end()); + bytes[(copy_start - unique_offset) as usize..(copy_end - unique_offset) as usize] + .copy_from_slice( + &decoded[(copy_start - group_offset) as usize + ..(copy_end - group_offset) as usize], + ); + } + + write_all_at(cache_file, chunk.logical_byte_offset(), &bytes) + } + /// Ensure every group overlapping `[offset, offset + len)` is decoded and /// written to the cache file. Shared by `read_at` and `ensure_range`. fn ensure_byte_range(&self, offset: u64, len: u64, cache_file: &File) -> io::Result<()> { @@ -342,6 +485,14 @@ impl LocalBlobCache { return Ok(()); } + // CDC blobs are looked up per record: binary-search the records + // overlapping the logical range; logical gaps between records are + // padding/holes that read back as zeros from the sparse cache file. + if self.blob_metadata.is_cdc() { + let records = self.blob_metadata.cdc_chunks_overlapping(offset, end); + return self.ensure_cdc_records(records, cache_file); + } + let groups = self.group_span(offset, end)?; let (first_group, last_group) = groups.into_inner(); @@ -468,6 +619,27 @@ impl BlobCache for LocalBlobCache { // Prefetch writes the bulk of the cache, so it is worth one stat to // make sure the file it fills is still the one other processes read. self.ensure_data_file_linked(&cache_file)?; + + // CDC blobs: walk records in unique-offset order so each group is + // decoded (roughly) once through the memo, and readiness is tracked + // per record. + if self.blob_metadata.is_cdc() { + let chunks = self.blob_metadata.cdc_chunks(); + let mut order: Vec = (0..chunks.len()).collect(); + order.sort_by_key(|&index| chunks[index].unique_byte_offset()); + let mut memo: HashMap> = HashMap::new(); + for index in order { + if self.group_map.is_ready(index)? { + continue; + } + self.fill_cdc_record(&chunks[index], &cache_file, &mut memo, ReadKind::Prefetch)?; + self.group_map.set_ready(index)?; + nydus_telemetry::metrics::inc_cache_fill_group(); + } + self.group_map.latch_all_ready(); + return Ok(()); + } + // Prefetch owns its decode buffers and does not take `fetch_lock`, so it // never blocks on-demand FUSE reads. The group_map is internally locked // and `set_ready` is idempotent, so racing with a read at worst decodes @@ -565,6 +737,40 @@ impl BlobCache for LocalBlobCache { let end = offset.checked_add(len).ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "blob probe range overflow") })?; + + // CDC blobs: readiness is per record. Logical gaps between records + // (padding/holes) are always "ready" — the sparse cache file already + // reads back the correct zeros there. + if self.blob_metadata.is_cdc() { + let chunks = self.blob_metadata.cdc_chunks(); + let records = self.blob_metadata.cdc_chunks_overlapping(offset, end); + let mut ranges: Vec> = Vec::new(); + let mut push = |start: u64, stop: u64| { + if start >= stop { + return; + } + match ranges.last_mut() { + Some(last) if last.end == start => last.end = stop, + _ => ranges.push(start..stop), + } + }; + let mut cursor = offset; + for index in records { + let chunk = &chunks[index]; + let chunk_start = chunk.logical_byte_offset().max(offset); + let chunk_end = chunk.logical_byte_end().min(end); + // The gap before this record is ready zeros. + push(cursor, chunk_start); + if self.group_map.is_ready(index)? { + push(chunk_start, chunk_end); + } + cursor = chunk_end; + } + // The tail gap after the last record is ready zeros. + push(cursor, end); + return Ok(ranges); + } + let (first, last) = self.group_span(offset, end)?.into_inner(); self.group_map diff --git a/nydus/src/optimize/mod.rs b/nydus/src/optimize/mod.rs index d38f776f955..0a3ceabe24c 100644 --- a/nydus/src/optimize/mod.rs +++ b/nydus/src/optimize/mod.rs @@ -107,6 +107,14 @@ pub fn build_ondemand_blob( .with_context(|| format!("failed to open source blob {blob_index}"))?, ), }; + if cache.blob_metadata().is_cdc() { + // A CDC blob's groups describe the deduplicated unique byte + // stream, not the logical space `read_at` addresses, so its group + // bytes cannot be re-sliced into an ondemand artifact this way. + return Err(Error::Unsupported(format!( + "source blob {blob_index} uses CDC chunk dedup; optimize does not support CDC blobs yet" + ))); + } let group = *cache .blob_metadata() From 54b5a171ae3993dc1478cd21ceb922a3b46190ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:05:29 +0000 Subject: [PATCH 3/8] Add tests and docs for CDC chunk dedup Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- docs/nydus.md | 56 ++++++++++++++ nydus-format/src/blob/metadata.rs | 100 ++++++++++++++++++++++++ nydus-storage/src/cache/local.rs | 115 +++++++++++++++++++++++++++- nydus/src/build/blob_chunk.rs | 64 ++++++++++++++++ nydus/tests/testsuite/nydus_core.rs | 92 ++++++++++++++++++++++ 5 files changed, 423 insertions(+), 4 deletions(-) diff --git a/docs/nydus.md b/docs/nydus.md index c5f6aba6a8a..1f2c3b973ed 100644 --- a/docs/nydus.md +++ b/docs/nydus.md @@ -158,6 +158,8 @@ Options: Group uncompressed size in bytes (must be a power of two, >= 1MiB, and >= the chunk size). Controls the uncompressed size of each blob meta group used for compression [default: 4194304] --compressor Algorithm to compress data chunks [default: zstd] [possible values: none, zstd] + --cdc + Enable content-defined chunking (FastCDC) deduplication: file data is split at content-defined cut points and duplicate pieces are stored only once, within and across files/layers. Produces blobs with the `CHUNK_CDC` incompat blob meta flag -l, --log-level Specify the logging level [trace, debug, info, warn, error] [default: info] --exclude @@ -190,6 +192,18 @@ Current implementation notes: the group is stored plain and its blob_meta group record has `compressed_size == uncompressed_block_count * 4096`. - `--compressor none` writes every group plain. +- `--cdc` enables content-defined chunking (CDC) deduplication. EROFS inode + chunk indexes and `--chunk-size` are unchanged (they still address the dense + logical uncompressed space), but beneath them each fixed chunk's bytes are + split at FastCDC v2020 cut points (min 4 KiB / avg 16 KiB / max 64 KiB) and + deduplicated by BLAKE3 digest: only never-seen-before pieces enter the group + data stream, so shared content is stored once even when files embed it at + different offsets. The blob meta chunk table then holds 56-byte CDC records + `(digest, logical_byte_offset, unique_byte_offset, size)` instead of fixed + chunk records, the header carries the `CHUNK_CDC` incompat flag plus the + logical block count, and the build summary prints a `cdc_dedup` line with + logical/unique byte counts and the dedup percentage. CDC blobs are read + through the same group cache; `nydus optimize` does not support them yet. - `--exclude ` omits paths inside the source tree from the blob and the resulting filesystem tree entirely. It accepts absolute or current-working-directory-relative paths and may be repeated. @@ -1199,6 +1213,48 @@ does not bias `uncompressed_block_offset`. Only the data region as a whole is padded to a 4 KiB boundary (so the embedded bootstrap that follows starts on a block); groups themselves are not individually padded. +### CDC (content-defined chunking) blob meta + +When a blob is built with `--cdc`, the header sets the `CHUNK_CDC` incompat +flag (`1 << 2`) and the chunk table holds 56-byte CDC records instead of the +48-byte fixed chunk records above: + +```text +CDC chunk record (56 bytes) + +u8 digest[32] BLAKE3 of the piece's bytes (the dedup key) +u64 logical_byte_offset byte position in the logical uncompressed space +u64 unique_byte_offset byte position in the deduplicated unique stream +u32 size piece length in bytes (4 KiB..64 KiB FastCDC pieces) +u32 reserved +``` + +Two address spaces are involved: + +- The **logical** space is unchanged: EROFS inode chunk indexes still point at + fixed power-of-two chunks in a dense uncompressed address space, and the + cache data file still mirrors it, so the kernel-visible format and the + read/`fetch`/`probe` APIs are untouched. Its size is + `logical_block_count * 4096`, from a new u64 header field at offset 56 (that + field must be zero for non-CDC blobs, keeping their on-disk bytes + identical). +- The **unique** space is what the group records describe: each fixed chunk's + real bytes are split at FastCDC v2020 cut points (min 4 KiB / avg 16 KiB / + max 64 KiB) and only never-seen-before pieces (by BLAKE3 digest) are + appended, byte-granular, to the group stream, which is then grouped and + compressed exactly as before. Many CDC records may reference the same + unique bytes — that sharing is the deduplication. + +Records are sorted by `logical_byte_offset` and never overlap; logical ranges +not covered by any record (tail-block padding, elided all-zero chunks) read +back as zeros. The runtime looks a read up by binary-searching the records +overlapping the logical range, maps each cold record's unique range to its +group(s) with the same `>> group_block_bits` division, decodes those groups, +and copies the record's bytes to its logical offset in the cache file; +readiness is tracked per record in a `.chunk.map` sidecar (same format as the +group map). `nydus optimize` rejects CDC blobs for now because its group +re-slicing assumes the logical and group spaces coincide. + ### Blocks, chunks and groups The three units live in two address spaces: blocks, chunks and groups are diff --git a/nydus-format/src/blob/metadata.rs b/nydus-format/src/blob/metadata.rs index 55481ba80c0..87809b980af 100644 --- a/nydus-format/src/blob/metadata.rs +++ b/nydus-format/src/blob/metadata.rs @@ -1695,6 +1695,106 @@ mod tests { assert_eq!(loaded.total_uncompressed_size(), 8192); } + #[test] + fn cdc_blob_metadata_round_trips_through_mmap() { + let dir = tempdir().unwrap(); + let path = dir.path().join("blob.meta"); + let blob_id = [0x6bu8; SHA256_DIGEST_SIZE]; + let piece_a = vec![0x11; 5000]; + let piece_b = vec![0x22; 2000]; + // One group holding 8192 unique bytes; three records over a 4-block + // (16 KiB) logical space, two of them sharing the same unique bytes. + let groups = vec![group(0, 2, 0, 8192, &vec![0u8; 8192])]; + let cdc_chunks = vec![ + BlobMetadataCdcChunk::new(digest(&piece_a), 0, 0, 5000).unwrap(), + BlobMetadataCdcChunk::new(digest(&piece_a), 8192, 0, 5000).unwrap(), + BlobMetadataCdcChunk::new(digest(&piece_b), 13500, 5000, 2000).unwrap(), + ]; + let blob_metadata = BlobMetadata::from_cdc_parts( + blob_id, + 256, + BlobMetadataCompressor::None, + groups, + cdc_chunks, + 4, + ) + .unwrap(); + + blob_metadata.save(&path).unwrap(); + let loaded = BlobMetadata::load(&path).unwrap(); + + assert!(loaded.is_cdc()); + assert_eq!(loaded.header().chunk_count(), 3); + assert_eq!(loaded.header().logical_block_count(), 4); + assert_eq!(loaded.header().chunk_record_size(), 56); + assert_eq!( + loaded.logical_uncompressed_size(), + 4 * EROFS_BLOCK_SIZE as u64 + ); + assert_eq!(loaded.total_uncompressed_size(), 8192); + // Fixed chunk records are absent in CDC mode. + assert!(loaded.chunks().is_empty()); + let records = loaded.cdc_chunks(); + assert_eq!(records.len(), 3); + assert_eq!(records[1].digest(), &digest(&piece_a)); + assert_eq!(records[1].logical_byte_offset(), 8192); + assert_eq!(records[1].unique_byte_offset(), 0); + assert_eq!(records[2].size(), 2000); + assert_eq!(records[2].unique_byte_end(), 7000); + // Range lookup: [4000, 9000) overlaps records 0 and 1 (the gap + // between them is a hole); [5000, 8192) overlaps nothing. + assert_eq!(loaded.cdc_chunks_overlapping(4000, 9000), 0..2); + assert_eq!(loaded.cdc_chunks_overlapping(5000, 8192), 1..1); + assert_eq!(loaded.cdc_chunks_overlapping(0, u64::MAX), 0..3); + + // Compressed offset bias preserves the CDC shape. + let biased = loaded.with_compressed_offset_bias(4096).unwrap(); + assert!(biased.is_cdc()); + assert_eq!(biased.groups()[0].compressed_byte_offset(), 4096); + assert_eq!(biased.cdc_chunks(), loaded.cdc_chunks()); + } + + #[test] + fn cdc_blob_metadata_rejects_bad_records() { + let groups = vec![group(0, 1, 0, 4096, &vec![0u8; 4096])]; + // Overlapping logical ranges. + let overlapping = vec![ + BlobMetadataCdcChunk::new([1u8; 32], 0, 0, 4096).unwrap(), + BlobMetadataCdcChunk::new([2u8; 32], 4095, 0, 1).unwrap(), + ]; + assert!(BlobMetadata::from_cdc_parts( + [0u8; SHA256_DIGEST_SIZE], + 256, + BlobMetadataCompressor::None, + groups.clone(), + overlapping, + 2, + ) + .is_err()); + // Unique range past the group data. + let out_of_unique = vec![BlobMetadataCdcChunk::new([1u8; 32], 0, 4000, 200).unwrap()]; + assert!(BlobMetadata::from_cdc_parts( + [0u8; SHA256_DIGEST_SIZE], + 256, + BlobMetadataCompressor::None, + groups.clone(), + out_of_unique, + 1, + ) + .is_err()); + // Logical range past the logical block count. + let out_of_logical = vec![BlobMetadataCdcChunk::new([1u8; 32], 4000, 0, 200).unwrap()]; + assert!(BlobMetadata::from_cdc_parts( + [0u8; SHA256_DIGEST_SIZE], + 256, + BlobMetadataCompressor::None, + groups, + out_of_logical, + 1, + ) + .is_err()); + } + #[test] fn blob_metadata_header_crc32_covers_full_metadata() { let payload = vec![0x33; EROFS_BLOCK_SIZE as usize]; diff --git a/nydus-storage/src/cache/local.rs b/nydus-storage/src/cache/local.rs index 9a9eb68d1f3..a8e7dc107ba 100644 --- a/nydus-storage/src/cache/local.rs +++ b/nydus-storage/src/cache/local.rs @@ -430,7 +430,7 @@ impl LocalBlobCache { let group = *self.blob_metadata.group_at(group_index).ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "blob meta group not found") })?; - if !memo.contains_key(&group_index) { + if let std::collections::hash_map::Entry::Vacant(entry) = memo.entry(group_index) { if let Some(recorder) = self.trace_recorder.as_ref() { recorder.record_group_access(self.blob_index, group_index as u32); } else { @@ -445,7 +445,7 @@ impl LocalBlobCache { &mut buffers, kind, )?; - memo.insert(group_index, decoded.to_vec()); + entry.insert(decoded.to_vec()); } let decoded = &memo[&group_index]; let group_offset = group.uncompressed_byte_offset(); @@ -453,8 +453,8 @@ impl LocalBlobCache { let copy_end = unique_end.min(group.uncompressed_byte_end()); bytes[(copy_start - unique_offset) as usize..(copy_end - unique_offset) as usize] .copy_from_slice( - &decoded[(copy_start - group_offset) as usize - ..(copy_end - group_offset) as usize], + &decoded + [(copy_start - group_offset) as usize..(copy_end - group_offset) as usize], ); } @@ -1134,6 +1134,113 @@ mod tests { assert!(cached.group_map.is_ready(0).unwrap()); } + /// A CDC blob whose 8 KiB unique stream backs a 16 KiB logical space: + /// three records, two of which share the same unique bytes (the dedup), + /// with logical gaps (holes) that must read back as zeros. + fn cdc_fixture(backend_dir: &Path) -> ([u8; SHA256_DIGEST_SIZE], Vec) { + use nydus_format::blob::BlobMetadataCdcChunk; + + let mut unique = vec![0u8; 8192]; + for (index, byte) in unique.iter_mut().enumerate() { + *byte = (index % 251) as u8 + 1; + } + let data_blob_id = sha256_bytes(&unique); + let records = vec![ + BlobMetadataCdcChunk::new(*blake3::hash(&unique[..5000]).as_bytes(), 0, 0, 5000) + .unwrap(), + BlobMetadataCdcChunk::new(*blake3::hash(&unique[..5000]).as_bytes(), 8192, 0, 5000) + .unwrap(), + BlobMetadataCdcChunk::new( + *blake3::hash(&unique[5000..7000]).as_bytes(), + 13500, + 5000, + 2000, + ) + .unwrap(), + ]; + let meta = BlobMetadata::from_cdc_parts( + data_blob_id, + 1, + nydus_format::blob::BlobMetadataCompressor::None, + vec![BlobMetadataGroup::new(0, 2, 0, 8192, crc32c::crc32c(&unique)).unwrap()], + records, + 4, + ) + .unwrap(); + let full_blob_id = write_minimal_full_blob(backend_dir, &unique, &meta, true); + + // The expected logical space: record bytes at their logical offsets, + // zeros everywhere else. + let mut logical = vec![0u8; 4 * 4096]; + logical[..5000].copy_from_slice(&unique[..5000]); + logical[8192..13192].copy_from_slice(&unique[..5000]); + logical[13500..15500].copy_from_slice(&unique[5000..7000]); + (full_blob_id, logical) + } + + #[test] + fn cdc_blob_cache_reads_dedup_records_and_holes() { + let backend_dir = tempdir().unwrap(); + let cache_dir = tempdir().unwrap(); + let (full_blob_id, logical) = cdc_fixture(backend_dir.path()); + let backend: Arc = Arc::new(Local::new(backend_dir.path().to_path_buf())); + + let cached = LocalBlobCache::open(full_blob_id, 1, cache_dir.path(), backend).unwrap(); + assert!(cached.blob_metadata().is_cdc()); + + // A read spanning a record tail, a hole, and a deduped record. + let mut buf = vec![0u8; 9000]; + cached.read_at(4000, &mut buf).unwrap(); + assert_eq!(buf, logical[4000..13000]); + // Records 0 and 1 were needed; record 2 stays cold. + assert!(cached.group_map.is_ready(0).unwrap()); + assert!(cached.group_map.is_ready(1).unwrap()); + assert!(!cached.group_map.is_ready(2).unwrap()); + + // A pure-hole read touches no record. + let mut hole = vec![0xffu8; 1000]; + cached.read_at(5500, &mut hole).unwrap(); + assert!(hole.iter().all(|byte| *byte == 0)); + + // ready_ranges: holes count as ready, cold records do not. + let ranges = cached.ready_ranges(0, 4 * 4096).unwrap(); + assert_eq!(ranges, vec![0..13500, 15500..4 * 4096]); + + // Whole logical space after warming everything. + let mut all = vec![0u8; logical.len()]; + cached.read_at(0, &mut all).unwrap(); + assert_eq!(all, logical); + assert_eq!(cached.ready_ranges(0, 4 * 4096).unwrap(), vec![0..4 * 4096]); + } + + #[test] + fn cdc_blob_cache_prefetch_fills_everything() { + let backend_dir = tempdir().unwrap(); + let cache_dir = tempdir().unwrap(); + let (full_blob_id, logical) = cdc_fixture(backend_dir.path()); + let backend = CountingBackend::new(backend_dir.path()); + + let cached = LocalBlobCache::open( + full_blob_id, + 1, + cache_dir.path(), + backend.clone() as Arc, + ) + .unwrap(); + let reads_before_prefetch = backend.reads(); + cached.prefetch_all().unwrap(); + // Three records but a single group: the decode memo must keep it to + // one backend data read. + assert_eq!(backend.reads() - reads_before_prefetch, 1); + assert!(cached.group_map.is_all_ready()); + + let mut all = vec![0u8; logical.len()]; + cached.read_at(0, &mut all).unwrap(); + assert_eq!(all, logical); + // Fully prefetched: reading adds no backend traffic. + assert_eq!(backend.reads() - reads_before_prefetch, 1); + } + #[test] fn stale_groupmap_without_data_file_is_reset() { let backend_dir = tempdir().unwrap(); diff --git a/nydus/src/build/blob_chunk.rs b/nydus/src/build/blob_chunk.rs index 42522ba7cea..9b836e464fb 100644 --- a/nydus/src/build/blob_chunk.rs +++ b/nydus/src/build/blob_chunk.rs @@ -430,6 +430,70 @@ mod tests { use std::fs; use tempfile::tempdir; + #[test] + fn cdc_blob_writer_dedups_duplicate_and_shifted_content() { + let dir = tempdir().unwrap(); + let blob_path = dir.path().join("blob.data"); + let file_a = dir.path().join("a.bin"); + let file_b = dir.path().join("b.bin"); + let file_c = dir.path().join("c.bin"); + + // Pseudo-random ~1.5 MiB body so FastCDC finds real cut points. + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut body = vec![0u8; (1 << 20) + (1 << 19)]; + for byte in body.iter_mut() { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state as u8; + } + fs::write(&file_a, &body).unwrap(); + // Exact duplicate and a shifted copy: fixed 1 MiB chunking would + // dedup neither, CDC must dedup (most of) both. + fs::write(&file_b, &body).unwrap(); + let mut shifted = b"prefix that shifts every later offset".to_vec(); + shifted.extend_from_slice(&body); + fs::write(&file_c, &shifted).unwrap(); + + let mut writer = BlobWriter::new(&blob_path, BLOB_METADATA_DEFAULT_CHUNK_SIZE) + .unwrap() + .with_cdc(); + writer + .write_file_chunks(&file_a, body.len() as u64) + .unwrap(); + writer + .write_file_chunks(&file_b, body.len() as u64) + .unwrap(); + writer + .write_file_chunks(&file_c, shifted.len() as u64) + .unwrap(); + writer.finish().unwrap(); + + let (logical, unique) = writer.cdc_dedup_stats(); + assert_eq!(logical, (2 * body.len() + shifted.len()) as u64); + // The duplicate file dedups fully and the shifted copy mostly: well + // over half of the logical bytes must be removed. + assert!( + unique * 2 < logical, + "expected >50% dedup, got logical {logical}, unique {unique}" + ); + + // The recorded metadata must pass CDC validation end to end. + let blob_metadata = writer.blob_metadata([0x11; EROFS_BLOB_ID_SIZE], 0).unwrap(); + assert!(blob_metadata.is_cdc()); + assert_eq!(blob_metadata.chunks().len(), 0); + assert!(!blob_metadata.cdc_chunks().is_empty()); + // Unique stream (plus final block padding) is what the groups store. + assert_eq!( + blob_metadata.total_uncompressed_size(), + round_up(unique as usize, EROFS_BLOCK_SIZE as usize) as u64 + ); + assert_eq!( + blob_metadata.logical_uncompressed_size(), + writer.total_blocks() * EROFS_BLOCK_SIZE as u64 + ); + } + #[test] fn blob_metadata_group_round_trips_minimal_fields() { let payload = vec![0u8; 0x3000]; diff --git a/nydus/tests/testsuite/nydus_core.rs b/nydus/tests/testsuite/nydus_core.rs index 289f2a296f7..d9662a633bf 100644 --- a/nydus/tests/testsuite/nydus_core.rs +++ b/nydus/tests/testsuite/nydus_core.rs @@ -39,6 +39,19 @@ fn build_test_image( build_test_image_with_layout(root, false) } +/// Like [`build_test_image`] but with CDC dedup enabled on the blob writer, +/// and with extra duplicate-content files so the dedup path is exercised. +fn build_cdc_test_image( + root: &Path, +) -> ( + PathBuf, + Config, + [u8; EROFS_BLOB_ID_SIZE], + HashMap>, +) { + build_test_image_full(root, false, true) +} + fn build_flattened_test_image( root: &Path, ) -> ( @@ -58,6 +71,19 @@ fn build_test_image_with_layout( Config, [u8; EROFS_BLOB_ID_SIZE], HashMap>, +) { + build_test_image_full(root, flattened, false) +} + +fn build_test_image_full( + root: &Path, + flattened: bool, + cdc: bool, +) -> ( + PathBuf, + Config, + [u8; EROFS_BLOB_ID_SIZE], + HashMap>, ) { let corpus_dir = root.join("corpus"); fs::create_dir_all(&corpus_dir).unwrap(); @@ -85,6 +111,25 @@ fn build_test_image_with_layout( corpus.insert("empty.txt".to_string(), Vec::new()); symlink("file1", corpus_dir.join("link_to_file1")).unwrap(); + if cdc { + // Duplicate content at shifted offsets: file1's bytes prefixed by a + // small header, so fixed chunking would find nothing while CDC + // re-synchronizes and dedups the shared tail. Plus an exact copy. + let mut shifted = b"shifted-header:".to_vec(); + shifted.extend_from_slice(&corpus["file1"]); + fs::write(corpus_dir.join("file1_shifted"), &shifted).unwrap(); + corpus.insert("file1_shifted".to_string(), shifted); + fs::write(corpus_dir.join("file1_copy"), &corpus["file1"]).unwrap(); + corpus.insert("file1_copy".to_string(), corpus["file1"].clone()); + // A file with an all-zero middle chunk to exercise zero-chunk elision + // alongside CDC records. + let mut holey = vec![0u8; 3 << 20]; + holey[..4096].copy_from_slice(&corpus["file2"][..4096]); + holey[(2 << 20) + 5..(2 << 20) + 4101].copy_from_slice(&corpus["file2"][..4096]); + fs::write(corpus_dir.join("holey"), &holey).unwrap(); + corpus.insert("holey".to_string(), holey); + } + let blob_dir = root.join("blobs"); fs::create_dir_all(&blob_dir).unwrap(); let staging = blob_dir.join("staging"); @@ -94,6 +139,9 @@ fn build_test_image_with_layout( BlobMetadataCompressor::Zstd, ) .unwrap(); + if cdc { + writer = writer.with_cdc(); + } let mut inodes = build_tree( &corpus_dir, &mut writer, @@ -102,6 +150,14 @@ fn build_test_image_with_layout( ) .unwrap(); writer.finish().unwrap(); + if cdc { + // The duplicate/shifted corpus must actually dedup. + let (logical, unique) = writer.cdc_dedup_stats(); + assert!( + unique < logical, + "CDC dedup found nothing: logical {logical}, unique {unique}" + ); + } let data_blob_id = writer.data_digest(); let blob_metadata = writer.blob_metadata(data_blob_id, 0).unwrap(); @@ -420,3 +476,39 @@ fn node_fetch_populates_blob_cache_without_reading_data() { file1_entry.fetch(0, 0).unwrap(); core.fs.open("/").unwrap().fetch(0, 4096).unwrap_err(); } + +#[test] +fn core_reads_back_cdc_deduped_image() { + let dir = tempdir().unwrap(); + let (bootstrap, config, _blob_id, corpus) = build_cdc_test_image(dir.path()); + + let core = NydusCore::new(&bootstrap, config).unwrap(); + + // Every file — including the shifted/duplicated ones and the holey file — + // must read back byte-identical through the on-demand CDC cache path. + for (name, expected) in &corpus { + let entry = core.fs.open(name).unwrap(); + let all = entry.read().unwrap(); + assert_eq!( + &all[..expected.len()], + expected.as_slice(), + "content mismatch for {name}" + ); + assert!( + all[expected.len()..].iter().all(|byte| *byte == 0), + "tail padding not zero for {name}" + ); + } + + // Unaligned partial reads cross CDC record boundaries. + let entry = core.fs.open("file1_shifted").unwrap(); + let mut buf = vec![0u8; 100_000]; + let read = entry.read_at(123_457, &mut buf).unwrap(); + assert_eq!(read, buf.len()); + assert_eq!(&buf, &corpus["file1_shifted"][123_457..123_457 + read]); + + // probe/fetch flat ranges work per CDC record too. + let file1_entry = core.fs.open("file1").unwrap(); + file1_entry.fetch(12345, 4097).unwrap(); + assert!(!file1_entry.probe_ranges(12345, 4097).unwrap().is_empty()); +} From 3f76ee3908cfc6ec857ffba83276f220a7df201c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:08:23 +0000 Subject: [PATCH 4/8] Address review: unique-order record walk in ensure path, generic map warn Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- nydus-storage/src/cache/local.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/nydus-storage/src/cache/local.rs b/nydus-storage/src/cache/local.rs index a8e7dc107ba..786f1ec1194 100644 --- a/nydus-storage/src/cache/local.rs +++ b/nydus-storage/src/cache/local.rs @@ -169,7 +169,7 @@ impl LocalBlobCache { // split them off with their readiness invisible to each other. group_map.reset()?; warn!( - "stale group_map without cache data file, reset: {}", + "stale readiness map without cache data file, reset: {}", readiness_map_path.display() ); } @@ -331,12 +331,15 @@ impl LocalBlobCache { /// Ensure every CDC chunk record in `records` (indexes into the sorted /// record table) has its bytes decoded into the cache file at its logical - /// offset. `memo` deduplicates group decodes across the records of one - /// call, since consecutive records usually reference the same group. + /// offset. Records are processed in unique-offset order so the decode + /// `memo` (which deduplicates group decodes across the records of one + /// call) sees monotonic group accesses and each group is decoded once. fn ensure_cdc_records(&self, records: Range, cache_file: &File) -> io::Result<()> { let chunks = self.blob_metadata.cdc_chunks(); + let mut order: Vec = records.collect(); + order.sort_by_key(|&index| chunks[index].unique_byte_offset()); let mut memo: HashMap> = HashMap::new(); - for index in records { + for index in order { self.ensure_cdc_record(index, &chunks[index], cache_file, &mut memo)?; } Ok(()) @@ -420,9 +423,9 @@ impl LocalBlobCache { .group_index_for_byte_offset(unique_end - 1) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "blob meta group not found"))?; - // Callers walk records in (mostly) increasing unique offset order, so - // groups below the current record's first group are never needed - // again; dropping them bounds the memo to the record's group span. + // Callers walk records in increasing unique offset order, so groups + // below the current record's first group are never needed again; + // dropping them bounds the memo to the record's group span. memo.retain(|group_index, _| *group_index >= first); let mut bytes = vec![0u8; chunk.size() as usize]; From 64496f106c8ec68f41ec3c19a610e55ba1bf9483 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:41:41 +0000 Subject: [PATCH 5/8] Make CDC the only chunk format; support CDC in optimize Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- docs/nydus.md | 112 ++++++----- nydus-backend/src/local.rs | 9 +- nydus-format/src/blob/metadata.rs | 295 ++++------------------------ nydus-format/src/blob/mod.rs | 6 +- nydus-storage/src/cache/local.rs | 191 ++++++++++++++++-- nydus/src/bin/nydus/build.rs | 29 +-- nydus/src/build/blob_chunk.rs | 187 ++++++------------ nydus/src/build/merge.rs | 1 - nydus/src/build/mod.rs | 15 +- nydus/src/check/mod.rs | 1 - nydus/src/optimize/mod.rs | 32 +-- nydus/tests/testsuite/nydus_core.rs | 25 +-- 12 files changed, 378 insertions(+), 525 deletions(-) diff --git a/docs/nydus.md b/docs/nydus.md index 1f2c3b973ed..d7e67d94cf4 100644 --- a/docs/nydus.md +++ b/docs/nydus.md @@ -158,8 +158,6 @@ Options: Group uncompressed size in bytes (must be a power of two, >= 1MiB, and >= the chunk size). Controls the uncompressed size of each blob meta group used for compression [default: 4194304] --compressor Algorithm to compress data chunks [default: zstd] [possible values: none, zstd] - --cdc - Enable content-defined chunking (FastCDC) deduplication: file data is split at content-defined cut points and duplicate pieces are stored only once, within and across files/layers. Produces blobs with the `CHUNK_CDC` incompat blob meta flag -l, --log-level Specify the logging level [trace, debug, info, warn, error] [default: info] --exclude @@ -192,18 +190,18 @@ Current implementation notes: the group is stored plain and its blob_meta group record has `compressed_size == uncompressed_block_count * 4096`. - `--compressor none` writes every group plain. -- `--cdc` enables content-defined chunking (CDC) deduplication. EROFS inode - chunk indexes and `--chunk-size` are unchanged (they still address the dense - logical uncompressed space), but beneath them each fixed chunk's bytes are - split at FastCDC v2020 cut points (min 4 KiB / avg 16 KiB / max 64 KiB) and - deduplicated by BLAKE3 digest: only never-seen-before pieces enter the group - data stream, so shared content is stored once even when files embed it at - different offsets. The blob meta chunk table then holds 56-byte CDC records - `(digest, logical_byte_offset, unique_byte_offset, size)` instead of fixed - chunk records, the header carries the `CHUNK_CDC` incompat flag plus the - logical block count, and the build summary prints a `cdc_dedup` line with - logical/unique byte counts and the dedup percentage. CDC blobs are read - through the same group cache; `nydus optimize` does not support them yet. +- Content-defined chunking (CDC) deduplication is always on. EROFS inode + chunk indexes and `--chunk-size` still address the dense logical + uncompressed space, but beneath them file data is split at FastCDC v2020 + cut points (min 4 KiB / avg 16 KiB / max 64 KiB) and deduplicated by + BLAKE3 digest: only never-seen-before pieces enter the group data stream, + so shared content is stored once even when files embed it at different + offsets. The blob meta chunk table holds 56-byte CDC records + `(digest, logical_byte_offset, unique_byte_offset, size)`, the header + carries the `CHUNK_CDC` incompat flag plus the logical block count, and + the build summary prints a `cdc_dedup` line with logical/unique byte + counts and the dedup percentage. CDC blobs are read through the same + group cache and are fully supported by `nydus optimize`. - `--exclude ` omits paths inside the source tree from the blob and the resulting filesystem tree entirely. It accepts absolute or current-working-directory-relative paths and may be repeated. @@ -1068,8 +1066,9 @@ At the same time: Whenever build emits a full blob, it writes one blob meta region before the footer. Blob meta is the canonical catalog for the external data blob. A blob -meta chunk is a content-addressed record (BLAKE3 digest + absolute block range) -used for inspection and future deduplication; chunks are independent of groups. +meta chunk is a content-addressed CDC record (BLAKE3 digest + logical byte +offset + unique byte offset + size) that maps the dense logical address space +onto the deduplicated unique data stream; chunks are independent of groups. A blob meta group is the compression unit and cache population unit. EROFS inode chunk indexes point into the logical uncompressed external-device address space; blob meta maps a block offset to its group by a single division and the cache @@ -1095,13 +1094,13 @@ embedded blob meta region | group_block_bits (u8 + pad) | | reserved tail (compat area) | +-------------------------------+ -| chunk records | -| 48 bytes each | +| CDC chunk records | +| 56 bytes each | | | | digest (BLAKE3) | -| uncompressed_block_offset | -| uncompressed_block_count | -| reserved | +| logical_byte_offset | +| unique_byte_offset | +| size + reserved | +-------------------------------+ | group records | | 40 bytes each | @@ -1143,7 +1142,7 @@ Header details: verifies this crc32c before mmaping a cached blob meta file for chunk lookup. - `chunks_offset` is fixed at the header size. `groups_offset` follows the dense chunk table. -- `chunk_count` is the number of chunk records. +- `chunk_count` is the number of CDC chunk records. - `group_count` is the number of compressed group records. - `chunk_block_bits` is log2 of the EROFS chunk size in 4 KiB blocks: `chunk_size = 4096 << chunk_block_bits`, so the default 1 MiB chunk stores @@ -1169,16 +1168,19 @@ Header details: Chunk details: -- Chunks are decoupled from groups: a chunk may straddle a group boundary, and a - group may contain parts of several chunks. The chunk table is a digest index, - not a per-group map. -- `digest` is the BLAKE3 hash of the chunk's decoded, block-aligned bytes — the - deduplication key. -- `uncompressed_block_offset` is the chunk's absolute 4 KiB block offset in the - dense decoded address space (chunks are stored back-to-back). -- `uncompressed_block_count` is the chunk span in 4 KiB blocks. Only the chunk's - final block carries zero padding; full chunks are already block-aligned, so the - dense layout packs real blocks instead of large zero runs. +- CDC records are decoupled from groups: a record's unique bytes may straddle a + group boundary, and a group may contain many records' bytes. The chunk table + is a byte-granular mapping from the logical space to the unique stream, not + a per-group map. +- `digest` is the BLAKE3 hash of the piece's bytes — the deduplication key. +- `logical_byte_offset` is the piece's byte position in the dense logical + uncompressed address space that EROFS chunk indexes point into. +- `unique_byte_offset` is the byte position of the piece's (single) copy in the + deduplicated unique data stream that the groups compress. Many records may + share one unique range — that sharing is the deduplication. +- `size` is the piece length in bytes (FastCDC pieces, 4 KiB min / 16 KiB avg / + 64 KiB max). Records are sorted by `logical_byte_offset` and never overlap; + logical gaps read back as zeros. Group details: @@ -1215,9 +1217,10 @@ block); groups themselves are not individually padded. ### CDC (content-defined chunking) blob meta -When a blob is built with `--cdc`, the header sets the `CHUNK_CDC` incompat -flag (`1 << 2`) and the chunk table holds 56-byte CDC records instead of the -48-byte fixed chunk records above: +Every data blob is built with content-defined chunking: the header sets the +`CHUNK_CDC` incompat flag (`1 << 2`) and the chunk table holds the 56-byte CDC +records described above (redirect/ondemand blobs are groups-only, with an +empty chunk table and the flag clear): ```text CDC chunk record (56 bytes) @@ -1235,9 +1238,9 @@ Two address spaces are involved: fixed power-of-two chunks in a dense uncompressed address space, and the cache data file still mirrors it, so the kernel-visible format and the read/`fetch`/`probe` APIs are untouched. Its size is - `logical_block_count * 4096`, from a new u64 header field at offset 56 (that - field must be zero for non-CDC blobs, keeping their on-disk bytes - identical). + `logical_block_count * 4096`, from a u64 header field at offset 56 (that + field must be zero for groups-only blobs without the flag, such as + ondemand blobs). - The **unique** space is what the group records describe: each fixed chunk's real bytes are split at FastCDC v2020 cut points (min 4 KiB / avg 16 KiB / max 64 KiB) and only never-seen-before pieces (by BLAKE3 digest) are @@ -1252,8 +1255,11 @@ overlapping the logical range, maps each cold record's unique range to its group(s) with the same `>> group_block_bits` division, decodes those groups, and copies the record's bytes to its logical offset in the cache file; readiness is tracked per record in a `.chunk.map` sidecar (same format as the -group map). `nydus optimize` rejects CDC blobs for now because its group -re-slicing assumes the logical and group spaces coincide. +group map). `nydus optimize` operates at group granularity on the unique +stream, so it supports CDC blobs directly: traced unique-stream groups are +re-encoded into the ondemand blob, and the phase-0 redirect fill fans each +decoded group's bytes out to the CDC records it fully covers, at their logical +offsets in the source cache. ### Blocks, chunks and groups @@ -1286,10 +1292,11 @@ blob cache at runtime: the core read paths satisfy them with zeros directly, and native EROFS mounts decode the null address in-kernel the same way. -The per-file chunks are then packed densely, back-to-back, into the decoded -external-device address space that EROFS chunk indexes point into; each -chunk's BLAKE3 digest and absolute block range are recorded in the blob meta -chunk table: +The per-file chunks are then packed densely, back-to-back, into the logical +external-device address space that EROFS chunk indexes point into; beneath +them, each chunk's bytes are split at FastCDC cut points and recorded as CDC +records in the blob meta chunk table (duplicate pieces point at the same +unique bytes): ```text blkaddr 0 256 448 704 960 @@ -1335,8 +1342,8 @@ encoded data region of the full blob: Hash and validation summary: -- **BLAKE3 per chunk** (blob meta chunk table) — the deduplication key over - the chunk's decoded, block-aligned bytes. +- **BLAKE3 per CDC piece** (blob meta chunk table) — the deduplication key + over the piece's bytes. - **CRC32C per group** (blob meta group record) — validated after every fetch and decode, on both the on-demand and prefetch paths. - **SHA256 over the data region** — written into the bootstrap device slot as @@ -1424,12 +1431,13 @@ The build pipeline now follows this sequence: space. Chunks are packed densely: each chunk advances by its real block-aligned size, so only a chunk's final block carries zero padding (no full-chunk zero runs). -3. Record one blob_meta chunk entry per chunk (BLAKE3 digest + absolute block - range) and feed the decoded data stream into a block-oriented group builder - that flushes a compression group whenever it fills to `--compress-size`, - regardless of chunk boundaries. A chunk may therefore span two groups. -4. Compute BLAKE3 digest over each uncompressed chunk and CRC32C over each - uncompressed group. +3. Split each chunk's bytes at FastCDC cut points, deduplicate the pieces by + BLAKE3 digest, and record one blob_meta CDC chunk entry per piece (digest + + logical byte offset + unique byte offset + size). Only never-seen-before + pieces enter the unique data stream, which feeds a block-oriented group + builder that flushes a compression group whenever it fills to + `--compress-size`, regardless of piece boundaries. +4. Compute CRC32C over each uncompressed group of the unique stream. 5. Compress each group according to the blob_meta header compressor and append the encoded bytes directly to the data region. Encoded groups are packed back-to-back with no inter-group padding. For zstd, groups that do not shrink diff --git a/nydus-backend/src/local.rs b/nydus-backend/src/local.rs index 7870e7ea917..d3419e4f754 100644 --- a/nydus-backend/src/local.rs +++ b/nydus-backend/src/local.rs @@ -270,7 +270,7 @@ fn probe_full_blob_source( mod tests { use super::*; use crate::ReadKind; - use nydus_format::blob::{BlobMetadataChunk, BlobMetadataGroup}; + use nydus_format::blob::BlobMetadataGroup; use nydus_format::utils::sha256_bytes; use tempfile::tempdir; @@ -279,7 +279,6 @@ mod tests { blob_id, 1, vec![BlobMetadataGroup::new(0, 1, 0, 4096, crc32c::crc32c(payload)).unwrap()], - vec![BlobMetadataChunk::new(*blake3::hash(payload).as_bytes(), 0, 1).unwrap()], ) .unwrap() } @@ -310,7 +309,8 @@ mod tests { ) .unwrap(); - assert_eq!(blob_metadata.header().chunk_count(), 1); + assert_eq!(blob_metadata.header().chunk_count(), 0); + assert_eq!(blob_metadata.groups().len(), 1); assert_eq!(data, payload); } @@ -338,7 +338,8 @@ mod tests { ) .unwrap(); - assert_eq!(blob_metadata.header().chunk_count(), 1); + assert_eq!(blob_metadata.header().chunk_count(), 0); + assert_eq!(blob_metadata.groups().len(), 1); assert_eq!(data, payload); assert!(backend.blob_metadata(&data_blob_id).is_err()); } diff --git a/nydus-format/src/blob/metadata.rs b/nydus-format/src/blob/metadata.rs index 87809b980af..f9b9773c774 100644 --- a/nydus-format/src/blob/metadata.rs +++ b/nydus-format/src/blob/metadata.rs @@ -41,7 +41,6 @@ const BLOB_METADATA_HEADER_CRC32_OFFSET: usize = 16; /// header block is a reserved compat area (writer-zeroed, reader-ignored). const BLOB_METADATA_HEADER_FIELD_BYTES: usize = 64; const BLOB_METADATA_GROUP_RESERVED: [u8; 6] = [0u8; 6]; -const BLOB_METADATA_CHUNK_RESERVED: u32 = 0; const BLOB_METADATA_CDC_CHUNK_RESERVED: u32 = 0; bitflags! { @@ -56,12 +55,13 @@ bitflags! { pub struct BlobMetadataFlags: u32 { const COMPRESSOR_ZSTD = 1 << 0; const DIGESTER_BLAKE3 = 1 << 1; - /// Incompat: the chunk table holds variable-size CDC (content-defined - /// chunking) records ([`BlobMetadataCdcChunk`], 56 bytes each) instead - /// of fixed-size [`BlobMetadataChunk`] records. Groups then describe - /// the deduplicated *unique* data stream, while EROFS inode chunk - /// indexes keep pointing into the dense *logical* address space whose - /// size is carried by the header `logical_block_count` field. + /// Incompat: the blob carries a CDC (content-defined chunking) chunk + /// table ([`BlobMetadataCdcChunk`], 56 bytes each) — the only chunk + /// table kind. Groups then describe the deduplicated *unique* data + /// stream, while EROFS inode chunk indexes keep pointing into the + /// dense *logical* address space whose size is carried by the header + /// `logical_block_count` field. Every data blob sets this flag; + /// redirect (ondemand) blobs carry groups only and leave it clear. const CHUNK_CDC = 1 << 2; } } @@ -265,13 +265,10 @@ impl BlobMetadataHeader { self.logical_block_count } - /// On-disk size of one chunk record, depending on the chunk table kind. + /// On-disk size of one chunk record; the chunk table always holds CDC + /// records. fn chunk_record_size(&self) -> u64 { - if self.is_cdc() { - size_of::() as u64 - } else { - size_of::() as u64 - } + size_of::() as u64 } pub fn chunk_bytes(&self) -> u64 { @@ -358,9 +355,7 @@ impl BlobMetadataHeader { self.groups_offset ))); } - if self.chunks_offset % align_of::() as u64 != 0 - || self.chunks_offset % align_of::() as u64 != 0 - { + if self.chunks_offset % align_of::() as u64 != 0 { return Err(Error::InvalidImage( "blob meta chunks offset is not aligned".to_string(), )); @@ -376,10 +371,17 @@ impl BlobMetadataHeader { "blob meta CDC logical block count must be non-zero".to_string(), )); } - } else if self.logical_block_count != 0 { - return Err(Error::InvalidImage( - "blob meta logical block count requires the CDC flag".to_string(), - )); + } else { + if self.logical_block_count != 0 { + return Err(Error::InvalidImage( + "blob meta logical block count requires the CDC flag".to_string(), + )); + } + if self.chunk_count != 0 { + return Err(Error::InvalidImage( + "blob meta chunk table requires the CDC flag".to_string(), + )); + } } Ok(()) } @@ -665,100 +667,6 @@ impl BlobMetadataGroup { } } -#[repr(C)] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct BlobMetadataChunk { - digest: [u8; 32], - uncompressed_block_offset: u64, - uncompressed_block_count: u32, - reserved: u32, -} - -const _: () = assert!(size_of::() == 48); - -impl BlobMetadataChunk { - pub fn new( - digest: [u8; 32], - uncompressed_block_offset: u64, - uncompressed_block_count: u32, - ) -> Result { - let chunk = Self { - digest, - uncompressed_block_offset, - uncompressed_block_count, - reserved: BLOB_METADATA_CHUNK_RESERVED, - }; - chunk.validate()?; - Ok(chunk) - } - - pub fn digest(&self) -> &[u8; 32] { - &self.digest - } - - /// Absolute block offset of this chunk within the dense uncompressed address - /// space. Chunks are independent of groups, so this is a plain block index - /// into the blob, not a group-relative offset. - pub fn uncompressed_block_offset(&self) -> u64 { - self.uncompressed_block_offset - } - - pub fn uncompressed_block_count(&self) -> u32 { - self.uncompressed_block_count - } - - pub fn uncompressed_byte_offset(&self) -> u64 { - self.uncompressed_block_offset * EROFS_BLOCK_SIZE as u64 - } - - pub fn uncompressed_byte_size(&self) -> u64 { - self.uncompressed_block_count as u64 * EROFS_BLOCK_SIZE as u64 - } - - pub fn write_to(&self, writer: &mut dyn Write) -> Result<()> { - self.validate()?; - writer.write_all(&self.to_bytes())?; - Ok(()) - } - - fn to_bytes(self) -> [u8; 48] { - let mut data = [0u8; 48]; - data[0..32].copy_from_slice(&self.digest); - data[32..40].copy_from_slice(&self.uncompressed_block_offset.to_le_bytes()); - data[40..44].copy_from_slice(&self.uncompressed_block_count.to_le_bytes()); - data[44..48].copy_from_slice(&self.reserved.to_le_bytes()); - data - } - - pub fn read_from(reader: &mut dyn Read) -> Result { - let chunk = Self { - digest: read_digest(reader)?, - uncompressed_block_offset: read_u64_from(reader)?, - uncompressed_block_count: read_u32_from(reader)?, - reserved: read_u32_from(reader)?, - }; - chunk.validate()?; - Ok(chunk) - } - - fn validate(&self) -> Result<()> { - if self.uncompressed_block_count == 0 { - return Err(Error::InvalidImage( - "blob meta chunk uncompressed block count must be non-zero".to_string(), - )); - } - self.uncompressed_byte_offset() - .checked_add(self.uncompressed_byte_size()) - .ok_or_else(|| Error::Overflow("blob meta chunk byte range overflow".to_string()))?; - if self.reserved != BLOB_METADATA_CHUNK_RESERVED { - return Err(Error::InvalidImage( - "blob meta chunk reserved field must be zero".to_string(), - )); - } - Ok(()) - } -} - /// A variable-size content-defined (CDC) chunk record — 56 bytes on disk, /// present when [`BlobMetadataFlags::CHUNK_CDC`] is set. /// @@ -881,7 +789,6 @@ impl BlobMetadataCdcChunk { enum BlobMetadataStorage { Owned { - chunks: Vec, cdc_chunks: Vec, groups: Vec, }, @@ -895,19 +802,14 @@ pub struct BlobMetadata { } impl BlobMetadata { + /// Construct chunkless blob metadata (groups only), as used by redirect + /// (ondemand) blobs and other artifacts without a logical chunk table. pub fn from_parts( blob_id: [u8; SHA256_DIGEST_SIZE], chunk_block_count: u32, groups: Vec, - chunks: Vec, ) -> Result { - Self::from_parts_with_options( - blob_id, - chunk_block_count, - BlobMetadataCompressor::None, - groups, - chunks, - ) + Self::from_parts_with_options(blob_id, chunk_block_count, BlobMetadataCompressor::None, groups) } pub fn from_parts_with_options( @@ -915,19 +817,17 @@ impl BlobMetadata { chunk_block_count: u32, compressor: BlobMetadataCompressor, groups: Vec, - chunks: Vec, ) -> Result { let mut header = BlobMetadataHeader::default(); header.set_chunk_block_count(chunk_block_count)?; header.set_compressor(compressor); - header.set_counts_and_offsets(chunks.len() as u32, groups.len() as u32)?; + header.set_counts_and_offsets(0, groups.len() as u32)?; header.group_block_bits = infer_group_block_bits(&groups)?; - validate_tables(&groups, &chunks, header.group_block_count())?; + validate_groups(&groups, header.group_block_count())?; let mut blob_metadata = Self { header, blob_id, storage: BlobMetadataStorage::Owned { - chunks, cdc_chunks: Vec::new(), groups, }, @@ -958,11 +858,7 @@ impl BlobMetadata { let mut blob_metadata = Self { header, blob_id, - storage: BlobMetadataStorage::Owned { - chunks: Vec::new(), - cdc_chunks, - groups, - }, + storage: BlobMetadataStorage::Owned { cdc_chunks, groups }, }; blob_metadata.header.crc32 = blob_metadata.compute_crc32(); Ok(blob_metadata) @@ -988,7 +884,6 @@ impl BlobMetadata { self.chunk_block_count(), self.compressor(), groups, - self.chunks().to_vec(), ) } } @@ -1025,16 +920,6 @@ impl BlobMetadata { self.header.digester() } - pub fn chunks(&self) -> &[BlobMetadataChunk] { - if self.is_cdc() { - return &[]; - } - match &self.storage { - BlobMetadataStorage::Owned { chunks, .. } => chunks, - BlobMetadataStorage::Mapped(mmap) => mapped_chunks(mmap, &self.header), - } - } - /// The CDC chunk records, sorted by logical byte offset. Empty when the /// blob is not CDC. pub fn cdc_chunks(&self) -> &[BlobMetadataCdcChunk] { @@ -1142,9 +1027,6 @@ impl BlobMetadata { &self.header.to_bytes_with_crc32(0), blob_metadata_crc32_field(), ); - for chunk in self.chunks() { - crc32 = crc32c_append(crc32, &chunk.to_bytes()); - } for chunk in self.cdc_chunks() { crc32 = crc32c_append(crc32, &chunk.to_bytes()); } @@ -1161,9 +1043,6 @@ impl BlobMetadata { pub fn write_to(&self, writer: &mut dyn Write) -> Result<()> { self.header .write_to_with_crc32(writer, self.compute_crc32())?; - for chunk in self.chunks() { - chunk.write_to(writer)?; - } for chunk in self.cdc_chunks() { chunk.write_to(writer)?; } @@ -1215,25 +1094,13 @@ impl BlobMetadata { validate_blob_metadata_crc32(data, &header)?; } - let mut chunks = Vec::new(); - let mut cdc_chunks = Vec::new(); + let mut cdc_chunks = Vec::with_capacity(header.chunk_count() as usize); cursor.set_position(header.chunks_offset()); - if header.is_cdc() { - cdc_chunks.reserve(header.chunk_count() as usize); - for index in 0..header.chunk_count() as usize { - cdc_chunks.push( - BlobMetadataCdcChunk::read_from(&mut cursor) - .with_context(|| format!("failed to read blob meta CDC chunk {index}"))?, - ); - } - } else { - chunks.reserve(header.chunk_count() as usize); - for index in 0..header.chunk_count() as usize { - chunks.push( - BlobMetadataChunk::read_from(&mut cursor) - .with_context(|| format!("failed to read blob meta chunk {index}"))?, - ); - } + for index in 0..header.chunk_count() as usize { + cdc_chunks.push( + BlobMetadataCdcChunk::read_from(&mut cursor) + .with_context(|| format!("failed to read blob meta CDC chunk {index}"))?, + ); } let mut groups = Vec::with_capacity(header.group_count() as usize); @@ -1244,20 +1111,14 @@ impl BlobMetadata { .with_context(|| format!("failed to read blob meta group {index}"))?, ); } + validate_groups(&groups, header.group_block_count())?; if header.is_cdc() { - validate_groups(&groups, header.group_block_count())?; validate_cdc_chunks(&groups, &cdc_chunks, header.logical_block_count())?; - } else { - validate_tables(&groups, &chunks, header.group_block_count())?; } Ok(Self { header, blob_id, - storage: BlobMetadataStorage::Owned { - chunks, - cdc_chunks, - groups, - }, + storage: BlobMetadataStorage::Owned { cdc_chunks, groups }, }) } @@ -1287,19 +1148,13 @@ impl BlobMetadata { if check_crc32 { validate_blob_metadata_crc32(&mmap, &header)?; } + validate_groups(mapped_groups(&mmap, &header), header.group_block_count())?; if header.is_cdc() { - validate_groups(mapped_groups(&mmap, &header), header.group_block_count())?; validate_cdc_chunks( mapped_groups(&mmap, &header), mapped_cdc_chunks(&mmap, &header), header.logical_block_count(), )?; - } else { - validate_tables( - mapped_groups(&mmap, &header), - mapped_chunks(&mmap, &header), - header.group_block_count(), - )?; } Ok(Self { header, @@ -1400,15 +1255,6 @@ fn blob_metadata_crc32_field() -> std::ops::Range { BLOB_METADATA_HEADER_CRC32_OFFSET..BLOB_METADATA_HEADER_CRC32_OFFSET + 4 } -fn validate_tables( - groups: &[BlobMetadataGroup], - chunks: &[BlobMetadataChunk], - group_block_count: u32, -) -> Result<()> { - validate_groups(groups, group_block_count)?; - validate_chunks(groups, chunks) -} - /// Infer the per-group block-count exponent from the group table. /// /// - A redirect (ondemand) blob copies groups of arbitrary sizes from its @@ -1490,30 +1336,6 @@ fn validate_groups(groups: &[BlobMetadataGroup], group_block_count: u32) -> Resu Ok(()) } -fn validate_chunks(groups: &[BlobMetadataGroup], chunks: &[BlobMetadataChunk]) -> Result<()> { - let total_blocks = groups - .last() - .map(|group| group.uncompressed_block_offset() + group.uncompressed_block_count() as u64) - .unwrap_or(0); - for (index, chunk) in chunks.iter().enumerate() { - chunk - .validate() - .with_context(|| format!("invalid blob meta chunk {index}"))?; - // Chunks are independent of groups; they only need to point at a valid - // block range inside the dense uncompressed address space. - let chunk_end = chunk - .uncompressed_block_offset() - .checked_add(chunk.uncompressed_block_count() as u64) - .ok_or_else(|| Error::Overflow("blob meta chunk block range overflow".to_string()))?; - if chunk_end > total_blocks { - return Err(Error::InvalidImage(format!( - "blob meta chunk {index} exceeds the blob block range" - ))); - } - } - Ok(()) -} - fn validate_cdc_chunks( groups: &[BlobMetadataGroup], chunks: &[BlobMetadataCdcChunk], @@ -1567,14 +1389,6 @@ fn groups_total_compressed_size(groups: &[BlobMetadataGroup]) -> u64 { .unwrap_or(0) } -fn mapped_chunks<'a>(data: &'a [u8], header: &BlobMetadataHeader) -> &'a [BlobMetadataChunk] { - let offset = header.chunks_offset() as usize; - let byte_len = header.chunk_count() as usize * size_of::(); - let bytes = &data[offset..offset + byte_len]; - let ptr = bytes.as_ptr().cast::(); - unsafe { std::slice::from_raw_parts(ptr, header.chunk_count() as usize) } -} - fn mapped_cdc_chunks<'a>( data: &'a [u8], header: &BlobMetadataHeader, @@ -1644,19 +1458,6 @@ mod tests { .unwrap() } - fn chunk( - payload: &[u8], - uncompressed_block_offset: u64, - uncompressed_block_count: u32, - ) -> BlobMetadataChunk { - BlobMetadataChunk::new( - digest(payload), - uncompressed_block_offset, - uncompressed_block_count, - ) - .unwrap() - } - #[test] fn blob_metadata_round_trips_through_mmap() { let dir = tempdir().unwrap(); @@ -1669,19 +1470,18 @@ mod tests { blob_id, 1, vec![group(0, 2, 8192, 8192, &group_payload)], - vec![chunk(&payload_a, 0, 1), chunk(&payload_b, 1, 1)], ) .unwrap(); blob_metadata.save(&path).unwrap(); let loaded = BlobMetadata::load(&path).unwrap(); - assert_eq!(loaded.header().chunk_count(), 2); + assert_eq!(loaded.header().chunk_count(), 0); assert_eq!(loaded.header().group_count(), 1); assert_eq!(loaded.header().version(), BLOB_METADATA_VERSION); - assert_eq!(loaded.header().chunk_bytes(), 96); + assert_eq!(loaded.header().chunk_bytes(), 0); assert_eq!(loaded.header().group_bytes(), 40); - assert_eq!(loaded.header().records_end(), 4096 + 96 + 40); + assert_eq!(loaded.header().records_end(), 4096 + 40); assert_eq!(loaded.header().metadata_size(), 8192); assert_eq!(loaded.header().chunk_size(), EROFS_BLOCK_SIZE); assert_eq!(loaded.header().group_block_count(), 2); @@ -1689,8 +1489,6 @@ mod tests { assert_eq!(loaded.header().digester(), BlobMetadataDigester::Blake3); assert_ne!(loaded.header().crc32(), 0); assert_eq!(loaded.groups()[0].compressed_byte_offset(), 8192); - assert_eq!(loaded.chunks()[1].digest(), &digest(&payload_b)); - assert_eq!(loaded.chunks()[1].uncompressed_block_offset(), 1); assert_eq!(loaded.group_index_for_byte_offset(4096), Some(0)); assert_eq!(loaded.total_uncompressed_size(), 8192); } @@ -1732,8 +1530,6 @@ mod tests { 4 * EROFS_BLOCK_SIZE as u64 ); assert_eq!(loaded.total_uncompressed_size(), 8192); - // Fixed chunk records are absent in CDC mode. - assert!(loaded.chunks().is_empty()); let records = loaded.cdc_chunks(); assert_eq!(records.len(), 3); assert_eq!(records[1].digest(), &digest(&piece_a)); @@ -1802,7 +1598,6 @@ mod tests { [0x7bu8; SHA256_DIGEST_SIZE], 1, vec![group(0, 1, 0, 4096, &payload)], - vec![chunk(&payload, 0, 1)], ) .unwrap(); let mut raw = Vec::new(); @@ -1825,7 +1620,6 @@ mod tests { [0x8cu8; SHA256_DIGEST_SIZE], 1, vec![group(0, 1, 0, 4096, &payload)], - vec![chunk(&payload, 0, 1)], ) .unwrap(); let mut raw = Vec::new(); @@ -1878,7 +1672,6 @@ mod tests { [0x1au8; SHA256_DIGEST_SIZE], 1, vec![group(0, 1, 0, 4096, &payload)], - vec![chunk(&payload, 0, 1)], ) .unwrap(); let mut raw = Vec::new(); @@ -1918,7 +1711,6 @@ mod tests { [0x2bu8; SHA256_DIGEST_SIZE], 1, vec![group(0, 1, 0, 4096, &payload)], - vec![chunk(&payload, 0, 1)], ) .unwrap(); let mut raw = Vec::new(); @@ -1961,7 +1753,6 @@ mod tests { ), group(4, 1, 4 * EROFS_BLOCK_SIZE as u64, EROFS_BLOCK_SIZE, &one), ], - vec![chunk(&two, 0, 2), chunk(&two, 2, 2), chunk(&one, 4, 1)], ) .unwrap(); @@ -2014,7 +1805,6 @@ mod tests { ), group(5, 1, 5 * EROFS_BLOCK_SIZE as u64, EROFS_BLOCK_SIZE, &one), ], - vec![chunk(&two, 0, 2), chunk(&three, 2, 3), chunk(&one, 5, 1)], ) { Ok(_) => panic!("non-uniform group sizes should be rejected"), Err(err) => err, @@ -2033,7 +1823,6 @@ mod tests { [0u8; SHA256_DIGEST_SIZE], 1, vec![group(0, 3, 0, 3 * EROFS_BLOCK_SIZE, &three)], - vec![chunk(&three, 0, 3)], ) .unwrap(); @@ -2062,7 +1851,6 @@ mod tests { group(0, 3, 0, 3 * EROFS_BLOCK_SIZE, &three), group(3, 1, 3 * EROFS_BLOCK_SIZE as u64, EROFS_BLOCK_SIZE, &one), ], - vec![chunk(&three, 0, 3), chunk(&one, 3, 1)], ) { Ok(_) => panic!("non-power-of-two full group should be rejected"), Err(err) => err, @@ -2080,7 +1868,6 @@ mod tests { [0u8; SHA256_DIGEST_SIZE], 1, vec![group(0, 2, 0, 5000, &two), group(2, 2, 5000, 3000, &two)], - vec![chunk(&two, 0, 2), chunk(&two, 2, 2)], ) .unwrap(); @@ -2096,7 +1883,6 @@ mod tests { [0u8; SHA256_DIGEST_SIZE], 1, vec![group(0, 2, 0, 5000, &two), group(2, 2, 4999, 3000, &two)], - vec![chunk(&two, 0, 2), chunk(&two, 2, 2)], ) { Ok(_) => panic!("overlapping compressed ranges should be rejected"), Err(err) => err, @@ -2185,7 +1971,6 @@ mod tests { [0x9du8; SHA256_DIGEST_SIZE], BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, groups.clone(), - Vec::new(), ) .unwrap(); assert!(blob_metadata.is_redirect_blob()); diff --git a/nydus-format/src/blob/mod.rs b/nydus-format/src/blob/mod.rs index c3fedcf1da9..fa9f0190be8 100644 --- a/nydus-format/src/blob/mod.rs +++ b/nydus-format/src/blob/mod.rs @@ -11,9 +11,9 @@ pub mod validate; pub use footer::NYDUS_BLOB_FOOTER_ALIGNMENT; pub use footer::{BlobFooter, NYDUS_BLOB_FOOTER_SIZE}; pub use metadata::{ - BlobMetadata, BlobMetadataCdcChunk, BlobMetadataChunk, BlobMetadataCompressor, - BlobMetadataDigester, BlobMetadataGroup, BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, - BLOB_METADATA_DEFAULT_CHUNK_SIZE, BLOB_METADATA_SUFFIX, + BlobMetadata, BlobMetadataCdcChunk, BlobMetadataCompressor, BlobMetadataDigester, + BlobMetadataGroup, BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, BLOB_METADATA_DEFAULT_CHUNK_SIZE, + BLOB_METADATA_SUFFIX, }; use std::io::Write; diff --git a/nydus-storage/src/cache/local.rs b/nydus-storage/src/cache/local.rs index 786f1ec1194..69f7356e973 100644 --- a/nydus-storage/src/cache/local.rs +++ b/nydus-storage/src/cache/local.rs @@ -6,7 +6,7 @@ use std::os::fd::{AsRawFd, RawFd}; use std::os::unix::fs::{FileExt, MetadataExt}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Condvar, Mutex, RwLock}; +use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock}; use std::time::Duration; use tracing::{info, warn}; @@ -97,6 +97,9 @@ pub struct LocalBlobCache { backend: Arc, trace_recorder: Option>, inflight_groups: Mutex>>, + /// CDC record indexes sorted by unique byte offset, built lazily. Maps a + /// unique-stream group's byte range to the records it can satisfy. + cdc_unique_order: OnceLock>, /// Keeps the processes sharing this cache from each fetching the same /// cold group. group_locks: GroupLocks, @@ -189,6 +192,7 @@ impl LocalBlobCache { backend, trace_recorder, inflight_groups: Mutex::new(HashMap::new()), + cdc_unique_order: OnceLock::new(), group_locks, }) } @@ -198,6 +202,94 @@ impl LocalBlobCache { &self.blob_metadata } + /// Fetch, decode and validate one group's bytes directly from the + /// backend, without touching the cache data file or readiness map. This + /// is the group-granular read used by `nydus optimize` to re-encode + /// accessed groups into an ondemand artifact; for CDC blobs the returned + /// bytes belong to the deduplicated unique data stream. + pub fn read_group(&self, group_index: usize) -> io::Result> { + let group = *self.blob_metadata.group_at(group_index).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "group index out of range") + })?; + let mut buffers = GroupBuffers::default(); + let decoded = fetch_decode_validate_group_into( + &self.blob_id, + &self.blob_metadata, + &self.backend, + &group, + &mut buffers, + ReadKind::OnDemand, + )?; + Ok(decoded.to_vec()) + } + + /// CDC record indexes sorted by unique byte offset (lazily built once). + fn cdc_unique_order(&self) -> &[u32] { + self.cdc_unique_order.get_or_init(|| { + let chunks = self.blob_metadata.cdc_chunks(); + let mut order: Vec = (0..chunks.len() as u32).collect(); + order.sort_by_key(|&index| chunks[index as usize].unique_byte_offset()); + order + }) + } + + /// Indexes of the CDC records whose unique byte range is fully contained + /// in `[unique_start, unique_end)` — the records one decoded group of the + /// unique stream can satisfy on its own. Records straddling a group + /// boundary are not returned. + fn cdc_records_contained_in(&self, unique_start: u64, unique_end: u64) -> Vec { + let chunks = self.blob_metadata.cdc_chunks(); + let order = self.cdc_unique_order(); + let first = + order.partition_point(|&i| chunks[i as usize].unique_byte_offset() < unique_start); + let mut contained = Vec::new(); + for &index in &order[first..] { + let chunk = &chunks[index as usize]; + if chunk.unique_byte_offset() >= unique_end { + break; + } + if chunk.unique_byte_end() <= unique_end { + contained.push(index as usize); + } + } + contained + } + + /// The CDC counterpart of a redirect fill: `decoded` holds one validated + /// group of the unique data stream, so copy every record fully contained + /// in the group's unique byte range to its logical cache offset and mark + /// those records ready. Records straddling a group boundary are left to + /// the on-demand path. + fn fill_cdc_records_from_group( + &self, + group: &BlobMetadataGroup, + decoded: &[u8], + ) -> io::Result<()> { + let chunks = self.blob_metadata.cdc_chunks(); + let group_offset = group.uncompressed_byte_offset(); + let cache_file = self.cache_file()?; + let mut filled = false; + for index in + self.cdc_records_contained_in(group_offset, group.uncompressed_byte_end()) + { + if self.group_map.is_ready(index)? { + continue; + } + let chunk = &chunks[index]; + let start = (chunk.unique_byte_offset() - group_offset) as usize; + let bytes = &decoded[start..start + chunk.size() as usize]; + write_all_at(cache_file.as_ref(), chunk.logical_byte_offset(), bytes)?; + self.group_map.set_ready(index)?; + filled = true; + } + if filled { + nydus_telemetry::metrics::inc_cache_redirect_fill_group(); + } else { + nydus_telemetry::metrics::inc_cache_hit_group(); + } + Ok(()) + } + fn cache_file(&self) -> io::Result> { if let Some(file) = self.cache_file.read().unwrap().as_ref() { return Ok(file.clone()); @@ -492,6 +584,12 @@ impl LocalBlobCache { // overlapping the logical range; logical gaps between records are // padding/holes that read back as zeros from the sparse cache file. if self.blob_metadata.is_cdc() { + if end > self.blob_metadata.logical_uncompressed_size() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "blob read range beyond logical uncompressed size", + )); + } let records = self.blob_metadata.cdc_chunks_overlapping(offset, end); return self.ensure_cdc_records(records, cache_file); } @@ -857,6 +955,21 @@ impl BlobCache for LocalBlobCache { } fn is_group_ready(&self, group_index: usize) -> bool { + // For a CDC blob readiness is tracked per record: a unique-stream + // group is "done" once every record it can satisfy on its own is + // filled into the logical cache. + if self.blob_metadata.is_cdc() { + let Some(group) = self.blob_metadata.group_at(group_index) else { + return false; + }; + return self + .cdc_records_contained_in( + group.uncompressed_byte_offset(), + group.uncompressed_byte_end(), + ) + .into_iter() + .all(|index| self.group_map.is_ready(index).unwrap_or(false)); + } self.group_map.is_ready(group_index).unwrap_or(false) } @@ -963,14 +1076,22 @@ impl BlobCache for LocalBlobCache { "redirect fill group index out of range", ) })?; - if self.group_map.is_ready(group_index)? { - nydus_telemetry::metrics::inc_cache_hit_group(); - return Ok(()); - } // Cross-check against this blob's own group metadata: the redirect // group's crc32 was copied from this source group at optimize time, so // any divergence (stale optimize artifact, corrupted transfer) is // caught here before it can poison the cache. + if self.blob_metadata.is_cdc() { + // A CDC source blob's groups describe the unique data stream while + // its cache file holds the logical space: fan the group's bytes out + // to the CDC records it fully covers, at their logical offsets. + let group = *group; + super::validate_group_with_metrics(&self.backend, &group, decoded)?; + return self.fill_cdc_records_from_group(&group, decoded); + } + if self.group_map.is_ready(group_index)? { + nydus_telemetry::metrics::inc_cache_hit_group(); + return Ok(()); + } super::validate_group_with_metrics(&self.backend, group, decoded)?; let cache_file = self.cache_file()?; write_all_at( @@ -1055,25 +1176,20 @@ fn write_all_at(file: &File, offset: u64, buf: &[u8]) -> io::Result<()> { mod tests { use super::*; use nydus_backend::Local; - use nydus_format::blob::{BlobMetadataChunk, BlobMetadataGroup}; + use nydus_format::blob::BlobMetadataGroup; use nydus_format::utils::sha256_bytes; use std::path::Path; use tempfile::tempdir; fn blob_metadata(blob_id: [u8; SHA256_DIGEST_SIZE], payload: &[u8]) -> BlobMetadata { - blob_metadata_with_crc32(blob_id, payload, crc32c::crc32c(payload)) + blob_metadata_with_crc32(blob_id, crc32c::crc32c(payload)) } - fn blob_metadata_with_crc32( - blob_id: [u8; SHA256_DIGEST_SIZE], - payload: &[u8], - crc32: u32, - ) -> BlobMetadata { + fn blob_metadata_with_crc32(blob_id: [u8; SHA256_DIGEST_SIZE], crc32: u32) -> BlobMetadata { BlobMetadata::from_parts( blob_id, 1, vec![BlobMetadataGroup::new(0, 1, 0, 4096, crc32).unwrap()], - vec![BlobMetadataChunk::new(*blake3::hash(payload).as_bytes(), 0, 1).unwrap()], ) .unwrap() } @@ -1216,6 +1332,47 @@ mod tests { assert_eq!(cached.ready_ranges(0, 4 * 4096).unwrap(), vec![0..4 * 4096]); } + #[test] + fn cdc_redirect_fill_populates_logical_records_from_unique_group_bytes() { + let backend_dir = tempdir().unwrap(); + let cache_dir = tempdir().unwrap(); + let (full_blob_id, logical) = cdc_fixture(backend_dir.path()); + let backend = CountingBackend::new(backend_dir.path()); + + let cached = LocalBlobCache::open( + full_blob_id, + 1, + cache_dir.path(), + backend.clone() as Arc, + ) + .unwrap(); + + // `read_group` hands optimize the group's decoded unique-stream bytes. + let unique = cached.read_group(0).unwrap(); + assert_eq!(unique.len(), 8192); + + // Corrupted redirect bytes are rejected before touching the cache. + let mut corrupted = unique.clone(); + corrupted[0] ^= 0xff; + assert!(cached.fill_group_from_redirect(0, &corrupted).is_err()); + assert!(!cached.is_group_ready(0)); + + // A valid redirect fill writes every record fully contained in the + // group's unique range at its logical offset and marks it ready. + cached.fill_group_from_redirect(0, &unique).unwrap(); + assert!(cached.is_group_ready(0)); + for record in 0..3 { + assert!(cached.group_map.is_ready(record).unwrap()); + } + + // The logical space is now complete without extra backend traffic. + let reads_after_fill = backend.reads(); + let mut all = vec![0u8; logical.len()]; + cached.read_at(0, &mut all).unwrap(); + assert_eq!(all, logical); + assert_eq!(backend.reads(), reads_after_fill); + } + #[test] fn cdc_blob_cache_prefetch_fills_everything() { let backend_dir = tempdir().unwrap(); @@ -1458,7 +1615,6 @@ mod tests { sha256_bytes(&payload), 1, vec![BlobMetadataGroup::new_redirect(0, 1, 0, 4096, crc32, 1, 0).unwrap()], - Vec::new(), ) .unwrap(); assert!(redirect_meta.is_redirect_blob()); @@ -1536,11 +1692,8 @@ mod tests { let cache_dir = tempdir().unwrap(); let payload = vec![0xacu8; 4096]; let data_blob_id = sha256_bytes(&payload); - let meta = blob_metadata_with_crc32( - data_blob_id, - &payload, - crc32c::crc32c(&payload).wrapping_add(1), - ); + let meta = + blob_metadata_with_crc32(data_blob_id, crc32c::crc32c(&payload).wrapping_add(1)); let full_blob_id = write_minimal_full_blob(backend_dir.path(), &payload, &meta, true); let backend: Arc = Arc::new(Local::new(backend_dir.path().to_path_buf())); diff --git a/nydus/src/bin/nydus/build.rs b/nydus/src/bin/nydus/build.rs index ddcf516a29e..28a3819a411 100644 --- a/nydus/src/bin/nydus/build.rs +++ b/nydus/src/bin/nydus/build.rs @@ -79,13 +79,6 @@ pub struct BuildArgs { #[arg(long, value_enum, default_value_t = Compressor::Zstd)] pub compressor: Compressor, - /// Enable content-defined chunking (FastCDC) deduplication: file data is - /// split at content-defined cut points and duplicate pieces are stored - /// only once, within and across files/layers. Produces blobs with the - /// `CHUNK_CDC` incompat blob meta flag. - #[arg(long)] - pub cdc: bool, - #[command(flatten)] pub log: cli_common::CommandLogArgs, @@ -201,7 +194,6 @@ fn run_dir_to_nydus(args: BuildArgs) -> Result<()> { compressor: args.compressor.into(), exclude: &exclude, standalone_bootstrap: args.bootstrap.is_some(), - cdc: args.cdc, }; // Fail on invalid chunk/compress geometry before creating output files. options.validate()?; @@ -238,17 +230,16 @@ fn run_dir_to_nydus(args: BuildArgs) -> Result<()> { blob_metadata_path: &blob_metadata_path, bootstrap_path: args.bootstrap.as_deref(), }); - if let Some((logical, unique)) = image.cdc_dedup_stats { - let saved = logical.saturating_sub(unique); - let percent = if logical > 0 { - saved as f64 * 100.0 / logical as f64 - } else { - 0.0 - }; - println!( - " cdc_dedup: logical {logical} bytes, unique {unique} bytes, saved {saved} bytes ({percent:.1}%)" - ); - } + let (logical, unique) = image.cdc_dedup_stats; + let saved = logical.saturating_sub(unique); + let percent = if logical > 0 { + saved as f64 * 100.0 / logical as f64 + } else { + 0.0 + }; + println!( + " cdc_dedup: logical {logical} bytes, unique {unique} bytes, saved {saved} bytes ({percent:.1}%)" + ); Ok(()) } diff --git a/nydus/src/build/blob_chunk.rs b/nydus/src/build/blob_chunk.rs index 9b836e464fb..a6e23f15d46 100644 --- a/nydus/src/build/blob_chunk.rs +++ b/nydus/src/build/blob_chunk.rs @@ -1,8 +1,8 @@ use crc32c::crc32c; use nydus_error::{Context, Error, Result}; use nydus_format::blob::{ - BlobMetadata, BlobMetadataCdcChunk, BlobMetadataChunk, BlobMetadataCompressor, - BlobMetadataGroup, BLOB_METADATA_DEFAULT_CHUNK_SIZE, + BlobMetadata, BlobMetadataCdcChunk, BlobMetadataCompressor, BlobMetadataGroup, + BLOB_METADATA_DEFAULT_CHUNK_SIZE, }; use nydus_format::erofs::{ErofsChunkAddr, EROFS_BLOB_ID_SIZE, EROFS_BLOCK_SIZE, EROFS_NULL_ADDR}; use nydus_format::utils::round_up; @@ -20,7 +20,9 @@ pub const CDC_MIN_CHUNK_SIZE: u32 = 4096; pub const CDC_AVG_CHUNK_SIZE: u32 = 16384; pub const CDC_MAX_CHUNK_SIZE: u32 = 65536; -/// Manages writing chunk data to a separate blob device. +/// Manages writing chunk data to a separate blob device. File data is always +/// split at content-defined (FastCDC) cut points and deduplicated by blake3 +/// digest, so only unique bytes enter the group data stream. pub struct BlobWriter { file: File, file_chunk_size: u32, @@ -32,8 +34,6 @@ pub struct BlobWriter { group_block_offset: u64, group_buffer: Vec, blob_metadata_groups: Vec, - blob_metadata_chunks: Vec, - cdc: bool, cdc_chunks: Vec, // blake3 digest -> byte offset in the unique data stream. Sizes need not // be stored: FastCDC cut points are content-defined, so equal content @@ -119,8 +119,6 @@ impl BlobWriter { group_block_offset: 0, group_buffer: Vec::with_capacity(group_size as usize), blob_metadata_groups: Vec::new(), - blob_metadata_chunks: Vec::new(), - cdc: false, cdc_chunks: Vec::new(), cdc_dedup: HashMap::new(), cdc_unique_len: 0, @@ -128,18 +126,6 @@ impl BlobWriter { }) } - /// Enable content-defined chunking: file data is split at FastCDC cut - /// points, deduplicated by blake3 digest, and only unique bytes enter the - /// group data stream. Must be called before any data is written. - pub fn with_cdc(mut self) -> Self { - self.cdc = true; - self - } - - pub fn is_cdc(&self) -> bool { - self.cdc - } - /// `(logical_bytes, unique_bytes)` fed through the CDC splitter so far; /// the difference is the data removed by deduplication. pub fn cdc_dedup_stats(&self) -> (u64, u64) { @@ -164,10 +150,6 @@ impl BlobWriter { (self.file, self.data_hasher) } - pub fn blob_metadata_chunks(&self) -> &[BlobMetadataChunk] { - &self.blob_metadata_chunks - } - pub fn blob_metadata_groups(&self) -> &[BlobMetadataGroup] { &self.blob_metadata_groups } @@ -177,24 +159,14 @@ impl BlobWriter { blob_id: [u8; EROFS_BLOB_ID_SIZE], source_offset_bias: u64, ) -> Result { - let blob_metadata = if self.cdc { - BlobMetadata::from_cdc_parts( - blob_id, - self.file_chunk_size / EROFS_BLOCK_SIZE, - self.compressor, - self.blob_metadata_groups.clone(), - self.cdc_chunks.clone(), - self.next_blkaddr, - )? - } else { - BlobMetadata::from_parts_with_options( - blob_id, - self.file_chunk_size / EROFS_BLOCK_SIZE, - self.compressor, - self.blob_metadata_groups.clone(), - self.blob_metadata_chunks.clone(), - )? - }; + let blob_metadata = BlobMetadata::from_cdc_parts( + blob_id, + self.file_chunk_size / EROFS_BLOCK_SIZE, + self.compressor, + self.blob_metadata_groups.clone(), + self.cdc_chunks.clone(), + self.next_blkaddr, + )?; Ok(blob_metadata.with_compressed_offset_bias(source_offset_bias)?) } @@ -211,10 +183,10 @@ impl BlobWriter { } pub fn finish(&mut self) -> Result<()> { - // In CDC mode the unique data stream is byte granular, so the tail - // group must be zero padded to a whole block before it is flushed - // (groups always describe whole uncompressed blocks). - if self.cdc && !self.group_buffer.is_empty() { + // The unique data stream is byte granular, so the tail group must be + // zero padded to a whole block before it is flushed (groups always + // describe whole uncompressed blocks). + if !self.group_buffer.is_empty() { let padded = round_up(self.group_buffer.len(), EROFS_BLOCK_SIZE as usize); self.group_buffer.resize(padded, 0); } @@ -223,8 +195,9 @@ impl BlobWriter { } /// Process a regular file: read it in chunk-sized pieces and append every - /// chunk to the blob device. Chunk-level digests are recorded in blob meta; - /// deduplication is intentionally disabled for now. + /// chunk to the blob device. Each chunk's real bytes are split at + /// content-defined cut points and deduplicated; only unique pieces are + /// stored, recorded as CDC records in blob meta. pub fn write_file_chunks( &mut self, path: &Path, @@ -285,33 +258,13 @@ impl BlobWriter { Error::Overflow(format!("blob meta chunk block count exceeds u32: {err}")) })?; - if self.cdc { - // CDC mode: the chunk still occupies `block_count` logical blocks - // (EROFS chunk indexes are untouched), but its real bytes are - // split at content-defined cut points and deduplicated; only - // unique pieces enter the group stream. The tail-block padding is - // never stored: uncovered logical bytes read back as zeros. - self.next_blkaddr += block_count as u64; - self.append_cdc_pieces(data, addr * EROFS_BLOCK_SIZE as u64)?; - return Ok(addr); - } - - // Block-aligned chunk payload: real bytes followed by zero padding only - // in its final block. - let mut uncompressed = vec![0u8; write_len]; - uncompressed[..data.len()].copy_from_slice(data); + // The chunk occupies `block_count` logical blocks (EROFS chunk indexes + // address the dense logical space), but its real bytes are split at + // content-defined cut points and deduplicated; only unique pieces + // enter the group stream. The tail-block padding is never stored: + // uncovered logical bytes read back as zeros. self.next_blkaddr += block_count as u64; - - // Record the chunk by its absolute block position; chunks are tracked - // independently of groups as a digest index only. - let digest = *blake3::hash(&uncompressed).as_bytes(); - let chunk = BlobMetadataChunk::new(digest, addr, block_count)?; - self.blob_metadata_chunks.push(chunk); - - // Feed the bytes into the group stream, which packs whole blocks up to - // the group size regardless of chunk boundaries. - self.append_to_group_stream(&uncompressed)?; - + self.append_cdc_pieces(data, addr * EROFS_BLOCK_SIZE as u64)?; Ok(addr) } @@ -455,9 +408,7 @@ mod tests { shifted.extend_from_slice(&body); fs::write(&file_c, &shifted).unwrap(); - let mut writer = BlobWriter::new(&blob_path, BLOB_METADATA_DEFAULT_CHUNK_SIZE) - .unwrap() - .with_cdc(); + let mut writer = BlobWriter::new(&blob_path, BLOB_METADATA_DEFAULT_CHUNK_SIZE).unwrap(); writer .write_file_chunks(&file_a, body.len() as u64) .unwrap(); @@ -481,7 +432,6 @@ mod tests { // The recorded metadata must pass CDC validation end to end. let blob_metadata = writer.blob_metadata([0x11; EROFS_BLOB_ID_SIZE], 0).unwrap(); assert!(blob_metadata.is_cdc()); - assert_eq!(blob_metadata.chunks().len(), 0); assert!(!blob_metadata.cdc_chunks().is_empty()); // Unique stream (plus final block padding) is what the groups store. assert_eq!( @@ -511,7 +461,7 @@ mod tests { } #[test] - fn blob_writer_tracks_unique_blob_metadata_chunks() { + fn blob_writer_packs_logical_chunks_densely_and_dedups_unique_stream() { let dir = tempdir().unwrap(); let blob_path = dir.path().join("blob.data"); let file_a = dir.path().join("a.bin"); @@ -539,50 +489,29 @@ mod tests { assert_eq!(indexes_b.len(), 1); assert_eq!(indexes_a[0].blkaddr, 0); assert_eq!(indexes_a[1].blkaddr, 256); - // Dense packing: file_a's 4KiB tail chunk occupies a single block, so - // file_b starts right after it instead of being padded to a full chunk. + // Dense logical packing: file_a's 4KiB tail chunk occupies a single + // block, so file_b starts right after it instead of being padded to a + // full chunk. assert_eq!(indexes_b[0].blkaddr, 257); assert_eq!(writer.total_blocks(), 513); - let entries = writer.blob_metadata_chunks(); - let groups = writer.blob_metadata_groups(); - assert_eq!(entries.len(), 3); - assert_eq!(groups.len(), 3); - // Chunks record absolute block offsets, independent of groups. - assert_eq!(entries[0].uncompressed_block_offset(), 0); - assert_eq!(entries[0].uncompressed_block_count(), 256); - assert_eq!(entries[1].uncompressed_block_offset(), 256); - assert_eq!(entries[1].uncompressed_block_count(), 1); - assert_eq!(entries[2].uncompressed_block_offset(), 257); - assert_eq!(entries[2].uncompressed_block_count(), 256); - // Groups pack whole blocks up to the group size (256 blocks) regardless - // of chunk boundaries: file_a's tail block and file_b's leading blocks - // share group 1, and the remainder spills into group 2. - assert_eq!(groups[0].uncompressed_block_offset(), 0); - assert_eq!(groups[0].uncompressed_block_count(), 256); - assert_eq!(groups[0].compressed_byte_offset(), 0); - assert_eq!( - groups[0].compressed_size(), - BLOB_METADATA_DEFAULT_CHUNK_SIZE - ); - assert_eq!(groups[1].uncompressed_block_offset(), 256); - assert_eq!(groups[1].uncompressed_block_count(), 256); - assert_eq!( - groups[1].compressed_byte_offset(), - BLOB_METADATA_DEFAULT_CHUNK_SIZE as u64 - ); + // The repeated content dedups: file_b contributes no unique bytes and + // file_a's constant body collapses to a handful of unique pieces. + let (logical, unique) = writer.cdc_dedup_stats(); + assert_eq!(logical, (content_a.len() + BLOB_METADATA_DEFAULT_CHUNK_SIZE as usize) as u64); + assert!(unique < logical, "logical {logical}, unique {unique}"); + + // Groups describe the (block padded) unique data stream, not the + // logical space. + let blob_metadata = writer.blob_metadata([0x22; EROFS_BLOB_ID_SIZE], 0).unwrap(); assert_eq!( - groups[1].compressed_size(), - BLOB_METADATA_DEFAULT_CHUNK_SIZE + blob_metadata.total_uncompressed_size(), + round_up(unique as usize, EROFS_BLOCK_SIZE as usize) as u64 ); - assert_eq!(groups[2].uncompressed_block_offset(), 512); - assert_eq!(groups[2].uncompressed_block_count(), 1); - // Groups pack back-to-back in the data region with no inter-group padding. assert_eq!( - groups[2].compressed_byte_offset(), - 2 * BLOB_METADATA_DEFAULT_CHUNK_SIZE as u64 + blob_metadata.logical_uncompressed_size(), + 513 * EROFS_BLOCK_SIZE as u64 ); - assert_eq!(groups[2].compressed_size(), EROFS_BLOCK_SIZE); } #[test] @@ -605,11 +534,12 @@ mod tests { assert_eq!(indexes[0].blkaddr, 0); assert_eq!(indexes[1].blkaddr, 1); assert_eq!(blob_metadata.header().chunk_size(), EROFS_BLOCK_SIZE); - assert_eq!(blob_metadata.chunks().len(), 2); assert_eq!(blob_metadata.groups().len(), 1); - assert_eq!(blob_metadata.chunks()[0].uncompressed_block_count(), 1); - assert_eq!(blob_metadata.chunks()[0].uncompressed_byte_size(), 4096); - assert_eq!(blob_metadata.chunks()[1].uncompressed_block_offset(), 1); + // Two distinct 4 KiB pieces, mapped by CDC records at their logical + // offsets. + assert_eq!(blob_metadata.cdc_chunks().len(), 2); + assert_eq!(blob_metadata.cdc_chunks()[0].logical_byte_offset(), 0); + assert_eq!(blob_metadata.cdc_chunks()[1].logical_byte_offset(), 4096); assert_eq!(blob_metadata.groups()[0].uncompressed_byte_size(), 8192); } @@ -637,8 +567,13 @@ mod tests { assert_eq!(indexes[0].blkaddr, 0); assert_eq!(indexes[1].blkaddr, EROFS_NULL_ADDR); assert_eq!(indexes[2].blkaddr, 1); - assert_eq!(blob_metadata.chunks().len(), 2); - assert_eq!(blob_metadata.chunks()[1].uncompressed_block_offset(), 1); + // Only the two data chunks produce CDC records; the hole has none. + assert_eq!(blob_metadata.cdc_chunks().len(), 2); + assert_eq!(blob_metadata.cdc_chunks()[0].logical_byte_offset(), 0); + assert_eq!(blob_metadata.cdc_chunks()[1].logical_byte_offset(), 4096); + // The tail chunk's 100 real bytes are stored unpadded in the unique + // stream; the rest of its logical block reads back as zeros. + assert_eq!(blob_metadata.cdc_chunks()[1].size(), 100); assert_eq!(writer.total_blocks(), 2); let data = fs::read(&blob_path).unwrap(); assert_eq!(data.len(), 2 * EROFS_BLOCK_SIZE as usize); @@ -667,7 +602,7 @@ mod tests { // Every chunk is a hole: nothing lands in the blob at all. assert_eq!(indexes.len(), 2); assert!(indexes.iter().all(|ci| ci.blkaddr == EROFS_NULL_ADDR)); - assert!(writer.blob_metadata_chunks().is_empty()); + assert_eq!(writer.cdc_dedup_stats(), (0, 0)); assert!(writer.blob_metadata_groups().is_empty()); assert_eq!(writer.total_blocks(), 0); assert_eq!(fs::read(&blob_path).unwrap().len(), 0); @@ -693,7 +628,6 @@ mod tests { writer.finish().unwrap(); let groups = writer.blob_metadata_groups(); - assert_eq!(writer.blob_metadata_chunks().len(), 1); assert_eq!(groups.len(), 1); assert_eq!(groups[0].uncompressed_block_count(), 256); assert_eq!( @@ -723,16 +657,17 @@ mod tests { .unwrap(); let raw = fs::read(&blob_metadata_path).unwrap(); - // 4 KiB header block + one chunk + one group, padded to a block. + // 4 KiB header block + one CDC record + one group, padded to a block. assert_eq!(raw.len(), 8192); let blob_metadata = BlobMetadata::load(&blob_metadata_path).unwrap(); + assert!(blob_metadata.is_cdc()); assert_eq!(blob_metadata.header().chunk_count(), 1); assert_eq!(blob_metadata.header().group_count(), 1); - assert_eq!(blob_metadata.header().chunk_bytes(), 48); + assert_eq!(blob_metadata.header().chunk_bytes(), 56); assert_eq!(blob_metadata.header().group_bytes(), 40); assert_eq!(blob_metadata.header().metadata_size(), 8192); - assert_eq!(blob_metadata.chunks()[0].uncompressed_block_offset(), 0); + assert_eq!(blob_metadata.cdc_chunks()[0].logical_byte_offset(), 0); assert_eq!(blob_metadata.groups()[0].compressed_byte_offset(), 8192); } diff --git a/nydus/src/build/merge.rs b/nydus/src/build/merge.rs index e6086a4d9a8..1bf0a10fce9 100644 --- a/nydus/src/build/merge.rs +++ b/nydus/src/build/merge.rs @@ -796,7 +796,6 @@ mod tests { compressor: BlobMetadataCompressor::None, exclude: &exclude, standalone_bootstrap: false, - cdc: false, }, fs::File::create(&blob_path).unwrap(), ) diff --git a/nydus/src/build/mod.rs b/nydus/src/build/mod.rs index 53adb0b07bf..04c095c4887 100644 --- a/nydus/src/build/mod.rs +++ b/nydus/src/build/mod.rs @@ -49,10 +49,6 @@ pub struct DirImageOptions<'a> { /// Also render a standalone bootstrap whose device slot references the /// full blob digest, returned in [`DirImage::standalone_bootstrap`]. pub standalone_bootstrap: bool, - /// Split file data at content-defined (FastCDC) cut points and - /// deduplicate equal pieces so only unique bytes are stored. Marks the - /// blob meta with the `CHUNK_CDC` incompat flag. - pub cdc: bool, } /// The result of [`build_dir_image`]: the digests, blob meta and footer of @@ -65,9 +61,9 @@ pub struct DirImage { pub blob_metadata: BlobMetadata, pub footer: BlobFooter, pub standalone_bootstrap: Option>, - /// `(logical_bytes, unique_bytes)` seen by the CDC splitter when CDC was - /// enabled; the difference is the data removed by deduplication. - pub cdc_dedup_stats: Option<(u64, u64)>, + /// `(logical_bytes, unique_bytes)` seen by the CDC splitter; the + /// difference is the data removed by deduplication. + pub cdc_dedup_stats: (u64, u64), } impl DirImageOptions<'_> { @@ -127,9 +123,6 @@ pub fn build_dir_image(options: &DirImageOptions<'_>, blob_out: File) -> Result< options.compress_size, options.compressor, )?; - if options.cdc { - blob_writer = blob_writer.with_cdc(); - } let mut inodes = build_tree( options.source, &mut blob_writer, @@ -157,7 +150,7 @@ pub fn build_dir_image(options: &DirImageOptions<'_>, blob_out: File) -> Result< let compressed_data_size = blob_writer.data_size(); let blob_metadata = blob_writer.blob_metadata(blob_id, 0)?; - let cdc_dedup_stats = options.cdc.then(|| blob_writer.cdc_dedup_stats()); + let cdc_dedup_stats = blob_writer.cdc_dedup_stats(); let (blob_file, full_blob_hasher) = blob_writer.into_file_and_data_hasher(); let mut blob_writer_stream = HashingWriter::new(BufWriter::new(blob_file), full_blob_hasher); diff --git a/nydus/src/check/mod.rs b/nydus/src/check/mod.rs index 17ca86e01d4..bc001e802e3 100644 --- a/nydus/src/check/mod.rs +++ b/nydus/src/check/mod.rs @@ -563,7 +563,6 @@ mod tests { [0u8; EROFS_BLOB_ID_SIZE], BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, Vec::new(), - Vec::new(), ) .unwrap(); let mut blob_metadata_bytes = Vec::new(); diff --git a/nydus/src/optimize/mod.rs b/nydus/src/optimize/mod.rs index 0a3ceabe24c..d79dd618f5f 100644 --- a/nydus/src/optimize/mod.rs +++ b/nydus/src/optimize/mod.rs @@ -33,7 +33,7 @@ use nydus_format::blob::{ }; use nydus_format::erofs::EROFS_BLOB_ID_SIZE; use nydus_storage::access_trace::{TraceDocument, TraceEntry, TRACE_DOCUMENT_VERSION}; -use nydus_storage::cache::{BlobCache, LocalBlobCache}; +use nydus_storage::cache::LocalBlobCache; /// The result of [`build_ondemand_blob`]: the assembled ondemand artifact and /// the rewritten bootstrap, ready to be written out by the caller. @@ -90,7 +90,6 @@ pub fn build_ondemand_blob( let mut ondemand_data = Vec::new(); let mut ondemand_groups = Vec::new(); let mut next_block_offset = 0u64; - let mut decoded = Vec::new(); for GroupRef { blob_index, @@ -107,12 +106,9 @@ pub fn build_ondemand_blob( .with_context(|| format!("failed to open source blob {blob_index}"))?, ), }; - if cache.blob_metadata().is_cdc() { - // A CDC blob's groups describe the deduplicated unique byte - // stream, not the logical space `read_at` addresses, so its group - // bytes cannot be re-sliced into an ondemand artifact this way. - return Err(Error::Unsupported(format!( - "source blob {blob_index} uses CDC chunk dedup; optimize does not support CDC blobs yet" + if cache.blob_metadata().is_redirect_blob() { + return Err(Error::InvalidImage(format!( + "source blob {blob_index} is already an ondemand blob; refusing to optimize" ))); } @@ -124,21 +120,14 @@ pub fn build_ondemand_blob( "pattern references group {group_index} out of range for blob {blob_index}" )) })?; - if group.is_redirect() { - return Err(Error::InvalidImage(format!( - "source blob {blob_index} is already an ondemand blob; refusing to optimize" - ))); - } - let decoded_len = usize::try_from(group.uncompressed_byte_size()).map_err(|err| { - Error::Overflow(format!("group uncompressed size exceeds usize: {err}")) + // Read the group's decoded bytes straight from the backend at group + // granularity: a CDC blob's groups describe the deduplicated unique + // byte stream (not the logical space `read_at` addresses), and the + // redirect fill on the runtime side works per source group either way. + let decoded = cache.read_group(*group_index as usize).with_context(|| { + format!("failed to read blob {blob_index} group {group_index} bytes") })?; - decoded.resize(decoded_len, 0); - cache - .read_at(group.uncompressed_byte_offset(), &mut decoded) - .with_context(|| { - format!("failed to read blob {blob_index} group {group_index} bytes") - })?; // Recompress the decoded bytes for the ondemand artifact, storing them // plain when compression is not worthwhile (same policy as build). @@ -176,7 +165,6 @@ pub fn build_ondemand_blob( BLOB_METADATA_DEFAULT_CHUNK_BLOCK_COUNT, BlobMetadataCompressor::Zstd, ondemand_groups, - Vec::new(), ) .context("failed to assemble ondemand blob meta")?; diff --git a/nydus/tests/testsuite/nydus_core.rs b/nydus/tests/testsuite/nydus_core.rs index d9662a633bf..16c68f6ecda 100644 --- a/nydus/tests/testsuite/nydus_core.rs +++ b/nydus/tests/testsuite/nydus_core.rs @@ -39,8 +39,8 @@ fn build_test_image( build_test_image_with_layout(root, false) } -/// Like [`build_test_image`] but with CDC dedup enabled on the blob writer, -/// and with extra duplicate-content files so the dedup path is exercised. +/// Like [`build_test_image`] but with extra duplicate-content files so the +/// (always-on) CDC dedup path is exercised. fn build_cdc_test_image( root: &Path, ) -> ( @@ -78,7 +78,7 @@ fn build_test_image_with_layout( fn build_test_image_full( root: &Path, flattened: bool, - cdc: bool, + dedup_corpus: bool, ) -> ( PathBuf, Config, @@ -111,7 +111,7 @@ fn build_test_image_full( corpus.insert("empty.txt".to_string(), Vec::new()); symlink("file1", corpus_dir.join("link_to_file1")).unwrap(); - if cdc { + if dedup_corpus { // Duplicate content at shifted offsets: file1's bytes prefixed by a // small header, so fixed chunking would find nothing while CDC // re-synchronizes and dedups the shared tail. Plus an exact copy. @@ -139,9 +139,6 @@ fn build_test_image_full( BlobMetadataCompressor::Zstd, ) .unwrap(); - if cdc { - writer = writer.with_cdc(); - } let mut inodes = build_tree( &corpus_dir, &mut writer, @@ -150,7 +147,7 @@ fn build_test_image_full( ) .unwrap(); writer.finish().unwrap(); - if cdc { + if dedup_corpus { // The duplicate/shifted corpus must actually dedup. let (logical, unique) = writer.cdc_dedup_stats(); assert!( @@ -280,13 +277,17 @@ fn core_describes_devices_and_fetches_aligned_ranges() { core.blobs.fetch(&blob_id, offset, len).unwrap(); core.blobs.fetch(&blob_id, 0, 0).unwrap(); + // The fetched logical range maps to CDC records whose unique bytes + // straddle the 1 MiB group boundary, so both covering unique-stream + // groups are traced in access order. let trace = core.trace_snapshot(); - assert_eq!(trace.entries.len(), 1); - assert_eq!(trace.entries[0].blob_index, 1); - assert_eq!(trace.entries[0].group_index, 1); + assert_eq!(trace.entries.len(), 2); + assert!(trace.entries.iter().all(|entry| entry.blob_index == 1)); + assert_eq!(trace.entries[0].group_index, 0); + assert_eq!(trace.entries[1].group_index, 1); assert_eq!( core.trace_json(), - "{\"version\":1,\"patterns\":[{\"blob_index\":1,\"group_index\":1}]}" + "{\"version\":1,\"patterns\":[{\"blob_index\":1,\"group_index\":0},{\"blob_index\":1,\"group_index\":1}]}" ); // Unaligned ranges and unknown blobs are rejected. From ba7e7cbc83a446e255ed77d1ab4289b2cd098d23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:43:53 +0000 Subject: [PATCH 6/8] Skip metrics on no-op CDC redirect fill; apply rustfmt Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- nydus-format/src/blob/metadata.rs | 16 +++++++++------- nydus-storage/src/cache/local.rs | 12 +++++++----- nydus/src/build/blob_chunk.rs | 5 ++++- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/nydus-format/src/blob/metadata.rs b/nydus-format/src/blob/metadata.rs index f9b9773c774..b1ec15fd3af 100644 --- a/nydus-format/src/blob/metadata.rs +++ b/nydus-format/src/blob/metadata.rs @@ -809,7 +809,12 @@ impl BlobMetadata { chunk_block_count: u32, groups: Vec, ) -> Result { - Self::from_parts_with_options(blob_id, chunk_block_count, BlobMetadataCompressor::None, groups) + Self::from_parts_with_options( + blob_id, + chunk_block_count, + BlobMetadataCompressor::None, + groups, + ) } pub fn from_parts_with_options( @@ -1466,12 +1471,9 @@ mod tests { let payload_a = vec![0x11; EROFS_BLOCK_SIZE as usize]; let payload_b = vec![0x22; EROFS_BLOCK_SIZE as usize]; let group_payload = [payload_a.as_slice(), payload_b.as_slice()].concat(); - let blob_metadata = BlobMetadata::from_parts( - blob_id, - 1, - vec![group(0, 2, 8192, 8192, &group_payload)], - ) - .unwrap(); + let blob_metadata = + BlobMetadata::from_parts(blob_id, 1, vec![group(0, 2, 8192, 8192, &group_payload)]) + .unwrap(); blob_metadata.save(&path).unwrap(); let loaded = BlobMetadata::load(&path).unwrap(); diff --git a/nydus-storage/src/cache/local.rs b/nydus-storage/src/cache/local.rs index 69f7356e973..ae3495838bc 100644 --- a/nydus-storage/src/cache/local.rs +++ b/nydus-storage/src/cache/local.rs @@ -267,11 +267,14 @@ impl LocalBlobCache { ) -> io::Result<()> { let chunks = self.blob_metadata.cdc_chunks(); let group_offset = group.uncompressed_byte_offset(); + let contained = self.cdc_records_contained_in(group_offset, group.uncompressed_byte_end()); + if contained.is_empty() { + // Only straddling records touch this group; nothing to fill here. + return Ok(()); + } let cache_file = self.cache_file()?; let mut filled = false; - for index in - self.cdc_records_contained_in(group_offset, group.uncompressed_byte_end()) - { + for index in contained { if self.group_map.is_ready(index)? { continue; } @@ -1692,8 +1695,7 @@ mod tests { let cache_dir = tempdir().unwrap(); let payload = vec![0xacu8; 4096]; let data_blob_id = sha256_bytes(&payload); - let meta = - blob_metadata_with_crc32(data_blob_id, crc32c::crc32c(&payload).wrapping_add(1)); + let meta = blob_metadata_with_crc32(data_blob_id, crc32c::crc32c(&payload).wrapping_add(1)); let full_blob_id = write_minimal_full_blob(backend_dir.path(), &payload, &meta, true); let backend: Arc = Arc::new(Local::new(backend_dir.path().to_path_buf())); diff --git a/nydus/src/build/blob_chunk.rs b/nydus/src/build/blob_chunk.rs index a6e23f15d46..6edde6bec6d 100644 --- a/nydus/src/build/blob_chunk.rs +++ b/nydus/src/build/blob_chunk.rs @@ -498,7 +498,10 @@ mod tests { // The repeated content dedups: file_b contributes no unique bytes and // file_a's constant body collapses to a handful of unique pieces. let (logical, unique) = writer.cdc_dedup_stats(); - assert_eq!(logical, (content_a.len() + BLOB_METADATA_DEFAULT_CHUNK_SIZE as usize) as u64); + assert_eq!( + logical, + (content_a.len() + BLOB_METADATA_DEFAULT_CHUNK_SIZE as usize) as u64 + ); assert!(unique < logical, "logical {logical}, unique {unique}"); // Groups describe the (block padded) unique data stream, not the From d458b282999f2a420354c3c03d2fa3dbf43ffb67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:17:50 +0000 Subject: [PATCH 7/8] Never let CDC records straddle group boundaries; track CDC readiness expectations in e2e Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- docs/nydus.md | 89 ++++++++++++++++------ nydus-storage/src/cache/local.rs | 6 +- nydus/src/build/blob_chunk.rs | 111 +++++++++++++++++++++++++++- nydus/tests/testsuite/nydus_core.rs | 18 +++-- tests/e2e/cache_sharing_test.go | 2 +- tests/e2e/harness.go | 8 +- tests/e2e/roundtrip_test.go | 10 +-- 7 files changed, 199 insertions(+), 45 deletions(-) diff --git a/docs/nydus.md b/docs/nydus.md index d7e67d94cf4..44f20193935 100644 --- a/docs/nydus.md +++ b/docs/nydus.md @@ -1168,10 +1168,15 @@ Header details: Chunk details: -- CDC records are decoupled from groups: a record's unique bytes may straddle a - group boundary, and a group may contain many records' bytes. The chunk table - is a byte-granular mapping from the logical space to the unique stream, not - a per-group map. +- CDC records are decoupled from groups: a group may contain many records' + bytes, and the chunk table is a byte-granular mapping from the logical space + to the unique stream, not a per-group map. The builder does guarantee one + group invariant: no record's unique bytes ever straddle a group boundary — + when a fresh piece would cross one, the unique stream is zero padded up to + the boundary first, so every group is self-contained (a decoded group alone + satisfies every record it holds). Readers must still tolerate straddling + records from foreign writers by decoding every group the record's unique + range touches. - `digest` is the BLAKE3 hash of the piece's bytes — the deduplication key. - `logical_byte_offset` is the piece's byte position in the dense logical uncompressed address space that EROFS chunk indexes point into. @@ -1186,7 +1191,11 @@ Group details: - Groups are formed by packing whole decoded blocks up to `--compress-size` regardless of chunk boundaries, then compressing the batch as one unit. So - every group but the last is exactly `1 << group_block_bits` blocks. + every group but the last is exactly `1 << group_block_bits` blocks. The + builder zero-pads the unique stream to the group boundary when a fresh CDC + piece would straddle it (see Chunk details above), so the padding bytes are + stored inside the group — they compress to almost nothing and are never + referenced by any record. - `uncompressed_block_offset` is the decoded cache 4 KiB block offset for the group. Groups are dense and contiguous in the decoded address space. - `compressed_byte_offset` is the encoded payload's byte offset within the data @@ -1246,20 +1255,45 @@ Two address spaces are involved: max 64 KiB) and only never-seen-before pieces (by BLAKE3 digest) are appended, byte-granular, to the group stream, which is then grouped and compressed exactly as before. Many CDC records may reference the same - unique bytes — that sharing is the deduplication. + unique bytes — that sharing is the deduplication. The builder zero-pads the + stream up to the next group boundary whenever a fresh piece would straddle + it, so every record's unique bytes live in exactly one group ("groups are + self-contained"); a deduped record inherits that invariant from the first + occurrence it points at, and the padding bytes are dead stream bytes no + record references. Records are sorted by `logical_byte_offset` and never overlap; logical ranges not covered by any record (tail-block padding, elided all-zero chunks) read -back as zeros. The runtime looks a read up by binary-searching the records -overlapping the logical range, maps each cold record's unique range to its -group(s) with the same `>> group_block_bits` division, decodes those groups, -and copies the record's bytes to its logical offset in the cache file; -readiness is tracked per record in a `.chunk.map` sidecar (same format as the -group map). `nydus optimize` operates at group granularity on the unique -stream, so it supports CDC blobs directly: traced unique-stream groups are -re-encoded into the ondemand blob, and the phase-0 redirect fill fans each -decoded group's bytes out to the CDC records it fully covers, at their logical -offsets in the source cache. +back as zeros. + +Runtime design for CDC blobs: + +- **Cache layout.** The cache data file mirrors the logical space + (`logical_block_count * 4096` bytes); readiness is tracked per CDC record — + not per group — in a `.chunk.map` sidecar (same on-disk format as the + `.group.map` sidecar, one bit per chunk record). Non-CDC (groups-only) + blobs keep the per-group `.group.map`. +- **On-demand reads.** A read binary-searches the records overlapping the + logical range, sorts the cold ones by unique offset, maps each record's + unique range to its group with the `>> group_block_bits` division, decodes + the group (memoized within the call, single-flight within the process, + cross-process claimed per record), and copies the record's bytes to its + logical offset in the cache file before marking the record ready. Records + from a foreign writer that straddle a group boundary are still handled by + decoding every group the unique range touches. +- **Prefetch.** `prefetch_all` walks the records in unique-offset order so + each group is fetched and decoded roughly once, fanning every decoded + group's bytes out to all the records it contains. +- **Redirect (ondemand-blob) fill.** `nydus optimize` operates at group + granularity on the unique stream, so it supports CDC blobs directly: traced + unique-stream groups are re-encoded into the ondemand blob, and the phase-0 + redirect fill CRC-checks each decoded group and fans its bytes out to the + CDC records it fully contains, at their logical offsets in the source + cache. Because the builder guarantees records never straddle groups, a + filled group leaves no partially-warm records behind — a traced workload + replayed after prefetch is served entirely from cache with zero on-demand + backend reads. A fill that finds all its records already ready counts as a + cache hit instead of a redirect fill. ### Blocks, chunks and groups @@ -1436,7 +1470,9 @@ The build pipeline now follows this sequence: logical byte offset + unique byte offset + size). Only never-seen-before pieces enter the unique data stream, which feeds a block-oriented group builder that flushes a compression group whenever it fills to - `--compress-size`, regardless of piece boundaries. + `--compress-size`. When a fresh piece would straddle a group boundary the + unique stream is zero padded up to the boundary first, so no record ever + spans two groups. 4. Compute CRC32C over each uncompressed group of the unique stream. 5. Compress each group according to the blob_meta header compressor and append the encoded bytes directly to the data region. Encoded groups are packed @@ -1519,8 +1555,12 @@ blob digest: - `.blob.data` stores decoded uncompressed data. - `.blob.meta` stores the verified blob meta copy cached from the local backend. -- `.group.map` records which blob_meta groups have been decoded - (a shared readiness bitmap, see +- `.chunk.map` records, for a CDC data blob, which CDC chunk + records have been filled into the logical cache file (a shared readiness + bitmap, one bit per record, same on-disk format as the group map, see + [Cross-process cache sharing](#cross-process-cache-sharing-and-prefetch-dedup)). +- `.group.map` records, for a groups-only (non-CDC) blob, + which blob_meta groups have been decoded (a shared readiness bitmap, see [Cross-process cache sharing](#cross-process-cache-sharing-and-prefetch-dedup)). - `.prefetch.lock` is the cross-process prefetch lock file (empty; only its `flock` state matters). @@ -1528,9 +1568,14 @@ blob digest: single groups (empty; only its byte-range lock state matters, see [Cross-process cache sharing](#cross-process-cache-sharing-and-prefetch-dedup)). -The cache data file mirrors the decoded address space one-to-one, so a group's -bytes land at `uncompressed_block_offset * 4096` and EROFS chunk `blkaddr` -offsets index into it directly: +The cache data file mirrors the decoded address space one-to-one. For a +groups-only blob that space is the group stream itself, so a group's bytes +land at `uncompressed_block_offset * 4096`; for a CDC data blob it is the +logical space (`logical_block_count * 4096` bytes), filled record by record +from decoded unique-stream groups, and EROFS chunk `blkaddr` offsets index +into it directly. The figure below shows the groups-only shape; a CDC blob +replaces `.group.map` with `.chunk.map` (one bit per CDC record) and fills +`.blob.data` at each record's `logical_byte_offset`: ```text cache directory, artifacts named by SHA256(full blob) = diff --git a/nydus-storage/src/cache/local.rs b/nydus-storage/src/cache/local.rs index ae3495838bc..8f8f27ee6d9 100644 --- a/nydus-storage/src/cache/local.rs +++ b/nydus-storage/src/cache/local.rs @@ -258,8 +258,10 @@ impl LocalBlobCache { /// The CDC counterpart of a redirect fill: `decoded` holds one validated /// group of the unique data stream, so copy every record fully contained /// in the group's unique byte range to its logical cache offset and mark - /// those records ready. Records straddling a group boundary are left to - /// the on-demand path. + /// those records ready. The builder zero-pads the unique stream so no + /// record ever straddles a group boundary; a straddling record from a + /// foreign writer is left to the on-demand path (which reads every group + /// the record's unique range touches). fn fill_cdc_records_from_group( &self, group: &BlobMetadataGroup, diff --git a/nydus/src/build/blob_chunk.rs b/nydus/src/build/blob_chunk.rs index 6edde6bec6d..0107f1b2ec1 100644 --- a/nydus/src/build/blob_chunk.rs +++ b/nydus/src/build/blob_chunk.rs @@ -283,6 +283,18 @@ impl BlobWriter { let unique_byte_offset = match self.cdc_dedup.get(&digest) { Some(offset) => *offset, None => { + // Groups must be self-contained: a redirect fill delivers + // one decoded group at a time and can only complete + // records whose unique bytes it fully contains. Pad the + // unique stream with zeros up to the group boundary + // whenever a new piece would straddle it, so no record + // ever spans two groups (deduped records inherit the + // invariant from their first occurrence). + let group_size = self.group_size as u64; + let space = group_size - self.cdc_unique_len % group_size; + if cut.length as u64 > space && cut.length as u64 <= group_size { + self.pad_group_stream_to_boundary(space as usize)?; + } let offset = self.cdc_unique_len; self.append_to_group_stream(piece)?; self.cdc_unique_len += cut.length as u64; @@ -301,9 +313,22 @@ impl BlobWriter { Ok(()) } - /// Append block-aligned data to the current group, flushing whenever it - /// fills to the group size. A chunk may straddle a group boundary, so groups - /// are pure block runs of exactly `group_size` (except the last). + /// Zero pad the unique stream to the next group boundary and flush the + /// group. Padding bytes are stored in the group (they compress to almost + /// nothing) but are never referenced by any CDC record, so they read back + /// only as part of the group and count toward `cdc_unique_len` to keep + /// unique offsets consistent with the stream. + fn pad_group_stream_to_boundary(&mut self, pad: usize) -> Result<()> { + debug_assert_eq!(pad, self.group_size as usize - self.group_buffer.len()); + self.group_buffer.resize(self.group_size as usize, 0); + self.flush_group()?; + self.cdc_unique_len += pad as u64; + Ok(()) + } + + /// Append data to the current group, flushing whenever it fills to the + /// group size, so groups are pure block runs of exactly `group_size` + /// (except the last). fn append_to_group_stream(&mut self, mut data: &[u8]) -> Result<()> { let group_size = self.group_size as usize; while !data.is_empty() { @@ -674,6 +699,86 @@ mod tests { assert_eq!(blob_metadata.groups()[0].compressed_byte_offset(), 8192); } + #[test] + fn cdc_records_never_straddle_a_group_boundary() { + let dir = tempdir().unwrap(); + let blob_path = dir.path().join("blob.data"); + let file_a = dir.path().join("a.bin"); + let file_b = dir.path().join("b.bin"); + let file_c = dir.path().join("c.bin"); + + // Small groups (one CDC max piece) with partial dedup: file_b's odd + // tail leaves the unique stream unaligned, so file_c's fresh pieces + // would straddle group boundaries without the builder's zero padding. + let group_size = CDC_MAX_CHUNK_SIZE; + let body = pseudo_random_bytes(group_size as usize); + fs::write(&file_a, &body).unwrap(); + fs::write(&file_b, &body[..10_007]).unwrap(); + let mut other = pseudo_random_bytes(4 * group_size as usize); + for byte in other.iter_mut() { + *byte = byte.wrapping_add(1); + } + fs::write(&file_c, &other).unwrap(); + + let file = File::create(&blob_path).unwrap(); + let mut writer = BlobWriter::from_file( + file, + EROFS_BLOCK_SIZE, + group_size, + BlobMetadataCompressor::None, + ) + .unwrap(); + writer + .write_file_chunks(&file_a, body.len() as u64) + .unwrap(); + writer.write_file_chunks(&file_b, 10_007).unwrap(); + writer + .write_file_chunks(&file_c, other.len() as u64) + .unwrap(); + writer.finish().unwrap(); + + let blob_metadata = writer.blob_metadata([0x33; EROFS_BLOB_ID_SIZE], 0).unwrap(); + assert!(blob_metadata.cdc_chunks().len() > 4); + // Padding must actually have kicked in: the unique stream is longer + // than the distinct piece bytes it stores. + let (_, unique) = writer.cdc_dedup_stats(); + let piece_bytes: u64 = { + let mut uniq: Vec<(u64, u32)> = blob_metadata + .cdc_chunks() + .iter() + .map(|c| (c.unique_byte_offset(), c.size())) + .collect(); + uniq.sort_unstable(); + uniq.dedup(); + uniq.iter().map(|&(_, size)| size as u64).sum() + }; + assert!( + unique > piece_bytes, + "expected group-boundary zero padding in the unique stream \ + (unique {unique}, piece bytes {piece_bytes})" + ); + let group_size = group_size as u64; + for chunk in blob_metadata.cdc_chunks() { + let first_group = chunk.unique_byte_offset() / group_size; + let last_group = (chunk.unique_byte_end() - 1) / group_size; + assert_eq!( + first_group, + last_group, + "record at unique offset {} size {} straddles a group boundary", + chunk.unique_byte_offset(), + chunk.size() + ); + } + // The padded unique stream is exactly what the groups describe. + assert_eq!( + blob_metadata.total_uncompressed_size(), + round_up( + writer.cdc_dedup_stats().1 as usize, + EROFS_BLOCK_SIZE as usize + ) as u64 + ); + } + fn pseudo_random_bytes(len: usize) -> Vec { let mut value = 0x1234_5678_9abc_def0u64; (0..len) diff --git a/nydus/tests/testsuite/nydus_core.rs b/nydus/tests/testsuite/nydus_core.rs index 16c68f6ecda..543a9528d0f 100644 --- a/nydus/tests/testsuite/nydus_core.rs +++ b/nydus/tests/testsuite/nydus_core.rs @@ -252,12 +252,13 @@ fn core_describes_devices_and_fetches_aligned_ranges() { assert_eq!(bootstrap_ranges[0].source_offset, 0); assert_eq!(bootstrap_ranges[0].len, EROFS_BLOCK_SIZE as u64); - // Fetch a block-aligned range in the middle; the cache file should be - // populated for that range and a second fetch is idempotent. The dense - // blob address space is independent of path order, so exact file - // content is covered by the static read API test below. + // Fetch a block-aligned range spanning more than one group's worth of + // data; the cache file should be populated for that range and a second + // fetch is idempotent. The dense blob address space is independent of + // path order, so exact file content is covered by the static read API + // test below. let block = EROFS_BLOCK_SIZE as u64; - let (blob_offset, len) = (256 * block, 16 * block); + let (blob_offset, len) = (block, 272 * block); let offset = descriptor.mapped_offset + blob_offset; assert!(core.probe_flat_ranges(offset, len).unwrap().is_empty()); let fd_ranges = core.fetch_flat_ranges(offset, len).unwrap(); @@ -277,9 +278,10 @@ fn core_describes_devices_and_fetches_aligned_ranges() { core.blobs.fetch(&blob_id, offset, len).unwrap(); core.blobs.fetch(&blob_id, 0, 0).unwrap(); - // The fetched logical range maps to CDC records whose unique bytes - // straddle the 1 MiB group boundary, so both covering unique-stream - // groups are traced in access order. + // The fetched logical range covers CDC records mapped to both of the + // first two unique-stream groups (records never straddle a group + // boundary — the builder pads instead), so both groups are traced in + // access order. let trace = core.trace_snapshot(); assert_eq!(trace.entries.len(), 2); assert!(trace.entries.iter().all(|entry| entry.blob_index == 1)); diff --git a/tests/e2e/cache_sharing_test.go b/tests/e2e/cache_sharing_test.go index 0ea2b678faa..ab9d42aa696 100644 --- a/tests/e2e/cache_sharing_test.go +++ b/tests/e2e/cache_sharing_test.go @@ -646,7 +646,7 @@ func TestCacheSharingStaleGroupmapKeepsInode(t *testing.T) { sha256File(t, filepath.Join(first.mnt, "shared.bin"))) sharedKey := sha256File(t, fixture.blobB) - groupmap := filepath.Join(cacheDir, sharedKey+".group.map") + groupmap := filepath.Join(cacheDir, sharedKey+".chunk.map") blobData := filepath.Join(cacheDir, sharedKey+".blob.data") require.FileExists(t, groupmap) require.FileExists(t, blobData) diff --git a/tests/e2e/harness.go b/tests/e2e/harness.go index 6d207bccedf..0cf137b22d8 100644 --- a/tests/e2e/harness.go +++ b/tests/e2e/harness.go @@ -449,11 +449,11 @@ func sha256File(t *testing.T, path string) string { } // wipeCacheDir removes every per-blob artifact so the next daemon starts -// COLD. Leaving a stale .group.map behind makes the daemon believe groups -// are ready while the re-created .blob.data is all zeros — reads would -// return zeros without fetching. Wipe data+meta+map+lock. +// COLD. Leaving a stale .group.map/.chunk.map behind makes the daemon believe +// groups (or CDC records) are ready while the re-created .blob.data is all +// zeros — reads would return zeros without fetching. Wipe data+meta+map+lock. func wipeCacheDir(cacheDir string) { - for _, pattern := range []string{"*.blob.data", "*.blob.meta", "*.group.map", "*.prefetch.lock"} { + for _, pattern := range []string{"*.blob.data", "*.blob.meta", "*.group.map", "*.chunk.map", "*.prefetch.lock"} { matches, _ := filepath.Glob(filepath.Join(cacheDir, pattern)) for _, m := range matches { _ = os.Remove(m) diff --git a/tests/e2e/roundtrip_test.go b/tests/e2e/roundtrip_test.go index 3a7ff987df1..37e97716cb9 100644 --- a/tests/e2e/roundtrip_test.go +++ b/tests/e2e/roundtrip_test.go @@ -418,7 +418,7 @@ func verifyBlobCacheArtifacts(t *testing.T, cacheDir string, blobs ...string) { require.NoError(t, err) var dataCount int - var groupmapCount int + var chunkmapCount int var blobMetaCount int for _, entry := range entries { if entry.IsDir() { @@ -429,8 +429,8 @@ func verifyBlobCacheArtifacts(t *testing.T, cacheDir string, blobs ...string) { switch { case strings.HasSuffix(name, ".blob.data"): dataCount++ - case strings.HasSuffix(name, ".group.map"): - groupmapCount++ + case strings.HasSuffix(name, ".chunk.map"): + chunkmapCount++ case strings.HasSuffix(name, ".blob.meta"): blobMetaCount++ } @@ -438,14 +438,14 @@ func verifyBlobCacheArtifacts(t *testing.T, cacheDir string, blobs ...string) { blobCount := len(blobs) assert.Equal(t, blobCount, dataCount, "unexpected cached blob.data count") - assert.Equal(t, blobCount, groupmapCount, "unexpected cached groupmap count") + assert.Equal(t, blobCount, chunkmapCount, "unexpected cached chunkmap count") assert.Equal(t, blobCount, blobMetaCount, "unexpected cached blob_meta count") for _, blob := range blobs { prefix := fullBlobDigest(t, blob) require.FileExists(t, filepath.Join(cacheDir, prefix+".blob.data")) require.FileExists(t, filepath.Join(cacheDir, prefix+".blob.meta")) - require.FileExists(t, filepath.Join(cacheDir, prefix+".group.map")) + require.FileExists(t, filepath.Join(cacheDir, prefix+".chunk.map")) } } From 9d769780aeb76439b44564b65ec7ef0c0d649a5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:29:31 +0000 Subject: [PATCH 8/8] Rename groupmap variable to chunkmap in cache sharing test Co-authored-by: imeoer <1524576+imeoer@users.noreply.github.com> --- nydusify/go.sum | 3 --- tests/e2e/cache_sharing_test.go | 12 ++++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/nydusify/go.sum b/nydusify/go.sum index d406e4c6b13..c0feb8b43fc 100644 --- a/nydusify/go.sum +++ b/nydusify/go.sum @@ -31,7 +31,6 @@ github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsx github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= @@ -95,7 +94,6 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -110,7 +108,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= diff --git a/tests/e2e/cache_sharing_test.go b/tests/e2e/cache_sharing_test.go index ab9d42aa696..ed339301c25 100644 --- a/tests/e2e/cache_sharing_test.go +++ b/tests/e2e/cache_sharing_test.go @@ -646,11 +646,11 @@ func TestCacheSharingStaleGroupmapKeepsInode(t *testing.T) { sha256File(t, filepath.Join(first.mnt, "shared.bin"))) sharedKey := sha256File(t, fixture.blobB) - groupmap := filepath.Join(cacheDir, sharedKey+".chunk.map") + chunkmap := filepath.Join(cacheDir, sharedKey+".chunk.map") blobData := filepath.Join(cacheDir, sharedKey+".blob.data") - require.FileExists(t, groupmap) + require.FileExists(t, chunkmap) require.FileExists(t, blobData) - before := cacheFileInode(t, groupmap) + before := cacheFileInode(t, chunkmap) // Simulate an external reclaimer that removes only the data file while the // first mount is still running. @@ -665,12 +665,12 @@ func TestCacheSharingStaleGroupmapKeepsInode(t *testing.T) { require.Equal(t, sha256Bytes(fixture.files["shared.bin"]), sha256File(t, filepath.Join(second.mnt, "shared.bin"))) - after := cacheFileInode(t, groupmap) - t.Logf("groupmap inode before=%d after=%d", before, after) + after := cacheFileInode(t, chunkmap) + t.Logf("chunkmap inode before=%d after=%d", before, after) // The bitmap is reset in place, so both processes keep observing the same // file. Replacing it would split them onto separate inodes and each would // publish readiness the other can never see. require.Equal(t, before, after, - "the stale groupmap must be reset in place, not replaced") + "the stale chunkmap must be reset in place, not replaced") }