Skip to content

Add zstd at-rest CAS compression with byte-for-byte wire passthrough - #2641

Open
walter-zeromatter wants to merge 21 commits into
TraceMachina:mainfrom
Reactor-Inc:user/wgray/zstd-store
Open

Add zstd at-rest CAS compression with byte-for-byte wire passthrough#2641
walter-zeromatter wants to merge 21 commits into
TraceMachina:mainfrom
Reactor-Inc:user/wgray/zstd-store

Conversation

@walter-zeromatter

@walter-zeromatter walter-zeromatter commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

NativeLink accepts and advertises REAPI compressed-blobs/zstd (#2527), and since #2596 it also sends it for its own GrpcStore hops. Both undo the compression at the store boundary: the CAS holds raw bytes, so every --remote_cache_compression client pays a decompress-on-write and a recompress-on-read even though the bytes were zstd on the wire the whole way. On a cache hit the server decompresses stored bytes only to immediately re-encode them at the gRPC boundary.

Mechanism

A new zstd algorithm for the existing compression store keeps CAS blobs as standard zstd frames at rest. When it is the store an instance points at directly, it advertises a WireCompressionStore capability that ByteStream read/write and BatchReadBlobs/BatchUpdateBlobs use to move the stored stream byte-for-byte, with no decode/re-encode round trip. Identity clients (no --remote_cache_compression) are unaffected — they still get plain decompressed bytes, encoded and decoded on the way in and out.

The capability is deliberately not forwarded through wrappers (Store::wire_compression_store does not follow inner_store): a wrapper outside a representation-changing store cannot promise its bytes are a valid wire stream.

Uploads are validated before anything is committed. A client-supplied compressed stream is decoded to recompute the length and hash, checked against the declared digest, and only then handed to the backend — so a bad stream can never reach the inner store.

Deployment constraints

These are real and do not apply to compression_algorithm: { lz4: ... }:

  • CAS-only. Every operation needs a digest key. Never point an AC store at it.
  • backend must be a dedicated, empty namespace. It stores digest → zstd bytes under the same digest keys, and raw CAS payloads are unstructured (an arbitrary blob can legitimately begin with the zstd magic number), so there is no safe way to sniff which encoding an entry is in. Rollout and rollback need a cache flush or a new namespace; there is no in-place migration.
  • Placement matters. It must be the store the instance points at directly for passthrough to apply. fast_slow, dedup, existence_cache, cache_metrics, shard, ref_store, and size_partitioning all compose fine inside it. A wrapper outside it stays correct but disables passthrough at that boundary.
  • compression_level is capped at 1..=19, not zstd's 22. Levels 1–19 cap the frame at windowLog ≤ 23 (≤ 8 MiB), decodable by every Bazel/zstd-jni client on plain libzstd defaults. Long-distance matching and dictionaries are never enabled.

deployment-examples/docker-compose/local-storage-cas-zstd.json5 is a complete annotated deployment, and /reference/nativelink-config/store-overview#pass-through-compression-compression_algorithmzstd documents the whole picture.

Bounds and admission

Compressed input is bounded from both ends. max_compressed_upload_size caps the compressed bytes a client may push (RESOURCE_EXHAUSTED); independently the decoder's output sink is capped at the digest's declared uncompressed size and rejects with INVALID_ARGUMENT the moment decoded bytes would exceed it, so a zstd bomb dies at the first over-limit block instead of being materialized. Zero-digest validation runs through the same bounded decoder with an output cap of zero.

Three deadlines and two admission bounds keep a stalled or hostile peer from parking on resources:

  • stage_timeout_s (default 600) — total validate-and-stage budget, measured from admission. Unlike a per-message idle timeout, continuous slow progress does not reset it, so a client trickling bytes cannot hold a staging slot open.
  • commit_timeout_s (default 300) — bounds optional recompression plus the inner-store commit.
  • compressed_upload_idle_timeout_s (default 60, a ByteStream instance setting) — bounds the wait for the next WriteRequest.
  • max_concurrent_staged_uploads (default 4) and max_concurrent_identity_ops (default 256) — the latter because identity transfers hold a blocking thread for their whole duration.

Every path — success, validation failure, any timeout, cancellation — removes the staged temp file and releases the permit.

Two throughput notes: compressed uploads at or below max_inline_commit_size (default 4 MiB) are validated and committed straight from memory with no staging file and no fsync, because BatchUpdateBlobs payloads are small and numerous. And recompression is best-effort — an upload that finds every recompression slot busy commits its original stream rather than queuing while holding a staging permit.

Interaction with #2596

#2596 is merged, and this branch is rebased on top of it. They compose, with one caveat worth documenting:

  • Add opt-in zstd wire compression to GrpcStore transfers #2596 encodes client-side at WIRE_COMPRESSION_ZSTD_LEVEL = 1 (deliberately cheap) for blobs at or above 64 KiB. A server-side compression_algorithm.zstd with a higher compression_level and max_recompression_size > 0 re-encodes exactly those uploads and keeps whichever stream is smaller — cheap client encode, better ratio at rest.
  • ⚠️ Do not enable GrpcSpec.experimental_remote_cache_compression on a grpc store used as the backend of a zstd compression store. The backend would receive bytes that are already zstd and encode them again for the wire. Symmetric, so not a correctness problem, but pure CPU cost on both ends.

Tests

nativelink-store/tests/zstd_store_test.rs — 49 tests, plus 6 unit tests in zstd_store.rs itself: identity round trips, ranged reads that must drain the inner stream, byte-for-byte passthrough including concatenated frames, digest/size mismatch rejection with non-commitment, incomplete trailing frames, oversize rejection, zstd-bomb rejection at the sink, zero-digest handling, staging-semaphore serialization, stage/commit timeouts releasing their slot, temp-file cleanup on cancellation, inline vs staged commit, a filesystem-backend commit (which commits by rename(2) rather than from the descriptor), and composition through fast_slow / dedup / existence_cache / cache_metrics / size_partitioning / shard / ref_store, plus a wrapper-outside case.

Service-level coverage in bytestream_server_test.rs and cas_server_test.rs exercises both fast paths end-to-end, including the compressed-upload idle timeout freeing a staging slot and a continuously-progressing upload not being timed out.

Verified on this branch:

bazel test //...   # 103/103 pass, with the clippy pedantic/nursery + rustfmt aspects
cargo test -p nativelink-store -p nativelink-service -p nativelink-config -p nativelink-util  # 72 suites, 0 failures
cargo clippy --all-targets -p nativelink-store -p nativelink-service -p nativelink-config     # clean
cargo +nightly fmt --all -- --check                                                           # clean

Notes

  • Fixes an unbalanced brace that made deployment-examples/docker-compose/local-storage-cas-zstd.json5 unparseable, and extends nativelink-config/tests/json5_test.rs to parse every deployment example so a broken one can't ship again. (That test runs under native-cargo; it self-skips under bazel, where the examples aren't in runfiles.)
  • nativelink-config/examples/stores-config.json5 is generated from the fenced json examples in the stores.rs doc comments by generate-stores-config, so the new fields are documented there and the example follows from it.
  • The store publishes its own metrics — wire_uploads/wire_upload_bytes/wire_downloads, batch_zstd_passthroughs/batch_identity_decodes, inline_commits/staged_commits, the staged_uploads_inflight/identity_ops_inflight gauges, recompressions_applied/rejected/skipped_busy, and stage_timeouts/commit_timeouts — so the bounds above can be tuned from measurements rather than guesses.
  • Follow-up worth doing separately: the identity encode/decode paths still run on spawn_blocking threads, which is what max_concurrent_identity_ops bounds. Add opt-in zstd wire compression to GrpcStore transfers #2596 converted the analogous service codecs to drive the zstd raw decoder from an async loop; adopting that here would remove the blocking-thread-per-transfer cost and let the knob go away.

🤖 Generated with Claude Code


This change is Reviewable

walter-zeromatter and others added 20 commits July 30, 2026 14:35
Adds a downcast helper that checks only the immediate inner StoreDriver,
without following inner_store() like the existing recursive downcast_ref.
Needed so callers (e.g. the service layer) can detect a directly-configured
representation-changing store such as the upcoming ZstdStore at an instance
boundary, rather than resolving through pass-through wrappers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nativelink-store failed to compile (E0004) because the StoreSpec::ZstdStore
variant had no match arm in default_store_factory.rs. Add the arm, wiring
ZstdStore::new to its backend store like the other pass-through stores, and
add a factory test that builds a ZstdStore via store_factory and downcasts
to confirm the concrete type.
…dation tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… concat-frame test

Register staged/recompression temp file paths in TempFileGuard before
create_empty_temp_file runs, so a set_permissions failure or cancellation
during file creation can't leak an untracked file. Add a post_init
write-probe so a read-only or mispermissioned temp_path fails at startup
instead of on first upload. Document that the 0o600 permission is unix-only.
Add a test proving get_for_batch's raw-decode path handles a concatenated
multi-frame zstd physical stream.
…rite

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sion

The ZstdStore fast path in inner_batch_update_blobs took a client's
zstd-compressed blob and stored it verbatim via update_zstd_oneshot
whenever the instance's CAS store happened to be a ZstdStore, without
checking remote_cache_compression_enabled. This let a ZstdStore-backed
instance silently accept zstd uploads even when remote cache
compression was disabled for it, unlike the non-ZstdStore path (which
already rejects zstd via decompress_batch_update) and capabilities
(which stops advertising zstd when disabled).

Require remote_cache_compression_enabled alongside the ZstdStore/Zstd
compressor check so a disabled instance falls through to
decompress_batch_update and gets the same rejection as any other
store. Also moved the size_bytes computation into the else branch
since it is only used there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fast-path sites

Add a non-recursive owned-Arc sibling to downcast_ref_immediate for callers
that must move the concrete store into a spawned/'static future, and replace
the four repeated `store.clone().into_inner().as_any_arc().downcast::<ZstdStore>()`
idioms in the ByteStream and CAS servers with it. Behavior is unchanged:
into_inner() returns the immediate inner Arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ZstdStore's identity encode/decode paths run inside `spawn_blocking!` and
need `std::io::Read`/`Write` views over a buf_channel pair. Add
`BufChannelReader`/`BufChannelWriter` to `nativelink_util::buf_channel`,
where the underlying `DropCloserReadHalf`/`DropCloserWriteHalf` already
live, and drop the private copies from zstd_store.rs.

`BufChannelWriter` deliberately does not send EOF on drop: the caller sends
it explicitly once an upload has been validated, so a failed write never
commits downstream.

The service wire codecs used to need the same adapter, but no longer do —
they drive the zstd raw decoder from an async loop instead of a blocking
one — so these adapters now exist solely for the store's blocking paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tore

Introduce a single private decode_all_zstd(data, size_hint, on_err) helper and
use it at the three whole-buffer decode sites (two zstd::stream::decode_all and
one zstd::bulk::decompress). The size_hint selects bulk vs streaming decode and
the on_err closure preserves each site's exact error code and message
(InvalidArgument for client input, DataLoss for stored data). The two streaming
decoders are left untouched. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under Bazel (unlike cargo, which inherits crate deps) the store
integration test suite needs `@crates//:zstd` declared explicitly for the
ZstdStore tests. Caught by the clippy/build aspect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pinned staging

Harden the ZstdStore fast path against resource-exhaustion, starvation, and
staging path-replacement races, and clean the patch-specific CI failures.

Issue 1 — bounded decompression at the output sink:
- DecodeSink enforces the decoded-output ceiling inside Write::write using
  checked_add; output past the digest's uncompressed size is rejected
  immediately (InvalidArgument) before hashing/collecting, stopping a zstd
  bomb at the first over-limit block.
- Zero-digest validation streams through the same bounded decoder with an
  output cap of zero (no whole-buffer decode/allocation) and now acquires the
  staged-upload semaphore, so it participates in concurrency admission.

Issue 2 — slow-client / stall starvation:
- The compressed ByteStream client pump applies an idle timeout to waiting for
  the next WriteRequest (reusing persist_stream_on_disconnect_timeout_s); a
  client making progress is never timed out. On timeout the channel sender is
  dropped so the blocking validation unwinds through the existing join, and
  DeadlineExceeded is surfaced as the primary error.
- Inner-store commit (and recompression) is bounded by a new commit_timeout_s
  (default 300s); on expiry the upload fails DeadlineExceeded and the staged
  file/permit are released, so a stalled backend cannot hold a slot forever.

Issue 3 — descriptor-pinned staging:
- Staging files are created exclusively (create_new/O_EXCL, 0o600 atomic on
  unix); the validated descriptor is retained, rewound, and handed to the
  inner store at commit (never reopened by pathname), defeating
  observe-and-replace races. Recompression candidate handled the same way.
- post_init verifies temp_path is a directory and rejects a world-writable
  directory lacking the sticky bit. Adds fs::FileSlot::from_std.

Issue 4/5 — CI + docs:
- Regenerate stores-config.json5, add Vale vocabulary terms, fix MDX-unsafe
  comparison operators (windowLog <= 23 -> ≤), and reconcile store-overview
  docs with the new bounds/admission/deadline/temp_path/descriptor behavior.

Adds focused tests: sink bound + zero-digest bomb + over-decode rejection,
descriptor-pinned commit, commit-timeout slot release (store); ByteStream
idle-timeout slot release + progress-not-timed-out (service); batch per-blob
timeout sibling isolation (service).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness and availability:

- Qualify the descriptor-pinning claim. Only backends using the default
  `update_with_whole_file` read the validated descriptor; `filesystem`
  drops it and commits by `rename(2)` on the path, where the defence is
  `O_EXCL` creation of an unguessable name in an operator-private dir.
  Adds a filesystem-backed commit test, which the previous
  `MemoryStore`-only test could not cover.
- Report the staging error, not the feeder's consequential channel error,
  when a large oneshot upload is rejected.
- Route the negotiated compressor through a single
  `wire_compressor_capability` helper instead of hardcoding zstd in the
  ByteStream read/write and BatchUpdate fast paths. The helper lives in
  `nativelink_util::wire_compression` next to the codecs and
  `WireCompressor`, and is re-exported from the service module.
- Add `stage_timeout_s`: a total validate-and-stage deadline, since a
  per-message idle timeout is reset by a client that trickles bytes to
  hold a staging slot open.
- Make recompression best-effort (`try_acquire`). It previously queued on
  the recompression semaphore while holding a staging permit, letting a
  pool of 1 throttle the whole upload path.
- Reject `max_recompression_size > 0` without `compression_level`, which
  silently disabled recompression.
- Add `max_concurrent_identity_ops` to bound identity reads/writes, each
  of which holds a blocking thread for a whole transfer.
- Add `max_inline_commit_size`: validate and commit small compressed
  uploads from memory, so BatchUpdateBlobs stops paying a per-blob fsync.
- Give compressed uploads their own `compressed_upload_idle_timeout_s`
  instead of borrowing `persist_stream_on_disconnect_timeout_s`.
- Publish metrics for fast-path hits, inline vs staged commits, in-flight
  gauges, recompression outcomes, and deadline expirations.

Fix an unbalanced brace that made
`deployment-examples/docker-compose/local-storage-cas-zstd.json5`
unparseable, and extend the json5 test to parse every deployment example
so nothing ships broken again.

Drop stale references to the abandoned two-candidate staging design,
consolidate the duplicated join/error/decode helpers, and cut the comment
density from 18% to 15% while adding the above.

Also adapts the branch to upstream API changes picked up by the rebase:
the `RemoveCallback` alias, `FilesystemSpec::evict_page_cache`, and the
newly denied `clippy::cast_possible_wrap` in tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nativelink Ready Ready Preview Jul 30, 2026 10:53pm
nativelink-aidm Ready Ready Preview Jul 30, 2026 10:53pm

Request Review

Three CI failures, all in files this branch touches:

- rustfmt (the pinned nightly used by the Bazel aspect) reflows the
  `store_trait` import and the `register_remove_callback` signature in
  zstd_store.rs, both of which changed width when the rebase adopted the
  shorter `RemoveCallback` alias. It also wants the new
  `use crate::store_trait::WireCompressor` sorted after `digest_hasher`
  rather than after `buf_channel`.

- `nativelink-config/examples/stores-config.json5` is generated from the
  ```json blocks in stores.rs doc comments by `generate-stores-config`; it
  is not hand-editable. Add `max_concurrent_identity_ops` and
  `max_inline_commit_size` to the doc comment, which is the source of
  truth, so the generator reproduces the committed file exactly.

- Vale lints Rust doc comments as well as MDX. Rephrase to avoid the
  possessives `backend's`/`upload's` and the two words absent from its
  dictionary (`untrusted`, `expirations`) instead of widening the accepted
  vocabulary for ordinary prose.

Verified with `bazel test //...` (103/103, so the `unit_test` targets that
surfaced the rustfmt failures are covered this time) plus the cargo test,
clippy, and nightly rustfmt runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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