From 5a0f57d9b91757614a910072d85f0b08740101a7 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Wed, 2 Sep 2026 14:51:00 -0700 Subject: [PATCH 1/2] Reduce Turbopack cache size with per-family compression (#97714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Adds per-family compression configuration to `turbo-persistence` and configures the Turbopack filesystem cache according to each keyspace's access pattern: | Family | Compression | Reason | | --- | --- | --- | | Infra | LZ4 HC level 4 | Preserve LZ4 decode performance while improving write-time compression | | TaskMeta | LZ4 HC level 4 | Preserve latency-sensitive metadata reads | | TaskCache | LZ4 HC level 4 | Preserve latency-sensitive task-cache reads | | TaskData | zstd level 3 | Prioritize the dominant disk-size opportunity | The low-level configuration exposes only the three presets this database needs: LZ4, LZ4 HC4, and zstd3. Compression is applied consistently to SST blocks, blob values, and compaction output. Each database owns a `ThreadLocal` that lazily creates one lock-free zstd decompressor per participating thread; the linked zstd reports each context as about 96 KiB, and all contexts are released when the database drops. Each meta file stores its family's length-prefixed, bincode-encoded compression preset once. SST and blob headers remain unchanged, including the 12.5% minimum-savings fallback to uncompressed blocks. On normal database open, the stored marker must match the runtime `FamilyConfig`; a mismatch, invalid/truncated preset, or pre-marker meta file is rejected before reads are served. This intentionally requires a fresh/versioned cache after a codec change, consistent with `turbo-persistence`'s existing no-cross-version-compatibility policy. The marker also removes the duplicated keyspace-to-codec mapping from `sst_inspect`: it reads the codec directly from metadata. `turbo-persistence-tools` can inspect metadata without supplying a Turbopack-specific family configuration. ## Why? LZ4 decode performance is important for cache query latency, but using one codec for every keyspace leaves a substantial disk-size opportunity in TaskData. The families do not have the same tradeoff: metadata and task-cache reads should stay on the LZ4 decoder, while TaskData can spend modestly more CPU to reduce persistent cache size. The final three-workload A/B reduced fresh cache directories by **20.61% overall**, with no measured cold/write or warm/read regression (the observed -3.42% / -2.39% should be treated as noise, not a speed claim). ## How? ### Real Next.js cache A/B The complete A/B was re-run after the final fixed-preset, bincode-marker, and database-owned thread-local revision. Both the unchanged base worktree and final proposed working tree were bootstrapped with `pnpm install --frozen-lockfile` and `pnpm build-all`, using locally compiled native bindings on the same 8-vCPU Intel Xeon VM (16.3 GiB RAM, Linux 6.18.40, Node 24.14.1). Each workload used three fresh-cache builds and five warm-cache rebuilds; the table reports medians. ```sh TURBO_ENGINE_IGNORE_DIRTY=1 NEXT_TELEMETRY_DISABLED=1 \ node packages/next/dist/bin/next build --turbopack ``` `test/e2e/filesystem-cache` additionally used `ENABLE_CACHING=1`. The other fixtures temporarily enabled `experimental.turbopackFileSystemCacheForBuild`; those fixture changes are not in this PR. | Workload | Cache size | Cold/write | Warm/read | | --- | ---: | ---:| ---: | | `test/e2e/filesystem-cache` | **-20.62%** | -2.24% | -3.00% | | `test/e2e/app-dir/app-rendering` | **-20.11%** | -6.04% | -1.47% | | `test/e2e/app-dir/client-reference-chunking` | **-21.02%** | -1.45% | -3.56% | | Combined medians | **-20.61%** (96.810 → 76.862 MiB) | **-3.42%** | **-2.39%** | Per-family inspection of a final filesystem-cache build, using the marker-derived codec: | Family | Total file size | Value-block savings | | --- | ---: | ---: | | Infra | 52 B | n/a | | TaskMeta | 6.31 MB | 29% | | TaskData | 18.17 MB | 66% | | TaskCache | 1.65 MB | n/a | The originally planned `app` and `app-static` fixtures could not run as standalone builds: `app` hit the existing Edge-runtime `process.cwd()` build failure, and `app-static` required an external data endpoint during page-data collection. They were replaced with the two successful repository fixtures shown above under the same base/proposed methodology. ### Temporary crate benchmark instrumentation The codec-specific Criterion cases were used locally to gather receipts, then removed from the delivered patch per review. | Codec | Synthetic write | vs LZ4 | Uncached get | Cached get | DB size | | --- | ---: | ---: | ---: | ---: | ---: | | LZ4 | 38.173 ms | baseline | 7.3004 µs | 10.735 µs | 21.59 MiB | | LZ4 HC4 | 256.31 ms | **+571.7%** | 3.4853 µs | 8.5467 µs | 21.57 MiB | | zstd3 | 57.790 ms | **+51.4%** | 6.2460 µs | 9.5323 µs | 21.49 MiB | The synthetic workload applies one codec to the entire database and barely separates codecs by size, so it is not representative of the real TaskData distribution. The all-HC write regression is real but confined to smaller families in the actual configuration. Cached reads do not decompress blocks, and the read samples contain substantial ordering/outlier noise. The real-cache A/B is the read-regression guard. ## Vercel Site ### Size 2.9G canary 2.2G this PR a 25% savings ### Performance Cold build (n=5), warm build (n=3), medians: | Scenario | Metric | Canary | Compression | Δ | |---|---|---|---|---| | **Cold** | wall (s) | 71.28 | 72.86 | +2.2% | | | user (s) | 555.21 | 570.23 | +2.7% | | | sys (s) | 94.49 | 95.00 | +0.5% | | | maxRSS (GB) | 17.95 | 17.98 | +0.2% | | **Warm** | wall (s) | 15.00 | 15.32 | +2.1% | | | user (s) | 7.59 | 9.98 | +31% | | | sys (s) | 12.79 | 12.87 | +0.6% | | | maxRSS (GB) | 3.78 | 3.86 | +2.0% | maxRSS converted at 1 GB = 2^30 bytes. Looking at tracing data i see in a cold build `persist` span went from 5.06s duration using 63s cpu time to 6.04s using 73s cpu time, a ~20% regression. Which explains the cpu regression and the time progression. Because the time regression is in the `persist` span during shutdown the added latency is somewhat hidden. Of course we also see a small wall/user time regression in warm builds due to the extra decompression costs for zstd. ### Verification - `cargo test -p turbo-persistence` (79 tests) - `cargo test -p turbo-tasks-backend` (99 unit tests plus integration suites) - `cargo test -p turbo-persistence-tools` - `cargo test -p turbo-persistence --all-targets --no-run` - `cargo fmt --all -- --check` - `cargo clippy -p turbo-persistence -p turbo-persistence-tools -p turbo-tasks-backend --all-targets -- -D warnings` - `cargo check -p turbo-persistence -p turbo-persistence-tools -p turbo-tasks-backend` - `pnpm lint-ast-grep` - `pnpm build-all` --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + turbopack/crates/turbo-persistence/Cargo.toml | 1 + turbopack/crates/turbo-persistence/README.md | 9 +- .../crates/turbo-persistence/benches/mod.rs | 20 ++- .../crates/turbo-persistence/src/arc_bytes.rs | 8 +- .../turbo-persistence/src/bin/sst_inspect.rs | 21 ++- .../turbo-persistence/src/compression.rs | 132 +++++++++++++++--- turbopack/crates/turbo-persistence/src/db.rs | 47 +++++-- turbopack/crates/turbo-persistence/src/lib.rs | 10 +- .../crates/turbo-persistence/src/meta_file.rs | 66 +++++++-- .../src/meta_file_builder.rs | 6 +- .../crates/turbo-persistence/src/rc_bytes.rs | 8 +- .../turbo-persistence/src/shared_bytes.rs | 8 +- .../src/static_sorted_file.rs | 122 +++++++++++----- .../src/static_sorted_file_builder.rs | 52 +++++-- .../crates/turbo-persistence/src/tests.rs | 61 ++++++-- .../turbo-persistence/src/write_batch.rs | 42 +++--- .../src/database/key_value_database.rs | 11 +- 19 files changed, 488 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ac005609e22..a7a2a4fb8799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9998,6 +9998,7 @@ dependencies = [ "turbo-tasks-malloc", "xxhash-rust", "zerocopy", + "zstd", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 68ef7787c403..67277da42881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -358,6 +358,7 @@ tracing = "0.1.44" tracing-subscriber = "0.3.16" triomphe = { git = "https://github.com/sokra/triomphe", branch = "sokra/unstable" } xxhash-rust = { version = "0.8.12", features = ["xxh3"] } +zstd = "0.13.2" unsize = "1.1.0" unty = "0.0.4" url = "2.2.2" diff --git a/turbopack/crates/turbo-persistence/Cargo.toml b/turbopack/crates/turbo-persistence/Cargo.toml index ea5d8475a679..f02e2873fa8b 100644 --- a/turbopack/crates/turbo-persistence/Cargo.toml +++ b/turbopack/crates/turbo-persistence/Cargo.toml @@ -38,6 +38,7 @@ smallvec = { workspace = true } thread_local = { workspace = true } tracing = { workspace = true } xxhash-rust = { workspace = true } +zstd = { workspace = true } [dev-dependencies] criterion = { workspace = true } diff --git a/turbopack/crates/turbo-persistence/README.md b/turbopack/crates/turbo-persistence/README.md index 54bf8320b6a8..52c0f31305cc 100644 --- a/turbopack/crates/turbo-persistence/README.md +++ b/turbopack/crates/turbo-persistence/README.md @@ -56,6 +56,7 @@ A meta file can contain metadata about multiple SST files. The metadata is store - Header - 4 bytes magic number (0xFE4ADA4A) - 4 bytes key family + - 1 byte compression algorithm, which must match the configuration used to open the database - 4 bytes count of obsolete SST files - foreach obsolete SST file - 4 bytes sequence number of the obsolete SST file @@ -88,9 +89,11 @@ The SST file contains only data without any header. #### Block Compression -Blocks can be stored compressed (LZ4) or uncompressed. The 4-byte header distinguishes them: +Blocks can be stored compressed or uncompressed. The compression algorithm is specified in the meta file. -- **Header > 0**: Block is LZ4 compressed. Header value is the uncompressed length. +The 4-byte header distinguishes compressed from uncompressed storage: + +- **Header > 0**: Block is compressed with the family's configured algorithm. Header value is the uncompressed length. - **Header = 0**: Block is stored uncompressed. Actual length is derived from block offsets. #### Block Checksum @@ -231,7 +234,7 @@ The plain value compressed with dynamic compression. Each blob file has an 8-byt - 4 bytes: uncompressed length (u32 big-endian) - 4 bytes: CRC32 checksum of the compressed data (u32 big-endian) -- remaining bytes: LZ4-compressed value data +- remaining bytes: value data compressed with the blob's key-family configuration The checksum is verified on the compressed data **before** decompression when the blob is read. diff --git a/turbopack/crates/turbo-persistence/benches/mod.rs b/turbopack/crates/turbo-persistence/benches/mod.rs index 0c617939bd2f..70cc4baad6df 100644 --- a/turbopack/crates/turbo-persistence/benches/mod.rs +++ b/turbopack/crates/turbo-persistence/benches/mod.rs @@ -10,9 +10,9 @@ use quick_cache::sync::GuardResult; use rand::{RngExt, SeedableRng, rngs::SmallRng, seq::SliceRandom}; use tempfile::TempDir; use turbo_persistence::{ - ArcBytes, BlockCache, CompactConfig, DbConfig as TpDbConfig, Entry, EntryValue, FamilyConfig, - FamilyKind, MetaEntryFlags, SerialScheduler, StaticSortedFile, StaticSortedFileMetaData, - TurboPersistence, hash_key, write_static_stored_file, + ArcBytes, BlockCache, CompactConfig, Compression, DbConfig as TpDbConfig, Entry, EntryValue, + FamilyConfig, FamilyKind, MetaEntryFlags, SerialScheduler, StaticSortedFile, + StaticSortedFileMetaData, TurboPersistence, hash_key, write_static_stored_file, }; use turbo_tasks_malloc::TurboMalloc; @@ -622,6 +622,7 @@ fn prefill_multi_value_database( family_configs: [FamilyConfig { name: "test", kind: FamilyKind::MultiValue, + compression: Compression::Lz4, }], }; let db = @@ -696,6 +697,7 @@ fn open_multi_value_db(path: &Path) -> TurboPersistence { family_configs: [FamilyConfig { name: "test", kind: FamilyKind::MultiValue, + compression: Compression::Lz4, }], }; TurboPersistence::::open_with_config(path.to_path_buf(), db_config).unwrap() @@ -964,6 +966,7 @@ fn bench_write_multi_value(c: &mut Criterion) { family_configs: [FamilyConfig { name: "test", kind: FamilyKind::MultiValue, + compression: Compression::Lz4, }], }; let db = TurboPersistence::::open_with_config( @@ -1201,15 +1204,20 @@ fn bench_static_sorted_file_lookup(c: &mut Criterion) { // Create temp directory and write SST file let tempdir = tempfile::tempdir().unwrap(); let sst_path = tempdir.path().join("00000001.sst"); - let (meta, _file) = - write_static_stored_file(&entries, &sst_path, MetaEntryFlags::FRESH).unwrap(); + let (meta, _file) = write_static_stored_file( + &entries, + &sst_path, + MetaEntryFlags::FRESH, + Compression::Lz4, + ) + .unwrap(); // Open the SST file let sst_meta = StaticSortedFileMetaData { sequence_number: 1, block_count: meta.block_count, }; - let sst = StaticSortedFile::open(tempdir.path(), sst_meta).unwrap(); + let sst = StaticSortedFile::open(tempdir.path(), sst_meta, Compression::Lz4).unwrap(); // Create block caches let key_block_cache: BlockCache = BlockCache::with( diff --git a/turbopack/crates/turbo-persistence/src/arc_bytes.rs b/turbopack/crates/turbo-persistence/src/arc_bytes.rs index 51900c354184..09e189e9a307 100644 --- a/turbopack/crates/turbo-persistence/src/arc_bytes.rs +++ b/turbopack/crates/turbo-persistence/src/arc_bytes.rs @@ -9,6 +9,7 @@ use std::{ use memmap2::Mmap; use crate::{ + Compression, compression::decompress_into_arc, shared_bytes::{SharedBytes, is_subslice_of}, }; @@ -144,8 +145,13 @@ impl SharedBytes for ArcBytes { } } - fn from_decompressed(uncompressed_length: u32, block: &[u8]) -> anyhow::Result { + fn from_decompressed( + compression: Compression, + uncompressed_length: u32, + block: &[u8], + ) -> anyhow::Result { Ok(ArcBytes::from(decompress_into_arc( + compression, uncompressed_length, block, )?)) diff --git a/turbopack/crates/turbo-persistence/src/bin/sst_inspect.rs b/turbopack/crates/turbo-persistence/src/bin/sst_inspect.rs index b7f4c5708bac..a35d2acfce26 100644 --- a/turbopack/crates/turbo-persistence/src/bin/sst_inspect.rs +++ b/turbopack/crates/turbo-persistence/src/bin/sst_inspect.rs @@ -20,7 +20,7 @@ use fs_err::{self as fs, File}; use lzzzz::lz4::decompress; use memmap2::Mmap; use turbo_persistence::{ - BLOCK_HEADER_SIZE, MAX_INLINE_VALUE_SIZE, checksum_block, + BLOCK_HEADER_SIZE, Compression, MAX_INLINE_VALUE_SIZE, checksum_block, meta_file::MetaFile, mmap_helper::advise_mmap_for_persistence, read_current_version, @@ -133,6 +133,7 @@ impl SstStats { struct SstInfo { sequence_number: u32, block_count: u16, + compression: Compression, } /// Accumulates statistics for a single entry of the given type. @@ -266,7 +267,8 @@ fn collect_sst_info(db_path: &Path) -> Result>> { let mut meta_files: Vec = meta_seqs .iter() .map(|&seq| { - MetaFile::open(db_path, seq).with_context(|| format!("Failed to open {seq:08}.meta")) + MetaFile::open(db_path, seq, None) + .with_context(|| format!("Failed to open {seq:08}.meta")) }) .collect::>()?; @@ -283,6 +285,7 @@ fn collect_sst_info(db_path: &Path) -> Result>> { family_sst_info.entry(family).or_default().push(SstInfo { sequence_number: entry.sequence_number(), block_count: entry.block_count(), + compression: meta.compression(), }); } } @@ -304,6 +307,7 @@ fn read_block( block_offsets_start: usize, block_index: u16, sequence_number: u32, + compression: Compression, ) -> Result { let offset = block_offsets_start + block_index as usize * size_of::(); @@ -343,7 +347,13 @@ fn read_block( let data = if was_compressed { let mut buffer = vec![0u8; uncompressed_length as usize]; - let bytes_written = decompress(compressed_data, &mut buffer)?; + let bytes_written = match compression { + Compression::Lz4 => { + decompress(compressed_data, &mut buffer).context("LZ4 decompression failed")? + } + Compression::Zstd3 => zstd::bulk::decompress_to_buffer(compressed_data, &mut buffer) + .context("zstd decompression failed")?, + }; assert_eq!( bytes_written, uncompressed_length as usize, "Decompressed length does not match expected" @@ -471,6 +481,7 @@ fn iter_key_block_entry_types( /// Analyze an SST file and return entry type statistics fn analyze_sst_file(db_path: &Path, info: &SstInfo) -> Result { + let compression = info.compression; let filename = format!("{:08}.sst", info.sequence_number); let path = db_path.join(&filename); @@ -496,6 +507,7 @@ fn analyze_sst_file(db_path: &Path, info: &SstInfo) -> Result { block_offsets_start, index_block_index, info.sequence_number, + compression, )?; let key_block_indices = parse_key_block_indices(&index_raw.data); @@ -512,6 +524,7 @@ fn analyze_sst_file(db_path: &Path, info: &SstInfo) -> Result { block_offsets_start, block_index, info.sequence_number, + compression, ) { Ok(raw) => raw, Err(e) => { @@ -930,7 +943,7 @@ fn main() -> Result<()> { db_path.display() ); - // Analyze and report by family + // Analyze and report by family. for (family, sst_list) in &family_sst_info { let mut family_stats = SstStats::default(); let mut sst_stats_list: Vec<(u32, SstStats)> = Vec::new(); diff --git a/turbopack/crates/turbo-persistence/src/compression.rs b/turbopack/crates/turbo-persistence/src/compression.rs index 608aeb48aadc..69d645f23d61 100644 --- a/turbopack/crates/turbo-persistence/src/compression.rs +++ b/turbopack/crates/turbo-persistence/src/compression.rs @@ -1,25 +1,59 @@ -use std::{mem::MaybeUninit, rc::Rc, sync::Arc}; +use std::{cell::RefCell, mem::MaybeUninit, rc::Rc, sync::Arc}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use lzzzz::lz4::{self, decompress}; +/// Compression algorithm used for a family's SST blocks and blob values. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum Compression { + /// Fast LZ4 compression using the default acceleration level. + #[default] + Lz4 = 0, + /// Zstandard compression at level 3. + Zstd3 = 1, +} + +thread_local! { + /// Zstd decompression contexts are reusable and relatively expensive to create. Keep one per + /// worker thread to avoid allocation on every block read without a global lock. + static ZSTD_DECOMPRESSOR: RefCell> = RefCell::new( + zstd::bulk::Decompressor::new().expect("zstd decompressor initialization should succeed") + ); +} + /// Decompresses `block` into `dest`, verifying the output length matches `expected_len`. -fn decompress_block(block: &[u8], dest: &mut [u8], expected_len: u32) -> Result<()> { +fn decompress_block( + compression: Compression, + block: &[u8], + dest: &mut [u8], + expected_len: u32, +) -> Result<()> { debug_assert!( expected_len > 0, "decompress_block called with uncompressed_length=0; uncompressed blocks should use \ zero-copy mmap path" ); - let bytes_written = decompress(block, dest).with_context(|| { + let bytes_written = match compression { + Compression::Lz4 => decompress(block, dest).map_err(anyhow::Error::from), + Compression::Zstd3 => ZSTD_DECOMPRESSOR.with_borrow_mut(|decompressor| { + decompressor + .decompress_to_buffer(block, dest) + .map_err(anyhow::Error::from) + }), + } + .with_context(|| { format!( - "Failed to decompress block ({} bytes compressed, {} bytes uncompressed)", + "Failed to decompress {compression:?} block ({} bytes compressed, {} bytes \ + uncompressed)", block.len(), expected_len ) })?; - assert_eq!( - bytes_written, expected_len as usize, - "Decompressed length does not match expected length" + ensure!( + bytes_written == expected_len as usize, + "Decompressed length does not match expected length: decompressed {bytes_written} bytes, \ + expected {expected_len}" ); Ok(()) } @@ -28,27 +62,35 @@ fn decompress_block(block: &[u8], dest: &mut [u8], expected_len: u32) -> Result< /// /// The caller must ensure `uncompressed_length > 0` (i.e., the block is actually compressed). /// Uncompressed blocks should be handled via zero-copy mmap slices before calling this. -pub fn decompress_into_arc(uncompressed_length: u32, block: &[u8]) -> Result> { +pub(crate) fn decompress_into_arc( + compression: Compression, + uncompressed_length: u32, + block: &[u8], +) -> Result> { // Allocate directly into an Arc to avoid a copy. The buffer is uninitialized; // decompression will overwrite it completely (verified by decompress_block). let buffer: Arc<[MaybeUninit]> = Arc::new_uninit_slice(uncompressed_length as usize); - // Safety: decompression will fully initialize the buffer (verified by the assert in + // Safety: decompression will fully initialize the buffer (verified by the length check in // decompress_block). let mut buffer = unsafe { buffer.assume_init() }; // We just created this Arc so refcount is 1; get_mut always succeeds. let dest = Arc::get_mut(&mut buffer).expect("Arc refcount should be 1"); - decompress_block(block, dest, uncompressed_length)?; + decompress_block(compression, block, dest, uncompressed_length)?; Ok(buffer) } /// Like [`decompress_into_arc`] but returns an `Rc<[u8]>` for thread-local use. -pub fn decompress_into_rc(uncompressed_length: u32, block: &[u8]) -> Result> { +pub(crate) fn decompress_into_rc( + compression: Compression, + uncompressed_length: u32, + block: &[u8], +) -> Result> { let buffer: Rc<[MaybeUninit]> = Rc::new_uninit_slice(uncompressed_length as usize); - // Safety: decompression will fully initialize the buffer (verified by the assert in + // Safety: decompression will fully initialize the buffer (verified by the length check in // decompress_block). let mut buffer = unsafe { buffer.assume_init() }; let dest = Rc::get_mut(&mut buffer).expect("Rc refcount should be 1"); - decompress_block(block, dest, uncompressed_length)?; + decompress_block(compression, block, dest, uncompressed_length)?; Ok(buffer) } @@ -57,8 +99,62 @@ pub fn checksum_block(data: &[u8]) -> u32 { crc32fast::hash(data) } -#[tracing::instrument(level = "trace", skip_all)] -pub fn compress_into_buffer(block: &[u8], buffer: &mut Vec) -> Result<()> { - lz4::compress_to_vec(block, buffer, lz4::ACC_LEVEL_DEFAULT).context("Compression failed")?; - Ok(()) +/// Reusable compressor for a stream of blocks using the same family configuration. +pub(crate) struct Compressor { + compression: Compression, + zstd: Option>, +} + +impl Compressor { + pub(crate) fn new(compression: Compression) -> Result { + let zstd = match compression { + Compression::Zstd3 => { + Some(zstd::bulk::Compressor::new(3).context("Failed to create zstd compressor")?) + } + Compression::Lz4 => None, + }; + Ok(Self { compression, zstd }) + } + + #[tracing::instrument(level = "trace", skip_all)] + pub(crate) fn compress_into_buffer( + &mut self, + block: &[u8], + buffer: &mut Vec, + ) -> Result<()> { + match self.compression { + Compression::Lz4 => { + lz4::compress_to_vec(block, buffer, lz4::ACC_LEVEL_DEFAULT) + .context("LZ4 compression failed")?; + } + Compression::Zstd3 => { + buffer.reserve(zstd::zstd_safe::compress_bound(block.len())); + self.zstd + .as_mut() + .expect("zstd compressor not initialized") + .compress_to_buffer(block, buffer) + .context("zstd compression failed")?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compression_round_trips() { + let input = b"turbo persistence compression ".repeat(1024); + for compression in [Compression::Lz4, Compression::Zstd3] { + let mut compressor = Compressor::new(compression).unwrap(); + let mut compressed = Vec::new(); + compressor + .compress_into_buffer(&input, &mut compressed) + .unwrap(); + let output = decompress_into_arc(compression, input.len() as u32, &compressed).unwrap(); + assert_eq!(&*output, input); + } + } } diff --git a/turbopack/crates/turbo-persistence/src/db.rs b/turbopack/crates/turbo-persistence/src/db.rs index 20e55ebbe944..572a2860b00c 100644 --- a/turbopack/crates/turbo-persistence/src/db.rs +++ b/turbopack/crates/turbo-persistence/src/db.rs @@ -29,7 +29,7 @@ use tracing::span::EnteredSpan; pub use crate::compaction::selector::CompactConfig; use crate::{ - DbConfig, FamilyKind, QueryKey, + Compression, DbConfig, FamilyKind, QueryKey, arc_bytes::ArcBytes, compaction::selector::{Compactable, get_merge_segments}, compression::{checksum_block, decompress_into_arc}, @@ -324,7 +324,7 @@ pub struct TurboPersistence { /// A cache for decompressed value blocks. Allocated lazily on first read via /// [`Self::value_block_cache`]; see [`Self::key_block_cache`]. value_block_cache: OnceLock, - /// Per-family configuration for file limits. + /// Per-family storage configuration. config: DbConfig, /// Statistics for the database. #[cfg(feature = "stats")] @@ -634,7 +634,7 @@ impl TurboPersistence let mut meta_files = self .parallel_scheduler .parallel_map_collect::<_, _, Result>>(&meta_files, |&seq| { - let meta_file = MetaFile::open(&self.path, seq)?; + let meta_file = MetaFile::open(&self.path, seq, Some(&self.config.family_configs))?; Ok(meta_file) })?; @@ -653,7 +653,7 @@ impl TurboPersistence /// Reads and decompresses a blob file. This is not backed by any cache. #[tracing::instrument(level = "info", name = "reading database blob", skip_all)] - fn read_blob(&self, seq: u32) -> Result { + fn read_blob(&self, seq: u32, compression: Compression) -> Result { let path = self.path.join(format!("{seq:08}.blob")); let file = File::open(&path)?; let mmap = unsafe { Mmap::map(file.file()) }.with_context(|| { @@ -686,7 +686,7 @@ impl TurboPersistence ); } - let buffer = decompress_into_arc(uncompressed_length, reader)?; + let buffer = decompress_into_arc(compression, uncompressed_length, reader)?; Ok(ArcBytes::from(buffer)) } @@ -926,7 +926,8 @@ impl TurboPersistence .parallel_map_collect_owned::<_, _, Result>>(sync_items, |item| match item { SyncItem::Meta(seq, file) => { file.sync_data()?; - let meta_file = MetaFile::open(&self.path, seq)?; + let meta_file = + MetaFile::open(&self.path, seq, Some(&self.config.family_configs))?; Ok(SyncResult::Meta(meta_file)) } SyncItem::Sst(file) => { @@ -1593,7 +1594,11 @@ impl TurboPersistence let meta_index = ssts_with_ranges[index].meta_index; let index_in_meta = ssts_with_ranges[index].index_in_meta; let entry = meta_files[meta_index].entry(index_in_meta); - StaticSortedFileIter::open(path, entry.sst_metadata()) + StaticSortedFileIter::open( + path, + entry.sst_metadata(), + self.config.family_configs[family as usize].compression, + ) }) .collect::>>()?; @@ -1610,6 +1615,7 @@ impl TurboPersistence /// used set). writer: Option<(u32, StreamingSstWriter)>, flags: MetaEntryFlags, + compression: Compression, new_sst_files: Vec<(u32, File, StaticSortedFileBuilderMeta<'static>)>, /// Hash of the last key added. Used to ensure we only split @@ -1617,10 +1623,11 @@ impl TurboPersistence last_hash: Option, } impl Collector { - fn new(flags: MetaEntryFlags) -> Self { + fn new(flags: MetaEntryFlags, compression: Compression) -> Self { Self { writer: None, flags, + compression, new_sst_files: Vec::new(), last_hash: None, } @@ -1641,6 +1648,7 @@ impl TurboPersistence &sst_path, self.flags, MAX_ENTRIES_PER_COMPACTED_FILE as u64, + self.compression, )?; self.writer = Some((seq, writer)); } @@ -1700,8 +1708,12 @@ impl TurboPersistence } } } - let mut used_collector = Collector::new(MetaEntryFlags::WARM); - let mut unused_collector = Collector::new(MetaEntryFlags::COLD); + let compression = + self.config.family_configs[family as usize].compression; + let mut used_collector = + Collector::new(MetaEntryFlags::WARM, compression); + let mut unused_collector = + Collector::new(MetaEntryFlags::COLD, compression); let mut current_key: Option = None; let mut keys_written = 0; @@ -1834,7 +1846,10 @@ impl TurboPersistence let mut blob_seq_numbers_to_delete = Vec::with_capacity(blob_delete_len); let meta_seq = sequence_number.fetch_add(1, Ordering::SeqCst) + 1; - let mut meta_file_builder = MetaFileBuilder::new(family); + let mut meta_file_builder = MetaFileBuilder::new( + family, + self.config.family_configs[family as usize].compression, + ); let mut keys_written = 0; self.parallel_scheduler.block_in_place(|| { @@ -2091,7 +2106,10 @@ impl TurboPersistence LookupValue::Blob { sequence_number } => { #[cfg(feature = "stats")] self.stats.hits_blob.fetch_add(1, Ordering::Relaxed); - let blob = self.read_blob(sequence_number)?; + let blob = self.read_blob( + sequence_number, + self.config.family_configs[family].compression, + )?; if deleted_values.iter().any(|d| **d == *blob) { continue; } @@ -2230,7 +2248,10 @@ impl TurboPersistence LookupValue::Blob { sequence_number } => { #[cfg(feature = "stats")] self.stats.hits_blob.fetch_add(1, Ordering::Relaxed); - let blob = self.read_blob(sequence_number)?; + let blob = self.read_blob( + sequence_number, + self.config.family_configs[family].compression, + )?; result_size += blob.len(); Some(blob) } diff --git a/turbopack/crates/turbo-persistence/src/lib.rs b/turbopack/crates/turbo-persistence/src/lib.rs index 3ab8517c0ca7..d7b08ee4043d 100644 --- a/turbopack/crates/turbo-persistence/src/lib.rs +++ b/turbopack/crates/turbo-persistence/src/lib.rs @@ -29,7 +29,7 @@ mod write_batch; mod tests; pub use arc_bytes::ArcBytes; -pub use compression::checksum_block; +pub use compression::{Compression, checksum_block}; pub use db::{ CommitStats, CompactConfig, CurrentDbVersion, MetaFileEntryInfo, MetaFileInfo, TurboPersistence, read_current_version, @@ -54,12 +54,13 @@ pub enum FamilyKind { pub struct FamilyConfig { pub name: &'static str, pub kind: FamilyKind, + pub compression: Compression, } -/// Database-wide configuration with per-family settings. +/// Database-wide configuration with per-family storage settings. /// -/// Each family (keyspace) can have different file size limits to optimize -/// for its specific access patterns and data characteristics. +/// Each family (keyspace) can select storage behavior suited to its access patterns and data +/// characteristics. #[derive(Clone, Debug)] pub struct DbConfig { pub family_configs: [FamilyConfig; FAMILIES], @@ -71,6 +72,7 @@ impl Default for DbConfig { family_configs: [FamilyConfig { name: "unknown", kind: FamilyKind::SingleValue, + compression: Compression::Lz4, }; FAMILIES], } } diff --git a/turbopack/crates/turbo-persistence/src/meta_file.rs b/turbopack/crates/turbo-persistence/src/meta_file.rs index 5a8ba6ff0cf2..5e39a8807286 100644 --- a/turbopack/crates/turbo-persistence/src/meta_file.rs +++ b/turbopack/crates/turbo-persistence/src/meta_file.rs @@ -5,7 +5,7 @@ use std::{ sync::OnceLock, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, bail, ensure}; use bitfield::bitfield; use byteorder::{BE, ReadBytesExt}; use fs_err::File; @@ -14,7 +14,7 @@ use smallvec::SmallVec; use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Ref, big_endian as be}; use crate::{ - QueryKey, + Compression, FamilyConfig, QueryKey, lookup_entry::LookupValue, mmap_helper::advise_mmap_for_persistence, static_sorted_file::{BlockCache, SstLookupResult, StaticSortedFile, StaticSortedFileMetaData}, @@ -117,6 +117,8 @@ pub struct MetaEntry { /// /// The `'static` lifetime is transmuted — the actual borrow is from `MetaFile::mmap`. amqf: qfilter::FilterRef<'static>, + /// Compression recorded in this entry's meta file. + compression: Compression, /// The static sorted file that is lazily loaded sst: OnceLock, } @@ -153,12 +155,14 @@ impl MetaEntry { fn sst(&self, meta: &MetaFile) -> Result<&StaticSortedFile> { self.sst.get_or_try_init(|| { - StaticSortedFile::open(&meta.db_path, self.sst_data).with_context(|| { - format!( - "Unable to open static sorted file referenced from {:08}.meta", - meta.sequence_number() - ) - }) + StaticSortedFile::open(&meta.db_path, self.sst_data, self.compression).with_context( + || { + format!( + "Unable to open static sorted file referenced from {:08}.meta", + meta.sequence_number() + ) + }, + ) }) } @@ -244,6 +248,8 @@ pub struct MetaFile { sequence_number: u32, /// The key family of the SST files in this meta file. family: u32, + /// Compression recorded for this family. + compression: Compression, /// The entries of the file. Dropped before `mmap` (field declaration order). entries: Vec, /// The entries that have been marked as obsolete. @@ -266,14 +272,28 @@ pub struct MetaFile { impl MetaFile { /// Opens a meta file at the given path. Memory maps the entire file and eagerly deserializes /// all AMQF filters as zero-copy [`qfilter::FilterRef`]s that borrow from the mmap. - pub fn open(db_path: &Path, sequence_number: u32) -> Result { + pub fn open( + db_path: &Path, + sequence_number: u32, + family_configs: Option<&[FamilyConfig]>, + ) -> Result { let filename = format!("{sequence_number:08}.meta"); let path = db_path.join(&filename); - Self::open_internal(db_path.to_path_buf(), sequence_number, &path) - .with_context(|| format!("Unable to open meta file {filename}")) + Self::open_internal( + db_path.to_path_buf(), + sequence_number, + &path, + family_configs, + ) + .with_context(|| format!("Unable to open meta file {filename}")) } - fn open_internal(db_path: PathBuf, sequence_number: u32, path: &Path) -> Result { + fn open_internal( + db_path: PathBuf, + sequence_number: u32, + path: &Path, + family_configs: Option<&[FamilyConfig]>, + ) -> Result { let file = File::open(path)?; let mmap = unsafe { MmapOptions::new().map(file.file()) }.context("Failed to mmap")?; #[cfg(unix)] @@ -287,6 +307,22 @@ impl MetaFile { bail!("Invalid magic number"); } let family = reader.read_u32::()?; + let compression = match reader.read_u8()? { + value if value == Compression::Lz4 as u8 => Compression::Lz4, + value if value == Compression::Zstd3 as u8 => Compression::Zstd3, + value => bail!("Invalid compression algorithm {value}"), + }; + if let Some(configs) = family_configs { + let configured = configs + .get(family as usize) + .with_context(|| format!("No configuration for family {family}"))? + .compression; + ensure!( + compression == configured, + "Compression configuration mismatch for family {family}: meta file uses \ + {compression:?}, runtime config uses {configured:?}" + ); + } let obsolete_count = reader.read_u32::()?; let mut obsolete_sst_files = Vec::with_capacity(obsolete_count as usize); for _ in 0..obsolete_count { @@ -344,6 +380,7 @@ impl MetaFile { flags, amqf_data_offset: start_of_amqf_data_offset..end_of_amqf_data_offset, amqf, + compression, sst: OnceLock::new(), }); start_of_amqf_data_offset = end_of_amqf_data_offset; @@ -356,6 +393,7 @@ impl MetaFile { db_path, sequence_number, family, + compression, entries, obsolete_entries: Vec::new(), obsolete_sst_files, @@ -386,6 +424,10 @@ impl MetaFile { self.family } + pub fn compression(&self) -> Compression { + self.compression + } + /// The on-disk size of this meta file in bytes (the length of its memory map). pub fn byte_size(&self) -> u64 { self.mmap.len() as u64 diff --git a/turbopack/crates/turbo-persistence/src/meta_file_builder.rs b/turbopack/crates/turbo-persistence/src/meta_file_builder.rs index af2baaae2b45..0b4dadd26292 100644 --- a/turbopack/crates/turbo-persistence/src/meta_file_builder.rs +++ b/turbopack/crates/turbo-persistence/src/meta_file_builder.rs @@ -10,12 +10,14 @@ use qfilter::Filter; use zerocopy::IntoBytes; use crate::{ + Compression, meta_file::{EntryHeader, META_FILE_MAGIC}, static_sorted_file_builder::StaticSortedFileBuilderMeta, }; pub struct MetaFileBuilder<'a> { family: u32, + compression: Compression, /// Entries in the meta file, tuples of (sequence_number, StaticSortedFileBuilderMetaResult) entries: Vec<(u32, StaticSortedFileBuilderMeta<'a>)>, /// Obsolete SST files, represented by their sequence numbers @@ -25,9 +27,10 @@ pub struct MetaFileBuilder<'a> { } impl<'a> MetaFileBuilder<'a> { - pub fn new(family: u32) -> Self { + pub fn new(family: u32, compression: Compression) -> Self { Self { family, + compression, entries: Vec::new(), obsolete_sst_files: Vec::new(), used_key_hashes_amqf: None, @@ -59,6 +62,7 @@ impl<'a> MetaFileBuilder<'a> { let mut file = CountingWriter::new(BufWriter::new(File::create(file)?)); file.write_u32::(META_FILE_MAGIC)?; // Magic number file.write_u32::(self.family)?; + file.write_u8(self.compression as u8)?; self.obsolete_sst_files.sort(); file.write_u32::(self.obsolete_sst_files.len() as u32)?; diff --git a/turbopack/crates/turbo-persistence/src/rc_bytes.rs b/turbopack/crates/turbo-persistence/src/rc_bytes.rs index 234ddb828f14..c4c1007cab32 100644 --- a/turbopack/crates/turbo-persistence/src/rc_bytes.rs +++ b/turbopack/crates/turbo-persistence/src/rc_bytes.rs @@ -9,6 +9,7 @@ use std::{ use memmap2::Mmap; use crate::{ + Compression, compression::decompress_into_rc, shared_bytes::{SharedBytes, is_subslice_of}, }; @@ -124,8 +125,13 @@ impl SharedBytes for RcBytes { } } - fn from_decompressed(uncompressed_length: u32, block: &[u8]) -> anyhow::Result { + fn from_decompressed( + compression: Compression, + uncompressed_length: u32, + block: &[u8], + ) -> anyhow::Result { Ok(RcBytes::from(decompress_into_rc( + compression, uncompressed_length, block, )?)) diff --git a/turbopack/crates/turbo-persistence/src/shared_bytes.rs b/turbopack/crates/turbo-persistence/src/shared_bytes.rs index fd9fc4633c2b..6dc0f8407d8f 100644 --- a/turbopack/crates/turbo-persistence/src/shared_bytes.rs +++ b/turbopack/crates/turbo-persistence/src/shared_bytes.rs @@ -2,6 +2,8 @@ use std::ops::{Deref, Range}; use memmap2::Mmap; +use crate::Compression; + /// Trait abstracting over `ArcBytes` and `RcBytes`. /// /// Both types are owned byte slices backed by either a ref-counted heap @@ -36,7 +38,11 @@ pub trait SharedBytes: Clone + Deref + Sized { unsafe fn from_mmap(mmap: &Self::MmapHandle, subslice: &[u8]) -> Self; /// Creates an instance from a decompressed block. - fn from_decompressed(uncompressed_length: u32, block: &[u8]) -> anyhow::Result; + fn from_decompressed( + compression: Compression, + uncompressed_length: u32, + block: &[u8], + ) -> anyhow::Result; } /// Returns `true` if `subslice` lies entirely within `backing`. diff --git a/turbopack/crates/turbo-persistence/src/static_sorted_file.rs b/turbopack/crates/turbo-persistence/src/static_sorted_file.rs index 54554601a4bd..c78410fa9a77 100644 --- a/turbopack/crates/turbo-persistence/src/static_sorted_file.rs +++ b/turbopack/crates/turbo-persistence/src/static_sorted_file.rs @@ -17,7 +17,7 @@ use rustc_hash::FxHasher; use smallvec::SmallVec; use crate::{ - QueryKey, + Compression, QueryKey, arc_bytes::ArcBytes, be, compression::checksum_block, @@ -199,6 +199,7 @@ trait ValueBlockCache { mmap: &B::MmapHandle, meta: &StaticSortedFileMetaData, block_index: u16, + compression: Compression, ) -> Result; } @@ -218,26 +219,39 @@ impl ValueBlockCache for ArcBlockCacheReader<'_> { mmap: &Arc, meta: &StaticSortedFileMetaData, block_index: u16, + compression: Compression, ) -> Result { - get_or_cache_block(mmap, meta, block_index, self.cache, self.verified_blocks) + get_or_cache_block( + mmap, + meta, + block_index, + self.cache, + self.verified_blocks, + compression, + ) } } /// Iteration-path: lightweight single-entry cache for sequential reads. -impl ValueBlockCache for &mut Option<(u16, RcBytes)> { +struct RcBlockCacheReader<'a> { + cache: &'a mut Option<(u16, RcBytes)>, +} + +impl ValueBlockCache for RcBlockCacheReader<'_> { fn get_or_read( self, mmap: &Rc, meta: &StaticSortedFileMetaData, block_index: u16, + compression: Compression, ) -> Result { - if let Some((idx, block)) = self.as_ref() + if let Some((idx, block)) = self.cache.as_ref() && *idx == block_index { return Ok(block.clone()); } - let block: RcBytes = read_block_generic(mmap, meta, block_index)?; - *self = Some((block_index, block.clone())); + let block: RcBytes = read_block_generic(mmap, meta, block_index, compression)?; + *self.cache = Some((block_index, block.clone())); Ok(block) } } @@ -270,12 +284,17 @@ pub struct StaticSortedFile { /// bitmap the CRC would be re-computed on every access. `Relaxed` ordering /// suffices: racing first-time verifications are idempotent. verified_blocks: Box<[AtomicU64]>, + compression: Compression, } impl StaticSortedFile { - /// Opens an SST file at the given path. This memory maps the file, but does not read it yet. - /// It's lazy read on demand. - pub fn open(db_path: &Path, meta: StaticSortedFileMetaData) -> Result { + /// Opens an SST file at the given path with the compression algorithm specified by its meta + /// file. This memory maps the file, but does not read it yet. + pub fn open( + db_path: &Path, + meta: StaticSortedFileMetaData, + compression: Compression, + ) -> Result { let filename = format!("{:08}.sst", meta.sequence_number); let path = db_path.join(&filename); let file = File::open(&path)?; @@ -301,6 +320,7 @@ impl StaticSortedFile { meta, mmap: Arc::new(mmap), verified_blocks, + compression, }) } @@ -325,6 +345,7 @@ impl StaticSortedFile { index_block_index, key_block_cache, &self.verified_blocks, + self.compression, )?; let key_block_index = self.lookup_index_block(&index_block, key_hash)?; @@ -334,6 +355,7 @@ impl StaticSortedFile { key_block_index, key_block_cache, &self.verified_blocks, + self.compression, )?; let reader = ArcBlockCacheReader { cache: value_block_cache, @@ -543,7 +565,15 @@ impl StaticSortedFile { key_block_arc: &ArcBytes, reader: ArcBlockCacheReader<'_>, ) -> Result { - handle_key_match_generic(&self.mmap, &self.meta, ty, val, key_block_arc, reader) + handle_key_match_generic( + &self.mmap, + &self.meta, + ty, + val, + key_block_arc, + self.compression, + reader, + ) } } @@ -561,6 +591,7 @@ fn get_or_cache_block( block_index: u16, cache: &BlockCache, verified_blocks: &[AtomicU64], + compression: Compression, ) -> Result { let (uncompressed_length, checksum, block_data) = get_raw_block_slice(mmap, meta, block_index) .with_context(|| { @@ -585,13 +616,15 @@ fn get_or_cache_block( // A cached block may have been evicted, so re-reading still // benefits from the bitmap to skip redundant CRC verification. verify_checksum_once(meta, block_data, checksum, block_index, verified_blocks)?; - let block = ArcBytes::from_decompressed(uncompressed_length, block_data) - .with_context(|| { - format!( - "Failed to decompress block {} from {:08}.sst ({} bytes uncompressed)", - block_index, meta.sequence_number, uncompressed_length - ) - })?; + let block = + ArcBytes::from_decompressed(compression, uncompressed_length, block_data) + .with_context(|| { + format!( + "Failed to decompress block {} from {:08}.sst ({} bytes \ + uncompressed)", + block_index, meta.sequence_number, uncompressed_length + ) + })?; let _ = guard.insert(block.clone()); block } @@ -725,6 +758,7 @@ fn read_block_generic( mmap: &B::MmapHandle, meta: &StaticSortedFileMetaData, block_index: u16, + compression: Compression, ) -> Result { let (uncompressed_length, expected_checksum, block) = get_raw_block_slice(mmap, meta, block_index).with_context(|| { @@ -741,12 +775,13 @@ fn read_block_generic( return Ok(unsafe { B::from_mmap(mmap, block) }); } - let buffer = B::from_decompressed(uncompressed_length, block).with_context(|| { - format!( - "Failed to decompress block {} from {:08}.sst ({} bytes uncompressed)", - block_index, meta.sequence_number, uncompressed_length - ) - })?; + let buffer = + B::from_decompressed(compression, uncompressed_length, block).with_context(|| { + format!( + "Failed to decompress block {} from {:08}.sst ({} bytes uncompressed)", + block_index, meta.sequence_number, uncompressed_length + ) + })?; Ok(buffer) } @@ -757,6 +792,7 @@ fn handle_key_match_generic( ty: u8, val: &[u8], key_block: &B, + compression: Compression, reader: impl ValueBlockCache, ) -> Result> { Ok(match ty { @@ -765,13 +801,13 @@ fn handle_key_match_generic( let size = be::read_u16(&val[2..]) as usize; let position = be::read_u32(&val[4..]) as usize; let value = reader - .get_or_read(mmap, meta, block)? + .get_or_read(mmap, meta, block, compression)? .slice(position..position + size); LookupValue::Slice { value } } KEY_BLOCK_ENTRY_TYPE_MEDIUM => { let block = be::read_u16(val); - let value = read_block_generic(mmap, meta, block)?; + let value = read_block_generic(mmap, meta, block, compression)?; LookupValue::Slice { value } } KEY_BLOCK_ENTRY_TYPE_BLOB => { @@ -815,6 +851,7 @@ pub struct StaticSortedFileIter { /// value blocks sequentially and don't revisit earlier blocks, so caching /// just the current one avoids redundant decompression. value_block_cache: Option<(u16, RcBytes)>, + compression: Compression, } enum CurrentKeyBlockKind { @@ -882,10 +919,14 @@ impl Iterator for StaticSortedFileIter { } impl StaticSortedFileIter { - /// Opens an SST file for sequential iteration. Uses `MADV_SEQUENTIAL` for - /// read-ahead and wraps the mmap in `Rc` directly (no `Arc`), - /// eliminating all atomic refcounting during iteration. - pub fn open(db_path: &Path, meta: StaticSortedFileMetaData) -> Result { + /// Opens an SST file for sequential iteration with the compression algorithm specified by its + /// meta file. Uses `MADV_SEQUENTIAL` for read-ahead and wraps the mmap in `Rc` directly + /// (no `Arc`), eliminating all atomic refcounting during iteration. + pub fn open( + db_path: &Path, + meta: StaticSortedFileMetaData, + compression: Compression, + ) -> Result { let filename = format!("{:08}.sst", meta.sequence_number); let path = db_path.join(&filename); let file = File::open(&path)?; @@ -899,13 +940,17 @@ impl StaticSortedFileIter { #[cfg(unix)] mmap.advise(memmap2::Advice::Sequential)?; advise_mmap_for_persistence(&mmap)?; - Self::new(Rc::new(mmap), meta) + Self::new(Rc::new(mmap), meta, compression) .with_context(|| format!("Unable to open static sorted file {filename}")) } - fn new(mmap: Rc, meta: StaticSortedFileMetaData) -> Result { + fn new( + mmap: Rc, + meta: StaticSortedFileMetaData, + compression: Compression, + ) -> Result { let root_block_index = meta.block_count - 1; - let block: RcBytes = read_block_generic(&mmap, &meta, root_block_index)?; + let block: RcBytes = read_block_generic(&mmap, &meta, root_block_index, compression)?; let block_type = block[0]; // The builder always writes an index block as the root block. @@ -924,7 +969,7 @@ impl StaticSortedFileIter { - size_of::()) / INDEX_BLOCK_ENTRY_SIZE; - let current_key_block = Self::parse_key_block(&mmap, &meta, first_child)?; + let current_key_block = Self::parse_key_block(&mmap, &meta, first_child, compression)?; Ok(StaticSortedFileIter { mmap, meta, @@ -933,6 +978,7 @@ impl StaticSortedFileIter { index_pos: 1, current_key_block, value_block_cache: None, + compression, }) } @@ -941,8 +987,9 @@ impl StaticSortedFileIter { mmap: &Rc, meta: &StaticSortedFileMetaData, block_index: u16, + compression: Compression, ) -> Result { - let block: RcBytes = read_block_generic(mmap, meta, block_index)?; + let block: RcBytes = read_block_generic(mmap, meta, block_index, compression)?; let data = &*block; ensure!(data.len() >= 4, "key block too short"); let block_type = data[0]; @@ -1035,7 +1082,10 @@ impl StaticSortedFileIter { ty, val, &kb.entries, - &mut self.value_block_cache, + self.compression, + RcBlockCacheReader { + cache: &mut self.value_block_cache, + }, )? .into() }; @@ -1052,7 +1102,7 @@ impl StaticSortedFileIter { let block_index = be::read_u16(&self.index_entries[base..]); self.index_pos += 1; self.current_key_block = - Self::parse_key_block(&self.mmap, &self.meta, block_index)?; + Self::parse_key_block(&self.mmap, &self.meta, block_index, self.compression)?; } else { return Ok(None); } diff --git a/turbopack/crates/turbo-persistence/src/static_sorted_file_builder.rs b/turbopack/crates/turbo-persistence/src/static_sorted_file_builder.rs index 7c6ae68c0802..83971258aaec 100644 --- a/turbopack/crates/turbo-persistence/src/static_sorted_file_builder.rs +++ b/turbopack/crates/turbo-persistence/src/static_sorted_file_builder.rs @@ -10,7 +10,8 @@ use byteorder::{BE, ByteOrder, WriteBytesExt}; use fs_err::File; use crate::{ - compression::{checksum_block, compress_into_buffer}, + Compression, + compression::{Compressor, checksum_block}, constants::{MAX_INLINE_VALUE_SIZE, MAX_SMALL_VALUE_SIZE, MIN_SMALL_VALUE_BLOCK_SIZE}, meta_file::MetaEntryFlags, static_sorted_file::{ @@ -339,9 +340,10 @@ pub fn write_static_stored_file( entries: &[E], file: &Path, flags: MetaEntryFlags, + compression: Compression, ) -> Result<(StaticSortedFileBuilderMeta<'static>, File)> { debug_assert!(entries.iter().map(|e| e.key_hash()).is_sorted()); - let mut writer = StreamingSstWriter::new(file, flags, entries.len() as u64)?; + let mut writer = StreamingSstWriter::new(file, flags, entries.len() as u64, compression)?; for entry in entries { writer.add(entry)?; } @@ -393,9 +395,10 @@ fn write_block_to_file( block_offsets: &mut Vec, block: &[u8], try_compress: bool, + compressor: &mut Compressor, ) -> Result { let (uncompressed_size, data_to_write): (u32, &[u8]) = if try_compress { - compress_into_buffer(block, compress_buffer)?; + compressor.compress_into_buffer(block, compress_buffer)?; // Same threshold as LevelDB/RocksDB: require at least 12.5% savings. if compress_buffer.len() < block.len() - (block.len() / 8) { (block.len().try_into().unwrap(), compress_buffer.as_slice()) @@ -535,6 +538,7 @@ pub struct StreamingSstWriter { file: Option>, compress_buffer: Vec, block_offsets: Vec, + compressor: Compressor, /// Pending key entries waiting to be flushed as key blocks. /// @@ -608,8 +612,14 @@ impl StreamingSstWriter { /// Creates a new streaming SST writer. /// /// `max_entry_count` is used to pre-allocate buffers and estimate block counts. - pub fn new(file: &Path, flags: MetaEntryFlags, max_entry_count: u64) -> Result { + pub fn new( + file: &Path, + flags: MetaEntryFlags, + max_entry_count: u64, + compression: Compression, + ) -> Result { let file = BufWriter::new(File::create(file)?); + let compressor = Compressor::new(compression)?; // Estimate number of key blocks based on max entry count. // Each key block holds up to MAX_KEY_BLOCK_ENTRIES entries. @@ -628,6 +638,7 @@ impl StreamingSstWriter { file: Some(file), compress_buffer: Vec::with_capacity(MIN_SMALL_VALUE_BLOCK_SIZE + MAX_SMALL_VALUE_SIZE), block_offsets: Vec::with_capacity(estimated_total_blocks), + compressor, pending_keys: VecDeque::with_capacity(entries_per_value_block), first_pending_small_index: 0, #[cfg(debug_assertions)] @@ -711,6 +722,7 @@ impl StreamingSstWriter { &mut self.block_offsets, value, true, + &mut self.compressor, ) .context("Failed to write value block")?; ValueRef::Medium { block_index } @@ -868,6 +880,7 @@ impl StreamingSstWriter { &mut self.block_offsets, &self.pending_small_value_block, true, + &mut self.compressor, ) .context("Failed to write small value block")?; @@ -989,6 +1002,7 @@ impl StreamingSstWriter { &mut self.block_offsets, &self.key_buffer, try_compress, + &mut self.compressor, ) .context("Failed to write key block")?; self.key_block_boundaries.push((first_hash, block_index)); @@ -1493,6 +1507,7 @@ mod tests { sequence_number: seq, block_count: meta.block_count, }, + Compression::Lz4, ) } @@ -1504,7 +1519,8 @@ mod tests { flags: MetaEntryFlags, ) -> Result> { let sst_path = dir.join(format!("{seq:08}.sst")); - let mut writer = StreamingSstWriter::new(&sst_path, flags, entries.len() as u64)?; + let mut writer = + StreamingSstWriter::new(&sst_path, flags, entries.len() as u64, Compression::Lz4)?; for entry in entries { writer.add(entry)?; } @@ -1740,7 +1756,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let sst_path = dir.path().join("test.sst"); let mut writer = - StreamingSstWriter::new(&sst_path, MetaEntryFlags::default(), 100).unwrap(); + StreamingSstWriter::new(&sst_path, MetaEntryFlags::default(), 100, Compression::Lz4) + .unwrap(); let max_entries = 50; for i in 0..max_entries { @@ -1766,7 +1783,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let sst_path = dir.path().join("test.sst"); let mut writer = - StreamingSstWriter::new(&sst_path, MetaEntryFlags::default(), 100).unwrap(); + StreamingSstWriter::new(&sst_path, MetaEntryFlags::default(), 100, Compression::Lz4) + .unwrap(); let value = vec![0u8; 1000]; for i in 0..10 { @@ -1802,8 +1820,12 @@ mod tests { // Write via convenience function let batch_path = dir.path().join("00000001.sst"); - let (meta1, _) = - write_static_stored_file(&entries, &batch_path, MetaEntryFlags::default())?; + let (meta1, _) = write_static_stored_file( + &entries, + &batch_path, + MetaEntryFlags::default(), + Compression::Lz4, + )?; // Write via streaming API let streaming_path = dir.path().join("00000002.sst"); @@ -1811,6 +1833,7 @@ mod tests { &streaming_path, MetaEntryFlags::default(), entries.len() as u64, + Compression::Lz4, )?; for entry in &entries { writer.add(entry)?; @@ -1830,6 +1853,7 @@ mod tests { sequence_number: 1, block_count: meta1.block_count, }, + Compression::Lz4, )?; let sst2 = StaticSortedFile::open( dir.path(), @@ -1837,6 +1861,7 @@ mod tests { sequence_number: 2, block_count: meta2.block_count, }, + Compression::Lz4, )?; let kc = make_cache(); let vc = make_cache(); @@ -1891,8 +1916,13 @@ mod tests { fn close_empty_writer_panics() { let dir = tempfile::tempdir().unwrap(); let sst_path = dir.path().join("empty.sst"); - let writer = - StreamingSstWriter::::new(&sst_path, MetaEntryFlags::default(), 0).unwrap(); + let writer = StreamingSstWriter::::new( + &sst_path, + MetaEntryFlags::default(), + 0, + Compression::Lz4, + ) + .unwrap(); writer.close().unwrap(); } diff --git a/turbopack/crates/turbo-persistence/src/tests.rs b/turbopack/crates/turbo-persistence/src/tests.rs index ae5f9c80db9a..4397c940ed4c 100644 --- a/turbopack/crates/turbo-persistence/src/tests.rs +++ b/turbopack/crates/turbo-persistence/src/tests.rs @@ -4,7 +4,7 @@ use anyhow::Result; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use crate::{ - DbConfig, FamilyConfig, FamilyKind, + Compression, DbConfig, FamilyConfig, FamilyKind, constants::{MAX_INLINE_VALUE_SIZE, MAX_MEDIUM_VALUE_SIZE, MAX_SMALL_VALUE_SIZE}, db::{CompactConfig, TurboPersistence, read_current_version}, lookup_entry::IterValue, @@ -1054,8 +1054,11 @@ fn batch_get_different_sizes() -> Result<()> { let tempdir = tempfile::tempdir()?; let path = tempdir.path(); - let db = TurboPersistence::<_, 16>::open_with_parallel_scheduler( + let mut config = DbConfig::default(); + config.family_configs[0].compression = Compression::Zstd3; + let db = TurboPersistence::<_, 16>::open_with_config_and_parallel_scheduler( path.to_path_buf(), + config, RayonParallelScheduler, )?; @@ -1108,16 +1111,22 @@ fn batch_get_across_families() -> Result<()> { let tempdir = tempfile::tempdir()?; let path = tempdir.path(); - let db = TurboPersistence::<_, 16>::open_with_parallel_scheduler( + let mut config = DbConfig::default(); + // set zstd on an arbitrary family, lz4 is used by default + config.family_configs[2].compression = Compression::Zstd3; + let db = TurboPersistence::<_, 16>::open_with_config_and_parallel_scheduler( path.to_path_buf(), + config.clone(), RayonParallelScheduler, )?; - // Write to multiple families + // Write compressible values to multiple families so every configured codec is exercised. let batch = db.write_batch()?; for family in 0..4u32 { for i in 0..20u8 { - batch.put(family, vec![i], vec![family as u8, i].into())?; + let mut value = vec![family as u8; 1024]; + value[0] = i; + batch.put(family, vec![i], value.into())?; } } db.commit_write_batch(batch)?; @@ -1131,7 +1140,13 @@ fn batch_get_across_families() -> Result<()> { for (i, result) in results.iter().enumerate() { assert_eq!( result.as_deref(), - Some(&vec![family as u8, i as u8][..]), + Some( + &{ + let mut value = vec![family as u8; 1024]; + value[0] = i as u8; + value + }[..] + ), "Failed at family {family}, index {i}" ); } @@ -1146,6 +1161,29 @@ fn batch_get_across_families() -> Result<()> { assert_ne!(results_f0[0].as_deref(), results_f1[0].as_deref()); db.shutdown()?; + drop(db); + + // Reopen with the same family configuration recorded in the meta files. + let db = TurboPersistence::<_, 16>::open_with_config_and_parallel_scheduler( + path.to_path_buf(), + config, + RayonParallelScheduler, + )?; + let value = db.get(2, &vec![7u8])?.expect("zstd family value exists"); + assert_eq!(value[0], 7); + assert!(value[1..].iter().all(|byte| *byte == 2)); + db.shutdown()?; + drop(db); + + // Reopening with the wrong codec must fail while validating the meta files. + assert!( + TurboPersistence::::open_with_config_and_parallel_scheduler( + path.to_path_buf(), + DbConfig::default(), + RayonParallelScheduler, + ) + .is_err() + ); Ok(()) } @@ -1154,8 +1192,11 @@ fn batch_get_after_compaction() -> Result<()> { let tempdir = tempfile::tempdir()?; let path = tempdir.path(); - let db = TurboPersistence::<_, 16>::open_with_parallel_scheduler( + let mut config = DbConfig::default(); + config.family_configs[0].compression = Compression::Zstd3; + let db = TurboPersistence::<_, 16>::open_with_config_and_parallel_scheduler( path.to_path_buf(), + config, RayonParallelScheduler, )?; @@ -1173,7 +1214,7 @@ fn batch_get_after_compaction() -> Result<()> { let keys_to_fetch: Vec> = (0..100u8).map(|i| vec![i]).collect(); let results_before = db.batch_get(0, &keys_to_fetch)?; - // Compact database + // Compact database using zstd to cover recompression with a non-default codec. db.full_compact()?; // Fetch after compaction @@ -1524,6 +1565,7 @@ fn multi_value_config() -> DbConfig<1> { config.family_configs[0] = FamilyConfig { name: "test", kind: FamilyKind::MultiValue, + compression: Compression::Lz4, }; config } @@ -2103,6 +2145,7 @@ fn compaction_deletes_blob_multi_value_tombstone() -> Result<()> { family_configs: [FamilyConfig { name: "test", kind: FamilyKind::MultiValue, + compression: Compression::Lz4, }], }; @@ -2467,7 +2510,7 @@ fn count_tombstones( sequence_number: entry.sequence_number, block_count: entry.block_count, }; - for item in StaticSortedFileIter::open(path, sst)? { + for item in StaticSortedFileIter::open(path, sst, Compression::Lz4)? { if matches!( item?.value, IterValue::KeyDeleted | IterValue::KeyValueDeleted { .. } diff --git a/turbopack/crates/turbo-persistence/src/write_batch.rs b/turbopack/crates/turbo-persistence/src/write_batch.rs index 4b5141cbe7fb..2f4c632cfa29 100644 --- a/turbopack/crates/turbo-persistence/src/write_batch.rs +++ b/turbopack/crates/turbo-persistence/src/write_batch.rs @@ -18,7 +18,7 @@ use crate::{ FamilyConfig, FamilyKind, ValueBuffer, collector::Collector, collector_entry::CollectorEntry, - compression::{checksum_block, compress_into_buffer}, + compression::{Compressor, checksum_block}, constants::{MAX_INLINE_VALUE_SIZE, MAX_MEDIUM_VALUE_SIZE, THREAD_LOCAL_SIZE_SHIFT}, db::WriteOperationGuard, key::StoreKey, @@ -79,7 +79,7 @@ pub struct WriteBatch<'db, K: StoreKey + Send, S: ParallelScheduler, const FAMIL parallel_scheduler: S, /// The database path db_path: PathBuf, - /// Per-family configuration (kind: SingleValue/MultiValue). + /// Per-family storage configuration. #[cfg_attr(not(feature = "verify_sst_content"), allow(dead_code))] family_configs: [FamilyConfig; FAMILIES], /// The current sequence number counter. Increased for every new SST file or blob file. @@ -238,7 +238,7 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize if value.len() <= MAX_MEDIUM_VALUE_SIZE { collector.put(key, value); } else { - let blob = self.create_blob(&value)?; + let blob = self.create_blob(family, &value)?; collector.put_blob(key, blob.seq); state.new_blob_files.push(blob); } @@ -318,14 +318,13 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize })?; // Now we flush the global collector(s). - let mut collector_state = self.collectors[usize_from_u32(family)].lock(); + let family_usize = usize_from_u32(family); + let mut collector_state = self.collectors[family_usize].lock(); + let family_config = self.family_configs[family_usize]; match &mut *collector_state { GlobalCollectorState::Unsharded(collector) => { if !collector.is_empty() { - let sst = self.create_sst_file( - family, - collector.sorted(self.family_configs[usize_from_u32(family)].kind), - )?; + let sst = self.create_sst_file(family, collector.sorted(family_config.kind))?; collector.clear(); self.new_sst_files.lock().push(sst); } @@ -340,10 +339,8 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize self.parallel_scheduler .try_parallel_for_each_mut(&mut shards, |collector| { if !collector.is_empty() { - let sst = self.create_sst_file( - family, - collector.sorted(self.family_configs[usize_from_u32(family)].kind), - )?; + let sst = + self.create_sst_file(family, collector.sorted(family_config.kind))?; collector.clear(); self.new_sst_files.lock().push(sst); collector.drop_contents(); @@ -457,7 +454,10 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize |(family, sst_files)| { let family = family as u32; let mut entries = 0; - let mut builder = MetaFileBuilder::new(family); + let mut builder = MetaFileBuilder::new( + family, + self.family_configs[usize_from_u32(family)].compression, + ); for (seq, sst) in sst_files { entries += sst.entries; builder.add(seq, sst); @@ -484,10 +484,12 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize /// Creates a new blob file with the given value. #[tracing::instrument(level = "trace", skip(self, value), fields(value_len = value.len()))] - fn create_blob(&self, value: &[u8]) -> Result { + fn create_blob(&self, family: u32, value: &[u8]) -> Result { let seq = self.current_sequence_number.fetch_add(1, Ordering::SeqCst) + 1; let mut compressed = Vec::new(); - compress_into_buffer(value, &mut compressed) + let compression = self.family_configs[usize_from_u32(family)].compression; + Compressor::new(compression)? + .compress_into_buffer(value, &mut compressed) .context("Compression of value for blob file failed")?; let mut buffer = Vec::with_capacity(8 + compressed.len()); @@ -516,7 +518,14 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize let path = self.db_path.join(format!("{seq:08}.sst")); let (meta, file) = self .parallel_scheduler - .block_in_place(|| write_static_stored_file(entries, &path, MetaEntryFlags::FRESH)) + .block_in_place(|| { + write_static_stored_file( + entries, + &path, + MetaEntryFlags::FRESH, + self.family_configs[usize_from_u32(family)].compression, + ) + }) .with_context(|| format!("Unable to write SST file {seq:08}.sst"))?; #[cfg(feature = "verify_sst_content")] @@ -540,6 +549,7 @@ impl<'db, K: StoreKey + Send + Sync, S: ParallelScheduler, const FAMILIES: usize sequence_number: seq, block_count: meta.block_count, }, + self.family_configs[usize_from_u32(family)].compression, )?; let cache2 = BlockCache::with( 10, diff --git a/turbopack/crates/turbo-tasks-backend/src/database/key_value_database.rs b/turbopack/crates/turbo-tasks-backend/src/database/key_value_database.rs index d1ed2a8b7c68..926374691b61 100644 --- a/turbopack/crates/turbo-tasks-backend/src/database/key_value_database.rs +++ b/turbopack/crates/turbo-tasks-backend/src/database/key_value_database.rs @@ -1,4 +1,4 @@ -use turbo_persistence::{FamilyConfig, FamilyKind}; +use turbo_persistence::{Compression, FamilyConfig, FamilyKind}; #[derive(Debug, Clone, Copy)] pub enum KeySpace { @@ -35,14 +35,21 @@ impl KeySpace { /// Returns the persistence configuration for this keyspace. pub const fn family_config(&self) -> FamilyConfig { match self { - KeySpace::Infra | KeySpace::TaskMeta | KeySpace::TaskData => FamilyConfig { + KeySpace::Infra | KeySpace::TaskMeta => FamilyConfig { name: self.name(), kind: FamilyKind::SingleValue, + compression: Compression::Lz4, + }, + KeySpace::TaskData => FamilyConfig { + name: self.name(), + kind: FamilyKind::SingleValue, + compression: Compression::Zstd3, }, KeySpace::TaskCache => FamilyConfig { name: self.name(), // TaskCache uses hash-based lookups with potential collisions. kind: FamilyKind::MultiValue, + compression: Compression::Lz4, }, } } From 69ec884e44d1050013724facfe452248e5b9de55 Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:21:09 +0000 Subject: [PATCH 2/2] v16.4.0-canary.15 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/devlow-bench/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 22 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lerna.json b/lerna.json index 94685b1489c9..e68c386f5c53 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.4.0-canary.14" + "version": "16.4.0-canary.15" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 2a13d4c0ceea..634c4de0c66f 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 2238cfc83dad..4d53bc8e10a8 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -1,7 +1,7 @@ { "name": "@vercel/devlow-bench", "private": true, - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 0886e7c514eb..98e265c7b1ba 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.4.0-canary.14", + "@next/eslint-plugin-next": "16.4.0-canary.15", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 2ce80eb07c6c..fbb5cf759380 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index dddeea02e316..7c76190fad19 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 54bffa791b5d..2d5da5c6171d 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index efb00a82898f..512ae7fda01a 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index b48199582175..9a8ace35cc9e 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 0e8770141b0a..f7dd349643d8 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index b9b89a12817b..c6a970f6fed6 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index f13b70bf3a85..04a627cf0998 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 6a51b3cefe28..c5558b82f596 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 7a1391f1d90f..7699b403c5cf 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 1929aab70c72..6f3bc6d86296 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 121343cf7edc..6e8bb886d5aa 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 996606cdf6a0..3570bdb809c8 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 0abbd2a3aa5f..9c30528bf0d9 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 575bf03d3bbb..2444232a98cd 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.4.0-canary.14", + "@next/env": "16.4.0-canary.15", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -164,11 +164,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.42.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.4.0-canary.14", - "@next/polyfill-module": "16.4.0-canary.14", - "@next/polyfill-nomodule": "16.4.0-canary.14", - "@next/react-refresh-utils": "16.4.0-canary.14", - "@next/swc": "16.4.0-canary.14", + "@next/font": "16.4.0-canary.15", + "@next/polyfill-module": "16.4.0-canary.15", + "@next/polyfill-nomodule": "16.4.0-canary.15", + "@next/react-refresh-utils": "16.4.0-canary.15", + "@next/swc": "16.4.0-canary.15", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.61.0", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 614f93001ff9..ab55f20025c3 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 18a291a61c9e..48bec5a7481f 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.4.0-canary.14", + "version": "16.4.0-canary.15", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.4.0-canary.14", + "next": "16.4.0-canary.15", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d4ec4efa961..3f61268b59ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1027,7 +1027,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1110,7 +1110,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../next-env '@swc/helpers': specifier: 0.5.23 @@ -1231,19 +1231,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../font '@next/polyfill-module': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../react-refresh-utils '@next/swc': - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1986,7 +1986,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.4.0-canary.14 + specifier: 16.4.0-canary.15 version: link:../next outdent: specifier: 0.8.0