Skip to content

feat(sandbox): add optional ZFile compression for memory snapshots - #92

Open
huajq wants to merge 3 commits into
kvcache-ai:mainfrom
huajq:zfile
Open

feat(sandbox): add optional ZFile compression for memory snapshots#92
huajq wants to merge 3 commits into
kvcache-ai:mainfrom
huajq:zfile

Conversation

@huajq

@huajq huajq commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

Add optional ZFile compression support for Firecracker memory snapshots. When enabled, dirty memory pages are compressed during snapshot creation using LZ4 (default) or Zstd, reducing snapshot artifact size by ~70-88% at the cost of ~40-90% longer pause latency.

Why

Memory snapshots for large VMs produce multi-GB artifacts. Compressing them during creation reduces storage and transfer costs, which is especially valuable for snapshot templates shared across nodes.

Related issue

Closes #

Scope and non-goals

Included:

  • [memory_snapshot] config: compression_enabled (default false), compression_algorithm (lz4/zstd), compression_workers (default 1, max 64)
  • Direct memory snapshot path: Firecracker dirty ranges → ProcessVmReader (process_vm_readv) → compact_to → raw or ZFileCompactWriter → temp → rename
  • ZFile compression core: persistent WorkerPool (std::thread + crossbeam-channel), CompressedSegment (contiguous output, no per-block Vec), 512 KiB compression batch, ordered committer, cancellation-safe poisoning
  • LocalFile::write_at_sync_pwrite for >4KiB batches (bypasses io_uring overhead on local page-cache writes)
  • compact_to ordered writer support, zeroed-index sort fix (Critical), MAX_LENGTH clamp
  • LZ4 switched from lz4_flex to lz4-sys (C liblz4, ~25% faster)
  • Integration tests for direct and legacy LZ4 compressed pause/resume

Excluded:

  • No changes to rootfs compression (rootfs layers remain raw)
  • No changes to snapshot manifest or artifact layout (ZFile is transparent to the repository)
  • No changes to resume path (switch file transparently decompresses ZFile)
  • No CI pipeline changes (integration tests run manually with compression configs)

Design and behavior changes

Data flow

Firecracker pause
  → create_state_only_snapshot (vm_state.bin)
  → GET /vm/dirty-memory-ranges
  → dirty_ranges_to_segment_mappings
  → ProcessVmReader (process_vm_readv, 512 KiB chunks)
  → compact_to
  → Raw VirtualFileWriter (unordered, io_uring)
    or ZFileCompactWriter (ordered, WorkerPool compression)
  → overlaybd.commit.tmp
  → rename overlaybd.commit

WorkerPool architecture

compact_to (async, Tokio worker)
  │
  │  512 KiB chunk via ProcessVmReader
  │
  ▼
crossbeam::channel::bounded<WorkItem>(workers * 2)
  │
  ├──► Worker Thread 1 (std::thread, owns compressor)
  ├──► Worker Thread 2
  ├──► Worker Thread 3
  └──► Worker Thread 4
         │
         ▼
  crossbeam::channel::bounded<CompressedBatch>(workers * 2)
         │
         ▼
  Ordered Committer (BTreeMap by seq)
    → commit_compressed_batch
    → LocalFile::write_at_sync_pwrite (>4KiB)
      or write_all_at (non-local fallback)

Key design decisions

  • workers=1 remains synchronous: no thread overhead for single-worker configs
  • 512 KiB compression batch: amortizes worker dispatch (32 KiB → 512 KiB)
  • sync pwrite for large batches: bypasses io_uring submit/completion round trips on local page-cache writes (~2-3 µs vs ~15 µs per call)
  • Cancellation-safe poisoning: any builder write/finish await pre-marks failed=true, cleared only on success; future cancellation or errors leave the writer permanently poisoned
  • catch_unwind in workers: prevents worker panics from deadlocking the ordered committer drain loop

Performance (1 GiB structured text payload, 1536 MiB VM)

Mode Pause (mean) Artifact size Ratio
Raw 801 ms 1,242 MB 1.00
LZ4 workers=4 1,127 ms 367 MB 0.30
Zstd workers=4 1,533 ms 149 MB 0.12

LZ4 compression adds ~40% pause latency for ~70% size reduction. Zstd adds ~90% for ~88% reduction.

Zeroed-index sort fix (Critical bug fix included)

compact_to previously pushed zeroed SegmentMapping entries into compact_index during phase 1, then appended data entries in phase 2. This produced an unsorted index ([all zeros..., all data...]), causing partition_point lookups in ReadOnlyIndex to silently miss mappings and read committed data as zeros. Fixed by sorting compact_index before compress_raw_index.

Compatibility and operations

  • Public API or generated protocol: N/A (no API changes)
  • Configuration or defaults: New [memory_snapshot] keys with safe defaults (disabled, lz4, 1 worker)
  • Snapshot manifest, artifact layout, or storage format: N/A (ZFile is transparent to the repository; resume path unchanged)
  • Upgrade and rollback: Old snapshots without compression continue to work. New compressed snapshots require the same code version to resume (ZFile read support is in the same crate)
  • Host requirements, permissions, ports, or dependencies: Adds crossbeam-channel dependency. No new system requirements.

Validation

  • make fmt (cargo fmt --all -- --check)
  • make clippy (cargo clippy -p overlaybd -p agentenv --all-targets -- -D warnings, zero warnings)
  • make test-unit (overlaybd lib 304/0/1 ignored, agentenv 6 core tests 6/6)
  • Relevant Rust integration tests (fc::memory_snapshot_format_matches_config_and_resumes, fc::legacy_memory_snapshot_format_matches_config_and_resumes, 2/2 with LZ4 workers=4)
  • make -C services test (N/A, no services/ changes)
  • Generated clients/server regenerated with the documented make target (N/A, no generated code changes)
  • Documentation updated (docs/src/configuration/reference.md)
  • Benchmarks or performance comparison completed (see Performance section)

Commands and results:

cargo test -p overlaybd --lib
  → 304 passed; 0 failed; 1 ignored

cargo test -p agentenv --lib -- memory_snapshot_compression_config_is_valid   compact_layers_mixed_input_and_configured_output   memory_runtime_suffix_compaction_uses_configured_compression   rootfs_runtime_suffix_compaction_remains_raw   convert_sparse_mem_supports_all_outputs_and_failure_cleanup   import_built_artifacts_preserves_zfile_memory_lower_bytes
  → 6 passed; 0 failed

cargo test -p agentenv --test integration -- --exact --test-threads=1   fc::memory_snapshot_format_matches_config_and_resumes   fc::legacy_memory_snapshot_format_matches_config_and_resumes
  → 2 passed; 0 failed (LZ4 workers=4, AENV_CONFIG_PATH with compression enabled)

cargo clippy -p overlaybd -p agentenv --all-targets -- -D warnings
  → zero warnings

Skipped checks and reasons:

  • make -C services test: no services/ changes
  • Generated code regeneration: no generated code changes

Risks and reviewer notes

Correctness:

  • WorkerPool uses bounded crossbeam channels with seq-numbered results and BTreeMap reordering. Submit+drain has no .await, so cancellation cannot strand stale results. Worker panics are caught via catch_unwind and converted to per-seq errors.
  • Byte-identity: LZ4/Zstd bulk compression is deterministic per block; test_verify_builder asserts workers=4 vs workers=1 produce identical files.
  • Cancellation-safe poisoning prevents partial writes from being reused after errors or aborts.

Performance:

  • pool.recv() blocks the calling Tokio task for up to one 512 KiB batch compression time (~ms). This is bounded and documented with a TODO to wrap in spawn_blocking if latency-sensitive.
  • process_vm_readv remains synchronous on the Tokio worker thread (existing TODO). Raw pause is unaffected; compression pause is dominated by codec CPU.

Compatibility:

  • LZ4 switched from lz4_flex to lz4-sys (C liblz4). Both produce standard LZ4 block format; old layers remain readable. Content-addressed caches may see different digests for the same input (LZ4 output is not canonical).
  • compression_workers is clamped to [1, 64] to prevent absurd configs from spawning excessive OS threads.

Key files for review:

  • storage/overlaybd/src/compression/zfile.rs (WorkerPool, CompressedSegment, commit_compressed_batch, tests)
  • src/sandbox/firecracker/overlaybd_snapshot.rs (direct memory snapshot)
  • src/sandbox/ublk/overlaybd.rs (compression config routing)
  • storage/overlaybd/src/lsmt/file/helper.rs (compact_to ordered writer, zeroed-index fix)
  • storage/overlaybd/src/backend/local.rs (write_at_sync_pwrite)

Checklist

  • The PR contains one coherent change and no unrelated formatting or refactoring.
  • New behavior is covered by tests, or I explained why testing is impractical.
  • Logs and examples contain no credentials, tokens, or private registry information.
  • I did not manually edit generated code without updating its source and regenerating it.

huajq added 2 commits July 31, 2026 15:10
Add [memory_snapshot] compression_enabled, compression_algorithm (lz4/zstd),
and compression_workers config (default disabled, lz4, 1 worker, max 64).

Implement direct memory snapshot path: Firecracker dirty ranges are converted
to SegmentMappings and read via ProcessVmReader (process_vm_readv) directly
into compact_to, skipping the intermediate sparse mem.bin file. The output
can be raw OverlayBD or ZFile-compressed via ZFileCompactWriter.

OverlaybdCompactOutput routes compression config through create_commit_args
and compact_layers for memory delta layers and runtime suffix compaction.
Rootfs layers remain raw. Snapshot repository preserves zfile memory lower
bytes during import.

Extend CompactWriter with requires_ordered_writes and finalize hooks so
compact_to can drive ordered writers (ZFile) correctly.
… compression

Replace per-batch spawn_blocking with a persistent WorkerPool using
std::thread and crossbeam-channel bounded queues. Each worker owns a
compressor and processes WorkItems by sequence number; an ordered
committer collects results via BTreeMap and writes them in seq order.

CompressedSegment eliminates per-block Vec allocations by compressing
directly into a contiguous output buffer. commit_compressed_batch uses
LocalFile::write_at_sync_pwrite for >4KiB batches (bypassing io_uring
submit/completion overhead on local page-cache writes) and falls back to
write_all_at for non-local destinations.

Increase the compression batch from 32KiB to 512KiB to amortize worker
dispatch. Cap physical writes at 4KiB for non-LocalFile fallback paths.
Add cancellation-safe poisoning: any builder write/finish await pre-marks
failed, cleared only on success; catch_unwind prevents worker panics from
deadlocking the drain loop.

Switch LZ4 from lz4_flex to lz4-sys (C liblz4) for ~25% higher throughput.
Add crossbeam-channel dependency.

compact_to improvements: ordered writer support (buffered reads + serial
writes), fix zeroed-index sort (Critical: zeroed mappings were pushed
before data entries, corrupting partition_point lookups), clamp chunk
entries to Segment::MAX_LENGTH, remove redundant semaphore.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 11 issue(s) in this PR.

  • ✅ Successfully posted inline: 10 comment(s)
  • ❌ Failed to post inline: 1 comment(s)

⚠️ 1 warning(s) occurred during review.


[test · medium]

📄 tests/integration/fc.rs (L196-L198)

⚠️ GitHub could not post this as an inline comment: Unprocessable Entity: "Line could not be resolved" - https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request

This round trip only verifies a file on the persistent guest disk. It does not verify that data resident solely in guest memory before pause_to_dir survives compression/decompression, so corrupted or omitted memory blocks can go unnoticed as long as enough VM/envd state remains valid to execute cat. Create a memory-only marker before pausing (for example, keep a process with a known anonymous-memory payload and query it after resume) and assert that payload here for each compression/path configuration.


⚠️ Warnings:

  • storage/overlaybd/src/compression/zfile.rs (subtask_error): context deadline exceeded

CompressOptions::DEFAULT_BLOCK_SIZE,
0,
));
compress_args.workers = workers.max(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
The documented 64-worker safety limit is only applied in from_memory_snapshot_config, but this crate-visible helper accepts a directly constructed OverlaybdCompactOutput::ZFile and only enforces the lower bound. Since ZFileCompactWriter creates one persistent OS thread per worker, bypassing the constructor can spawn an unbounded number of threads. Clamp here as well (or make the variant fields private and validate construction) so the resource invariant is enforced at the thread-creation boundary.

Suggestion:

Suggested change
compress_args.workers = workers.max(1);
compress_args.workers = workers.clamp(1, OverlaybdCompactOutput::MAX_COMPRESSION_WORKERS);

Comment thread src/sandbox/ublk/overlaybd.rs Outdated
Comment on lines +127 to +128
let lower_tmp = output_path.with_extension("commit.tmp");
let build_result: Result<()> = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
This deterministic temporary name is not unique or exclusively created. Two output paths with the same stem (for example, layer.commit and layer.raw) both map to layer.commit.tmp, and concurrent compactions for the same output also share it. LocalFile::new truncates an existing file rather than using create-new semantics, so one operation can truncate, rename, or remove the other operation's in-progress output, potentially publishing corrupt or wrong data. Use a uniquely named sibling temporary file (for example, NamedTempFile::new_in(output_path.parent())) and retain ownership until an atomic persist/rename.

Comment on lines +138 to +139
tokio::fs::rename(&lower_tmp, output_path)
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
The completed file is renamed without syncing it first, and the containing directory is not synced after the rename. CompactWriter::finalize explicitly does not provide stable-storage durability, so a crash after this function returns success can leave the snapshot missing or incomplete. Sync the temporary file before rename and sync the parent directory afterward if successful snapshot publication is expected to survive a host crash.

Comment on lines +150 to +153
if build_result.is_err() {
let _ = tokio::fs::remove_file(&lower_tmp).await;
}
build_result?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Cleanup only runs when the inner future returns Err; if compact_layers is cancelled or its task is aborted during the merge/rename, this code never executes and the partial temporary file remains. Use an RAII-owned temporary path whose Drop removes the file (and only disarm it after successful publication), so cleanup also occurs when the future is dropped.

Comment on lines 380 to +382
fn lz4_compress_bound(src_size: usize) -> usize {
src_size + (src_size / 255) + 16
let size = i32::try_from(src_size).expect("lz4 block size exceeds c_int");
let bound = unsafe { lz4_sys::LZ4_compressBound(size) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
CompressOptions::new accepts any nonzero u32 block size, so values above i32::MAX reach this production path and panic during compressor construction. The FFI methods below also cast slice lengths with as i32, which can silently truncate. Validate the configured LZ4 block size once with checked i32::try_from and return an error, and use checked conversions for every length passed across the FFI boundary.

return self.compress_and_commit(source, block_size).await;
}

self.write_full_blocks_owned(source.to_vec()).await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
The pooled borrowed path copies the entire batch here, then write_full_blocks_owned copies each segment again into its WorkItem. For the 512 KiB batch size this adds about 1 MiB of transient copying per call before compressed-output allocation. Consider partitioning one shared Arc<[u8]>/owned buffer with ranges for workers, or provide an ownership-taking public path so each source byte is transferred rather than copied twice.

Comment on lines +1890 to +1891
let (first_error, segments) = {
let pool = self.pool.as_ref().expect("checked is_some above");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · medium]
This async write path subsequently calls blocking crossbeam_channel::Receiver::recv() directly on the Tokio executor thread. Compressing a 512 KiB batch can therefore park a current-thread runtime and reduce capacity on a multi-thread runtime. Move submission/result collection to spawn_blocking (or use an async result channel) rather than leaving the known blocking behavior as a TODO in production code.

Comment on lines 1276 to 1280
let buf_size = writer.buffer_size();
ensure!(
buf_size.is_multiple_of(ALIGNMENT_USIZE),
buf_size >= ALIGNMENT_USIZE && buf_size.is_multiple_of(ALIGNMENT_USIZE),
"CompactWriter buffer size {buf_size} not aligned"
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This validation occurs after the header has already been sent through writer.write_all_at. For implementations using the trait's default write_all_at, a zero buffer size reaches data.chunks(0) and panics before this ensure! can return an error; the validation is also skipped entirely when zero detection is enabled. Validate writer.buffer_size() immediately after obtaining the writer and before the first write, so an invalid writer cannot panic on ordinary input.

Suggestion:

Suggested change
let buf_size = writer.buffer_size();
ensure!(
buf_size.is_multiple_of(ALIGNMENT_USIZE),
buf_size >= ALIGNMENT_USIZE && buf_size.is_multiple_of(ALIGNMENT_USIZE),
"CompactWriter buffer size {buf_size} not aligned"
);
let writer = commit_args.writer;
let concurrency = commit_args.concurrency.max(1);
let buf_size = writer.buffer_size();
ensure!(
buf_size >= ALIGNMENT_USIZE && buf_size.is_multiple_of(ALIGNMENT_USIZE),
"CompactWriter buffer size {buf_size} not aligned"
);
writer.write_all_at(&header_buf, 0).await?;

Comment thread tests/integration/fc.rs
Comment on lines +223 to +225
if direct {
assert!(snapshot_dir.path().join("mem_image.json").exists());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · low]
snapshot_memory_to_overlaybd writes mem_image.json after both the direct and legacy branches, so its presence is part of both layouts. Restricting this assertion to direct mode allows a legacy regression that omits the resume image config to escape the artifact-layout check (and fail only indirectly later). Assert it unconditionally.

Suggestion:

Suggested change
if direct {
assert!(snapshot_dir.path().join("mem_image.json").exists());
}
assert!(snapshot_dir.path().join("mem_image.json").exists());

Comment thread tests/integration/fc.rs
Comment on lines +253 to +256
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
This makes the test report success whenever the process configuration does not select the direct path. The checked-in default is direct_overlaybd = true with compression disabled, and there are no test-runner/config references that invoke these new tests under the promised LZ4/Zstd and legacy configurations. Consequently, normal CI exercises only the raw direct case while the other modes silently remain untested. Add an explicit matrix runner/configuration that launches all modes, or parameterize/setup each case so a missing mode fails rather than skips.

@huajq
huajq force-pushed the zfile branch 2 times, most recently from 5b3bcd7 to 794099c Compare July 31, 2026 09:50
Comment thread src/cfg.rs
Comment on lines +379 to +380
#[config(default = 1)]
pub compression_workers: usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
compression_workers accepts 0 and arbitrarily large values even though the documented domain starts at 1. The consumer currently clamps the value to 1..=64, so invalid operator input is silently changed rather than rejected, making the loaded configuration differ from runtime behavior. Add this field to Config::validate() (and cover 0 and the upper bound in tests), or model it as a validated/nonzero bounded type so startup reports an actionable configuration error.

Suggestion:

Suggested change
#[config(default = 1)]
pub compression_workers: usize,
#[config(default = 1)]
pub compression_workers: usize,

Comment on lines +640 to +641
let lower_tmp = output_path.with_extension("commit.tmp");
let build_result = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This fixed temporary path is not cancellation-safe. Cleanup only runs after the inner future returns, so dropping/aborting the snapshot future leaves a partial file; pause_to_dir also cannot run its error cleanup when its own future is cancelled. A retry against the caller-managed directory then reopens the same file with LocalFile::new, which neither truncates nor claims it exclusively, and concurrent/retried publication can overwrite or rename stale/mixed bytes. The sparse conversion below has the same pattern. Use a unique sibling tempfile held by an RAII guard (or create-new semantics), then persist/rename it only after finalization so cancellation automatically removes it.

CompressOptions::DEFAULT_BLOCK_SIZE,
0,
));
compress_args.workers = workers.max(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
The advertised 64-worker safety limit is enforced only in from_memory_snapshot_config, not at the resource-allocation boundary. Because this crate-visible helper accepts directly constructed ZFile modes, a caller can bypass that limit; ZFileCompactWriter then permits up to 256 persistent OS threads. Clamp here as well (or make construction validated/private) so every call path preserves the production worker cap.

Suggestion:

Suggested change
compress_args.workers = workers.max(1);
compress_args.workers = workers.clamp(1, OverlaybdCompactOutput::MAX_COMPRESSION_WORKERS);

Comment on lines +128 to +129
let lower_tmp = output_path.with_extension(format!("commit.{}.tmp", Uuid::now_v7()));
let build_result: Result<()> = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
The UUID temp file is only removed after this inner future returns Err. If compact_layers is cancelled or its task is aborted while creating/compressing/merging the layer, execution never reaches the cleanup block and the potentially large snapshot file remains on disk. Use a drop-based temporary-path/scope guard (for example, a tempfile abstraction retained until the rename succeeds) so unwinding or future cancellation also removes the file; explicitly disarm/persist the guard after a successful rename.

.await
.expect("compact mixed raw/lz4/zstd layers");
assert_eq!(published.as_deref(), Some(output_path.as_path()));
assert!(!output_path.with_extension("commit.tmp").exists());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · low]
This does not inspect the temporary file created by compact_layers: production adds a UUID (commit.<uuid>.tmp), while this assertion checks only commit.tmp. Consequently the cleanup test passes even when the randomized temp file leaks (the same mismatch occurs in the failure assertion below). Enumerate the output directory and assert that no filename matching the commit.*.tmp convention remains.

Comment on lines +410 to 416
ensure!(
src_blk_size <= i32::MAX as usize,
"block_size {src_blk_size} exceeds LZ4 i32 limit"
);

Ok(Self {
max_dst_size: lz4_compress_bound(src_blk_size),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
i32::MAX is not liblz4's maximum accepted source size. LZ4_compressBound() returns 0 above LZ4_MAX_INPUT_SIZE (currently 0x7E000000), so an externally supplied block size in that range reaches the production assert! in lz4_compress_bound instead of returning a configuration error. Validate against LZ4_MAX_INPUT_SIZE and make the bound helper return Result; additionally use checked i32::try_from conversions for every slice length passed to the FFI rather than relying on the configurable block size being “4KiB-scale”.

Comment on lines +1815 to +1817
if let Some(local) = local_dest {
let written = local.write_at_sync_pwrite(self.moffset, &batch.bytes)?;
ensure!(written == batch.bytes.len(), "short sync pwrite");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
Downcasting to LocalFile does not establish the buffered-I/O invariant required by write_at_sync_pwrite. LocalFileBuilder can create a LocalFile with direct_io(true), and these Vec buffers, compressed lengths, and offsets are generally not O_DIRECT-aligned; the pwrite path will then fail with EINVAL whereas the normal VirtualFile::write_at path supplies the required alignment handling. Gate this optimization on an explicit buffered-I/O capability (and otherwise use the async path).

Comment on lines +1931 to +1932
match pool.recv() {
Ok(batch) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · medium]
This is a blocking crossbeam receive inside an async method. While compression runs, it parks a Tokio executor thread; on a current-thread runtime it prevents all timers and unrelated tasks from progressing, and concurrent builders can exhaust a multi-thread runtime. The TODO acknowledges this, but this should not ship in the request path: collect results in spawn_blocking/block_in_place, or bridge worker completions through an async channel so waiting yields to the runtime.

Comment on lines +2013 to 2014
self.write_full_blocks_borrowed(head).await?;
buf = tail;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
A single public write() can commit several batches before a later batch returns an error or the future is cancelled. commit_compressed_batch() has already advanced block_len/moffset, but raw_data_size is only assigned at the end of write(). The builder then remains reusable and finish() can emit metadata whose logical size excludes committed blocks (or a retry can duplicate them), corrupting the zfile. The compact-writer wrapper poisons itself, but ZFileBuilder is public and used directly. Mark the builder failed before the first write await and reject subsequent writes/finalization after any error or cancellation, or make each public write transactionally update all logical state.

Comment thread tests/integration/fc.rs
Comment on lines +249 to +259
#[tokio::test]
async fn memory_snapshot_format_matches_config_and_resumes() -> Result<()> {
common::setup().await;
let config = agentenv::cfg::ConfigManager::global().config();
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}

run_memory_snapshot_format_and_resume_case(true).await
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
The repository's integration runner invokes this test binary only once with config/default.toml, whose new settings are direct_overlaybd = true and compression_enabled = false. I could not find any runner or temporary configs that launch this test under LZ4, Zstd, or legacy settings. Consequently, this test only covers the direct/raw case, while the new legacy test always skips and the compression branches in assert_memory_layer_matches_config are never exercised. Please add explicit test orchestration that runs separate processes with AENV_CONFIG_PATH pointing to direct raw/LZ4/Zstd and legacy configs (a separate process is required because ConfigManager is global).

Suggestion:

Suggested change
#[tokio::test]
async fn memory_snapshot_format_matches_config_and_resumes() -> Result<()> {
common::setup().await;
let config = agentenv::cfg::ConfigManager::global().config();
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}
run_memory_snapshot_format_and_resume_case(true).await
}
#[tokio::test]
async fn memory_snapshot_format_matches_config_and_resumes() -> Result<()> {
common::setup().await;
let config = agentenv::cfg::ConfigManager::global().config();
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}
run_memory_snapshot_format_and_resume_case(true).await
}

Comment thread src/cfg.rs
Comment on lines +379 to +380
#[config(default = 1)]
pub compression_workers: usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Validate this configured thread count instead of accepting every usize. The consumer currently silently clamps it to 1..=64, so values such as 0 or 1000 load successfully but do not represent the effective configuration. This can hide deployment mistakes and makes configuration behavior inconsistent with the existing background-download bounds checks. Add config validation (and rejection tests) for the supported range, ideally sharing the maximum with the consumer.

Suggestion:

Suggested change
#[config(default = 1)]
pub compression_workers: usize,
#[config(default = 1)]
pub compression_workers: usize,

Comment on lines +128 to +129
let lower_tmp = output_path.with_extension(format!("commit.{}.tmp", Uuid::now_v7()));
let build_result: Result<()> = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Cleanup only runs after this inner future returns. If compact_layers is cancelled or its task is aborted during merge/compression/rename, control never reaches remove_file, leaving a potentially large UUID-named snapshot file behind. This lifecycle operation can be cancelled during shutdown/error propagation, and there is no startup cleanup for this naming pattern. Use a cancellation-safe temporary-file guard whose Drop removes the unpublished path (disarming it after a successful rename), or otherwise arrange cleanup independently of polling this future to completion.

.await
.expect("compact mixed raw/lz4/zstd layers");
assert_eq!(published.as_deref(), Some(output_path.as_path()));
assert!(!output_path.with_extension("commit.tmp").exists());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · low]
This assertion cannot detect leaked temporary files. The implementation creates <stem>.commit.<uuid>.tmp, while with_extension("commit.tmp") checks <stem>.commit.tmp, a path that is never created. Scan the output directory for names matching the stable compacted-{name}.commit.*.tmp prefix/suffix (and make the same correction to the failure-path assertion below).

Comment on lines +267 to +270
pub fn write_at_sync_pwrite(&self, offset: u64, buf: &[u8]) -> Result<usize> {
#[cfg(test)]
self.write_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This entry point relies on the file being buffered, but LocalFile can be opened with direct_io(true) and the method does not enforce that precondition. The new concrete-type fast path can therefore select this method for an O_DIRECT LocalFile; an ordinary Vec<u8> batch is not guaranteed to have an aligned address or length, so pwrite will fail with EINVAL. Reject O_DIRECT here with an explicit check and ensure the caller falls back to the aligned async write path (or expose a capability predicate used before selecting this method).

Suggestion:

Suggested change
pub fn write_at_sync_pwrite(&self, offset: u64, buf: &[u8]) -> Result<usize> {
#[cfg(test)]
self.write_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
pub fn write_at_sync_pwrite(&self, offset: u64, buf: &[u8]) -> Result<usize> {
ensure!(
!self.direct_io,
"write_at_sync_pwrite does not support O_DIRECT files"
);
#[cfg(test)]
self.write_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);

Comment thread tests/integration/fc.rs
Comment on lines +253 to +256
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
The claimed raw/LZ4/Zstd and direct/legacy matrix is not actually launched by the changed test harness. make test-agent-integration runs this integration binary once with the default config (direct_overlaybd = true, compression disabled), and the only new e2e manifest change adds a dependency; there are no temporary-config invocations or compression environment overrides. Consequently this test only covers direct/raw, while the legacy test returns success and both compressed modes are never executed. Add explicit test-runner invocations/config fixtures for all intended modes (or make the test itself create isolated processes/configurations), so missing matrix entries cannot silently pass.

Add direct and legacy Firecracker integration tests that verify LZ4
compressed memory snapshots pause and resume correctly with workers=4.
Extract shared helper for the two test bodies with mode-specific skip
guards.

Add OSS e2e test for zfile memory lower byte preservation during
snapshot repository import.
Comment thread src/cfg.rs
Comment on lines +379 to +380
#[config(default = 1)]
pub compression_workers: usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Validate this setting at the configuration boundary (for example, require 1..=64) rather than accepting every usize. The only consumer currently clamps it to that range, so 0 and values above 64 load successfully but silently run with a different worker count than configured. This can hide deployment mistakes and makes the parsed AppConfig inconsistent with runtime behavior. Add the check to AppConfig::validate and cover both bounds in the config tests.

Suggestion:

Suggested change
#[config(default = 1)]
pub compression_workers: usize,
#[config(default = 1)]
pub compression_workers: usize,

Comment on lines +640 to +641
let lower_tmp = output_path.with_extension("commit.tmp");
let build_result = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · medium]
This fixed temporary filename makes publication unsafe when two snapshot attempts target the same output path. Both operations can truncate/write the same file, and one operation's error cleanup can delete the other's in-progress output; a rename may then publish mixed or wrong data. Cancellation also bypasses the post-await cleanup and leaves this shared partial file behind. Use a unique sibling temporary file (as compact_layers does with a UUID), ideally with a cleanup guard that runs when the future is dropped, before atomically renaming it into place.

Comment on lines +690 to +691
let lower_tmp = data_path.with_extension("commit.tmp");
let build_result: Result<()> = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · medium]
The sparse-memory path has the same deterministic-temp race: overlapping publications can truncate, rename, or remove each other's overlaybd.commit.tmp, and cancellation leaves a partial shared temp file. Create a unique temp file per attempt and arrange drop/cancellation cleanup before renaming it to data_path.

Comment on lines +128 to +129
let lower_tmp = output_path.with_extension(format!("commit.{}.tmp", Uuid::now_v7()));
let build_result: Result<()> = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Temporary-file cleanup is not cancellation-safe. If this future is dropped while LocalFile::new, merge_files_ro, or rename is pending (for example during snapshot cancellation/shutdown), control never reaches the later remove_file, leaving a potentially large UUID-named snapshot file behind. Please hold an RAII cleanup guard/temporary-path owner that removes the file on Drop, disarming it only after the rename succeeds; cleanup failures on ordinary error paths should also be logged rather than silently discarded.

.await
.expect("compact mixed raw/lz4/zstd layers");
assert_eq!(published.as_deref(), Some(output_path.as_path()));
assert!(!output_path.with_extension("commit.tmp").exists());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
This assertion does not check the filename created by production. For compacted-raw.commit, production creates compacted-raw.commit.<UUID>.tmp, while this checks only compacted-raw.commit.tmp; therefore the test still passes if the actual UUID-named temporary file leaks. Enumerate the output directory and assert that no files matching the production <stem>.commit.*.tmp pattern remain (both here and in the failure-path assertion below).

Comment on lines +1069 to +1072
fs::write(
&manifest.memory.image_config_path,
format!(r#"{{"lowers":[{{"file":"{}"}}]}}"#, zfile_lower.display()),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
Avoid interpolating Path::display() into JSON. A temp root containing ", \, or control characters will produce invalid JSON or a different path, making this regression test environment-dependent; display() is also lossy for non-UTF-8 paths. Build the value with serde_json so JSON escaping is handled correctly (and any unsupported path encoding fails explicitly).

Suggestion:

Suggested change
fs::write(
&manifest.memory.image_config_path,
format!(r#"{{"lowers":[{{"file":"{}"}}]}}"#, zfile_lower.display()),
)
fs::write(
&manifest.memory.image_config_path,
serde_json::to_vec(&json!({
"lowers": [{ "file": zfile_lower }],
}))
.expect("serialize zfile memory image config"),
)

let buf_size = writer.buffer_size();
ensure!(
buf_size.is_multiple_of(ALIGNMENT_USIZE),
buf_size >= ALIGNMENT_USIZE && buf_size.is_multiple_of(ALIGNMENT_USIZE),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This validation occurs only after writer.write_all_at(&header_buf, 0). For a custom writer using the trait's default write_all_at, buffer_size() == 0 reaches data.chunks(0) and panics before this ensure! can return an error. Read and validate buffer_size() before the first writer operation (and reuse the validated value here) so invalid public inputs fail through Result rather than panicking.

Suggestion:

Suggested change
buf_size >= ALIGNMENT_USIZE && buf_size.is_multiple_of(ALIGNMENT_USIZE),
buf_size >= ALIGNMENT_USIZE && buf_size.is_multiple_of(ALIGNMENT_USIZE),

Comment thread tests/integration/fc.rs
Comment on lines 237 to 238
let mut resumed = FirecrackerSandbox::resume_from_snapshot_config(&snapshot).await?;
verify_disk_marker(&mut resumed).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
The round-trip assertion only verifies a marker on the persistent disk. It proves that the VM can resume and access its disk, but not that guest-memory bytes survived compression/decompression correctly; corruption in anonymous memory or dirty-range handling may remain unnoticed. Preserve a memory-only value or running process across pause() and verify that state after resume, in addition to checking ZFile metadata and disk contents.

Comment thread tests/integration/fc.rs
Comment on lines +253 to +256
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
This makes missing configuration coverage indistinguishable from success. In the repository, the default config only runs the raw/direct case, and no test runner or CI entry was found that launches this test under LZ4, Zstd, or the legacy configuration. As a result, the newly claimed compression/legacy coverage can silently never execute. Add explicit test invocations/config fixtures for every required mode (or fail when the expected mode is not selected) rather than relying on skip-based external processes.

Comment thread tests/integration/fc.rs
Comment on lines +253 to +258
if !config.memory_snapshot.direct_overlaybd {
eprintln!("skipping direct overlaybd integration test; direct_overlaybd is disabled");
return Ok(());
}

run_memory_snapshot_format_and_resume_case(true).await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · medium]
The new raw/LZ4/Zstd and direct/legacy coverage is not actually wired into the repository's integration-test runner. make test-agent-integration invokes this test binary once with config/default.toml (direct_overlaybd = true, compression_enabled = false), so this only exercises the direct/raw case; the legacy test returns Ok(()), and no process selects LZ4 or Zstd. Add explicit test-runner/CI invocations using temporary configs for every promised mode (and avoid treating a missing required configuration as a successful skip), otherwise compression and legacy regressions will pass CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant