From 486710d73a9f0703d20fcba643f4114defc6ea03 Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Thu, 2 Jul 2026 13:16:09 -0700 Subject: [PATCH 01/12] Add REAPI content-defined chunking (SplitBlob/SpliceBlob) support Implements the server side of the remote-apis blob split/splice extension used by Bazel's --experimental_remote_cache_chunking (Bazel 8.7.0+/9.1.0+), fixes #2496. - Vendor SplitBlob/SpliceBlob RPCs, ChunkingFunction, FastCdc2020Params and CacheCapabilities fields 8-12 from upstream remote-apis. - SpliceBlob re-assembles chunked uploads: verifies chunk existence and the spliced digest before committing, materializes the blob so non-chunking clients stay correct, and persists the chunk layout in a configurable index store. Chunk reads are pipelined while hashing stays in chunk order. - SplitBlob serves stored layouts (validated against the blob size so corrupt or truncated index entries are never served), or chunks blobs on demand with FastCDC 2020 (fastcdc crate, normalization level 2) so outputs uploaded whole by remote execution workers also get chunked downloads. Unusable layouts fall back to re-chunking. - Capabilities advertise split/splice support and FastCDC 2020 parameters per instance, collected across all server blocks, gated behind the new opt-in experimental_chunking CAS service config (off by default; zero behavior change when unset). - Reject foot-gun configs at startup: index_store == cas_store (chunk layouts stored under blob digests would overwrite blob content) and chunking on grpc proxy stores (would download/re-upload entire blobs instead of forwarding RPCs). - Conformance-test the chunker against the official REAPI fastcdc2020_test_vectors.txt (offsets, lengths, sha256s and gear fingerprints, seeds 0 and 666, in-memory and streaming). - Add ChunkingMetrics (splice/split totals, hit/miss/on-demand rates, byte counters, digest verification failures). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 35 + MODULE.bazel.lock | 3 + nativelink-config/src/cas_server.rs | 74 +- .../execution/v2/remote_execution.proto | 356 ++++++++++ .../build.bazel.remote.execution.v2.pb.rs | 669 +++++++++++++++++ nativelink-service/BUILD.bazel | 5 + nativelink-service/Cargo.toml | 6 + nativelink-service/src/capabilities_server.rs | 36 +- nativelink-service/src/cas_server.rs | 672 +++++++++++++++++- nativelink-service/tests/cas_server_test.rs | 513 ++++++++++++- .../tests/data/SekienAkashita.jpg | Bin 0 -> 109466 bytes .../tests/data/fastcdc2020_test_vectors.txt | 35 + .../tests/fastcdc_conformance_test.rs | 151 ++++ nativelink-store/src/verify_store.rs | 10 +- nativelink-util/src/digest_hasher.rs | 8 + src/bin/nativelink.rs | 31 +- 16 files changed, 2575 insertions(+), 29 deletions(-) create mode 100644 nativelink-service/tests/data/SekienAkashita.jpg create mode 100644 nativelink-service/tests/data/fastcdc2020_test_vectors.txt create mode 100644 nativelink-service/tests/fastcdc_conformance_test.rs diff --git a/Cargo.lock b/Cargo.lock index 9fe033363..8604a1ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -1639,6 +1661,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastcdc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf51ceb43e96afbfe4dd5c6f6082af5dfd60e220820b8123792d61963f2ce6bc" +dependencies = [ + "async-stream", + "tokio", + "tokio-stream", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -3221,6 +3254,7 @@ dependencies = [ "async-trait", "axum", "bytes", + "fastcdc", "futures", "hex", "http-body-util", @@ -3246,6 +3280,7 @@ dependencies = [ "sha2", "tokio", "tokio-stream", + "tokio-util", "tonic", "tonic-prost", "tower", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index cf67c3255..0b1d14847 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -795,6 +795,8 @@ "assert-json-diff_2.0.2": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.8\"}],\"features\":{}}", "async-channel_1.9.0": "{\"dependencies\":[{\"name\":\"concurrent-queue\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3\"},{\"name\":\"event-listener\",\"req\":\"^2.4.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.5\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^1\"}],\"features\":{}}", "async-lock_3.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"event-listener\",\"req\":\"^5.0.0\"},{\"default_features\":false,\"name\":\"event-listener-strategy\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"flume\",\"req\":\"^0.11.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"event-listener/loom\",\"dep:loom\"],\"std\":[\"event-listener/std\",\"event-listener-strategy/std\"]}}", + "async-stream-impl_0.3.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{}}", + "async-stream_0.3.6": "{\"dependencies\":[{\"name\":\"async-stream-impl\",\"req\":\"=0.3.6\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", "async-trait_0.1.89": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\",\"proc-macro\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"kind\":\"dev\",\"name\":\"tracing-attributes\",\"req\":\"^0.1.27\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", "atomic-waker_1.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7.0\"}],\"features\":{}}", "atomic_0.6.1": "{\"dependencies\":[{\"name\":\"bytemuck\",\"req\":\"^1.13.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"default\":[\"fallback\"],\"fallback\":[],\"nightly\":[],\"serde\":[\"dep:serde\"],\"std\":[]}}", @@ -933,6 +935,7 @@ "event-listener-strategy_0.5.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"event-listener\",\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"event-listener/loom\"],\"portable-atomic\":[\"event-listener/portable-atomic\"],\"std\":[\"event-listener/std\"]}}", "event-listener_2.5.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"}],\"features\":{}}", "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", + "fastcdc_3.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aes\",\"req\":\"^0.8.2\"},{\"name\":\"async-stream\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"features\":[\"cargo\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.2.1\"},{\"kind\":\"dev\",\"name\":\"ctr\",\"req\":\"^0.9.2\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"memmap2\",\"req\":\"^0.9.5\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"fs\",\"io-util\",\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"default\":[],\"futures\":[\"dep:futures\"],\"tokio\":[\"dep:tokio\",\"tokio-stream\",\"async-stream\"]}}", "fastrand_1.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"instant\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"instant\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"kind\":\"dev\",\"name\":\"wyhash\",\"req\":\"^0.5\"}],\"features\":{}}", "fastrand_2.3.0": "{\"dependencies\":[{\"features\":[\"js\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wyhash\",\"req\":\"^0.5\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"js\":[\"std\",\"getrandom\"],\"std\":[\"alloc\"]}}", "fastrand_2.4.1": "{\"dependencies\":[{\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.4\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.4\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.4\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wyhash\",\"req\":\"^0.6\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"js\":[\"std\",\"getrandom\"],\"std\":[\"alloc\"]}}", diff --git a/nativelink-config/src/cas_server.rs b/nativelink-config/src/cas_server.rs index 5015e9aea..1b3172ab6 100644 --- a/nativelink-config/src/cas_server.rs +++ b/nativelink-config/src/cas_server.rs @@ -121,7 +121,7 @@ pub struct AcStoreConfig { pub read_only: bool, } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(deny_unknown_fields)] #[cfg_attr(feature = "dev-schema", derive(JsonSchema))] pub struct CasStoreConfig { @@ -129,6 +129,78 @@ pub struct CasStoreConfig { /// This store name referenced here may be reused multiple times. #[serde(deserialize_with = "convert_string_with_shellexpand")] pub cas_store: StoreRefName, + + /// Experimental: Enables the REAPI `SplitBlob`/`SpliceBlob` RPCs used by + /// content-defined chunking clients (e.g. Bazel's + /// `--experimental_remote_cache_chunking`). When set, the capabilities + /// service advertises blob split/splice support and `FastCDC` 2020 + /// parameters for this instance. + /// + /// Default: not set (chunking RPCs are rejected and not advertised). + #[serde(default)] + pub experimental_chunking: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "dev-schema", derive(JsonSchema))] +pub struct CasChunkingConfig { + /// The store name referenced in the `stores` map in the main config used + /// to persist blob-to-chunks layouts. Keys are the digests of the + /// original blobs and values are serialized chunk layouts (which do not + /// hash to those digests), so this store MUST NOT perform content digest + /// verification and MUST NOT be the same store as `cas_store` — writing + /// layouts into the CAS would overwrite blob content. Using the same + /// store name as `cas_store` is rejected at startup. + #[serde(deserialize_with = "convert_string_with_shellexpand")] + pub index_store: StoreRefName, + + /// The average chunk size in bytes advertised to clients through the + /// `FastCDC` 2020 capability parameters and used for server-side + /// chunking in `SplitBlob`. Clients derive the minimum and maximum + /// chunk sizes from this value (avg / 4 and avg * 4). The value must + /// be between 1 KiB and 1 MiB. + /// + /// Default: 524288 (512 KiB) + #[serde(default)] + pub avg_chunk_size_bytes: u64, +} + +impl CasChunkingConfig { + /// Default for `avg_chunk_size_bytes`, the value recommended by the + /// REAPI spec for `FastCdc2020Params`. + pub const DEFAULT_AVG_CHUNK_SIZE_BYTES: u64 = 512 * 1024; + /// Bounds for `avg_chunk_size_bytes` mandated by the REAPI spec for + /// `FastCdc2020Params`. + pub const MIN_AVG_CHUNK_SIZE_BYTES: u64 = 1024; + pub const MAX_AVG_CHUNK_SIZE_BYTES: u64 = 1024 * 1024; + + /// Returns `avg_chunk_size_bytes` with the default applied. + #[must_use] + pub const fn resolved_avg_chunk_size_bytes(&self) -> u64 { + if self.avg_chunk_size_bytes == 0 { + Self::DEFAULT_AVG_CHUNK_SIZE_BYTES + } else { + self.avg_chunk_size_bytes + } + } + + /// Returns `avg_chunk_size_bytes` with the default applied, or an error + /// when the configured value is outside the REAPI-mandated bounds. + pub fn validated_avg_chunk_size_bytes(&self) -> Result { + let avg_chunk_size_bytes = self.resolved_avg_chunk_size_bytes(); + if !(Self::MIN_AVG_CHUNK_SIZE_BYTES..=Self::MAX_AVG_CHUNK_SIZE_BYTES) + .contains(&avg_chunk_size_bytes) + { + return Err(make_err!( + Code::InvalidArgument, + "'experimental_chunking.avg_chunk_size_bytes' is {avg_chunk_size_bytes}, must be between {} and {}", + Self::MIN_AVG_CHUNK_SIZE_BYTES, + Self::MAX_AVG_CHUNK_SIZE_BYTES + )); + } + Ok(avg_chunk_size_bytes) + } } #[derive(Deserialize, Serialize, Debug, Default)] diff --git a/nativelink-proto/build/bazel/remote/execution/v2/remote_execution.proto b/nativelink-proto/build/bazel/remote/execution/v2/remote_execution.proto index ebfcea3c7..b23d01ae0 100644 --- a/nativelink-proto/build/bazel/remote/execution/v2/remote_execution.proto +++ b/nativelink-proto/build/bazel/remote/execution/v2/remote_execution.proto @@ -429,6 +429,125 @@ service ContentAddressableStorage { rpc GetTree(GetTreeRequest) returns (stream GetTreeResponse) { option (google.api.http) = { get: "/v2/{instance_name=**}/blobs/{root_digest.hash}/{root_digest.size_bytes}:getTree" }; } + + // SplitBlob retrieves information about how a blob is split into chunks. + // + // This call returns information about how a blob is split into chunks, and + // returns a list of the chunk digests. Using the returned list of chunk digests, + // a client can check which chunks are locally available and only fetch the + // missing ones. The desired blob can be assembled by concatenating the fetched + // chunks in the order of the digests in the list. The chunks SHOULD all be + // available in the CAS. + // + // This API can be used to reduce the required data to download a large blob + // from CAS if some chunks from similar blobs are locally available. For this + // procedure to work properly, blobs SHOULD be split in a content-defined way, + // rather than with fixed-sized chunking. + // + // If a split request is answered successfully, a client can expect the + // following guarantees from the server: + // 1. The blob chunks are stored in CAS. + // 2. Concatenating the blob chunks in the order of the digest list returned + // by the server results in the original blob. + // + // Servers which implement this functionality MUST declare that they support + // it by setting the + // [CacheCapabilities.split_blob_support][build.bazel.remote.execution.v2.CacheCapabilities.split_blob_support] + // field accordingly. + // + // Clients MUST check that the server supports this capability, before using + // it. + // + // Clients SHOULD verify that the digest of the blob assembled by the fetched + // chunks is equal to the requested blob digest. + // + // The lifetimes of the generated chunk blobs MAY be independent of the + // lifetime of the original blob. In particular: + // * A blob and any chunk derived from it MAY be evicted from the CAS at + // different times. + // * A call to [SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + // extends the lifetime of the original blob, and sets the lifetimes of + // the resulting chunks (or extends the lifetimes of already-existing + // chunks). + // * Touching a chunk extends its lifetime, but the server MAY choose not + // to extend the lifetime of the original blob. + // * Touching the original blob extends its lifetime, but the server MAY + // choose not to extend the lifetimes of chunks derived from it. + // + // When blob splitting and splicing is used at the same time, the clients and + // the server SHOULD agree out-of-band upon a chunking algorithm used by both + // parties to benefit from each other's chunk data and avoid unnecessary data + // duplication. + // + // Errors: + // + // * `NOT_FOUND`: The requested blob is not present in the CAS, OR there is no + // split information available for the blob, OR at least one chunk needed to + // reconstruct the blob is missing from the CAS. + // * `RESOURCE_EXHAUSTED`: There is insufficient disk quota to store the blob + // chunks. + rpc SplitBlob(SplitBlobRequest) returns (SplitBlobResponse) { + option (google.api.http) = { get: "/v2/{instance_name=**}/blobs/{blob_digest.hash}/{blob_digest.size_bytes}:splitBlob" }; + } + + // SpliceBlob tells the CAS how chunks can compose a blob. + // + // This is the complementary operation to the + // [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + // function to handle the chunked upload of large blobs to save upload + // traffic. + // + // When uploading a large blob using chunked upload, clients MUST first upload + // all chunks to the CAS, then call this RPC to tell the server how those chunks + // compose the original blob. The chunks referenced in the SpliceBlob call SHOULD be + // available in the CAS before calling this RPC. + // + // If a client needs to upload a large blob and is able to split a blob into + // chunks in such a way that reusable chunks are obtained, e.g., by means of + // content-defined chunking, it can first determine which parts of the blob + // are already available in the remote CAS and upload the missing chunks, and + // then use this API to store information on how the chunks compose the + // original blob. + // + // Servers which implement this functionality MUST declare that they support + // it by setting the + // [CacheCapabilities.splice_blob_support][build.bazel.remote.execution.v2.CacheCapabilities.splice_blob_support] + // field accordingly. + // + // Clients MUST check that the server supports this capability, before using + // it. + // + // In order to ensure data consistency of the CAS, the server MUST only add + // blobs to the CAS after verifying their digests. In particular, servers MUST NOT + // trust digests provided by the client. The server MAY accept a request as no-op + // if the client-specified blob is already in CAS or if information on how to + // construct the blob from chunks is available. If the client-specified blob is + // not already in the CAS, the server MUST verify that the digest of the newly + // created blob assembled from chunks matches the digest specified by the + // client, and reject the request if they differ. Servers MAY choose to allow + // overwriting existing chunk mappings or to store multiple chunk mappings for + // the same blob. + // + // When blob splitting and splicing is used at the same time, the clients and + // the server SHOULD agree out-of-band upon a chunking algorithm used by both + // parties to benefit from each other's chunk data and avoid unnecessary data + // duplication. + // + // Errors: + // + // * `NOT_FOUND`: At least one of the blob chunks is not present in the CAS. + // * `RESOURCE_EXHAUSTED`: There is insufficient disk quota to store the + // spliced blob. + // * `INVALID_ARGUMENT`: The digest of the spliced blob is different from the + // provided expected digest. + // * `ALREADY_EXISTS`: The blob already exists in CAS and the server did not + // extend the lifetime of the chunks specified in the request, e.g. because + // it prefers a different chunking and extended those instead. Clients can + // call [SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + // to check what chunk mapping the server is using. + rpc SpliceBlob(SpliceBlobRequest) returns (SpliceBlobResponse) { + option (google.api.http) = { post: "/v2/{instance_name=**}/blobs:spliceBlob" body: "*" }; + } } // The Capabilities service may be used by remote execution clients to query @@ -1777,6 +1896,100 @@ message GetTreeResponse { string next_page_token = 2; } +// A request message for +// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob]. +message SplitBlobRequest { + // The instance of the execution system to operate against. A server may + // support multiple instances of the execution system (with their own workers, + // storage, caches, etc.). The server MAY require use of this field to select + // between them in an implementation-defined fashion, otherwise it can be + // omitted. + string instance_name = 1; + + // The digest of the blob to be split. + Digest blob_digest = 2; + + // The digest function of the blob to be split. + // + // If the digest function used is one of MD5, MURMUR3, SHA1, SHA256, + // SHA384, SHA512, or VSO, the client MAY leave this field unset. In + // that case the server SHOULD infer the digest function using the + // length of the blob digest hashes and the digest functions announced + // in the server's capabilities. + DigestFunction.Value digest_function = 3; + + // The chunking function that the client prefers to use. + // + // The server MAY use a different chunking function. + ChunkingFunction.Value chunking_function = 4; +} + +// A response message for +// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob]. +message SplitBlobResponse { + // The ordered list of digests of the chunks into which the blob was split. + // The original blob is assembled by concatenating the chunk data according to + // the order of the digests given by this list. + // + // The server MUST use the same digest function as the one explicitly or + // implicitly (through hash length) specified in the split request. + repeated Digest chunk_digests = 1; + + // The chunking function used to split the blob. + ChunkingFunction.Value chunking_function = 2; +} + +// A request message for +// [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob]. +message SpliceBlobRequest { + // The instance of the execution system to operate against. A server may + // support multiple instances of the execution system (with their own workers, + // storage, caches, etc.). The server MAY require use of this field to select + // between them in an implementation-defined fashion, otherwise it can be + // omitted. + string instance_name = 1; + + // Expected digest of the spliced blob. The client MUST set this field due + // to the following reasons: + // 1. It allows the server to perform an early existence check of the blob + // or existing chunks that assemble the blob before spending the splicing + // effort, as described in the [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob] + // documentation. + // 2. It allows servers with different storage backends to dispatch the + // request to the correct storage backend based on the size and/or the + // hash of the blob. + // 3. If chunking information already exists for the blob, it allows + // the server to keep the existing chunking information or replace it with + // new chunking information. + Digest blob_digest = 2; + + // The ordered list of digests of the chunks which need to be concatenated to + // assemble the original blob. + repeated Digest chunk_digests = 3; + + // The digest function of all chunks to be concatenated and of the blob to be + // spliced. The server MUST use the same digest function for both cases. + // + // If the digest function used is one of MD5, MURMUR3, SHA1, SHA256, SHA384, + // SHA512, or VSO, the client MAY leave this field unset. In that case the + // server SHOULD infer the digest function using the length of the blob digest + // hashes and the digest functions announced in the server's capabilities. + DigestFunction.Value digest_function = 4; + + // The chunking function that the client used to split the blob. + ChunkingFunction.Value chunking_function = 5; +} + +// A response message for +// [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob]. +message SpliceBlobResponse { + // Computed digest of the spliced blob. + // + // The server MUST use the same digest function as the one explicitly or + // implicitly (through hash length) specified in the splice request. + Digest blob_digest = 1; +} + // A request message for // [Capabilities.GetCapabilities][build.bazel.remote.execution.v2.Capabilities.GetCapabilities]. message GetCapabilitiesRequest { @@ -1967,6 +2180,34 @@ message Compressor { } } +// The chunking function is used to split a blob into chunks. +// +// The server advertises support for a chunking function by setting the +// corresponding params field in +// [CacheCapabilities][build.bazel.remote.execution.v2.CacheCapabilities]. +// For example, if fast_cdc_2020_params is set, the server supports FAST_CDC_2020. +// +// For optimal deduplication, clients SHOULD use an advertised chunking function. +// When clients use UNKNOWN, the server chooses an algorithm for SplitBlob and +// simply verifies chunk concatenation for SpliceBlob. +message ChunkingFunction { + enum Value { + // No specific algorithm. Servers MUST always accept this value. + // For SplitBlob, the server chooses the algorithm. For SpliceBlob, the + // server only verifies that chunks concatenate to form the expected blob. + UNKNOWN = 0; + + // The FastCDC chunking algorithm as described in the 2020 paper by + // Wen Xia, et al. See https://ieeexplore.ieee.org/document/9055082 + // for details. + FAST_CDC_2020 = 1; + + // The RepMaxCDC chunking algorithm as implemented by buildbarn/go-cdc. + // See https://github.com/buildbarn/go-cdc for details. + REP_MAX_CDC = 2; + } +} + // Capabilities of the remote cache system. message CacheCapabilities { // All the digest functions supported by the remote cache. @@ -2000,6 +2241,121 @@ message CacheCapabilities { // [BatchUpdateBlobs][build.bazel.remote.execution.v2.ContentAddressableStorage.BatchUpdateBlobs] // requests. repeated Compressor.Value supported_batch_update_compressors = 7; + + // The maximum blob size that the server will accept for CAS blob uploads. + // - If it is 0, it means there is no limit set. A client may assume + // arbitrarily large blobs may be uploaded to and downloaded from the cache. + // - If it is larger than 0, implementations SHOULD NOT attempt to upload + // blobs with size larger than the limit. Servers SHOULD reject blob + // uploads over the `max_cas_blob_size_bytes` limit with response code + // `INVALID_ARGUMENT` + // - If the cache implementation returns a given limit, it MAY still serve + // blobs larger than this limit. + int64 max_cas_blob_size_bytes = 8; + + // Whether blob splitting is supported for the particular server/instance. If + // yes, the server/instance implements the specified behavior for blob + // splitting and a meaningful result can be expected from the + // [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + // operation. + bool split_blob_support = 9; + + // Whether blob splicing is supported for the particular server/instance. If + // yes, the server/instance implements the specified behavior for blob + // splicing and a meaningful result can be expected from the + // [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob] + // operation. + bool splice_blob_support = 10; + + // The parameters for the FastCDC 2020 chunking algorithm. + // If set, the server supports the FastCDC chunking algorithm. + FastCdc2020Params fast_cdc_2020_params = 11; + + // The parameters for the RepMaxCDC chunking algorithm. + // If set, the server supports the RepMaxCDC chunking algorithm. + RepMaxCdcParams rep_max_cdc_params = 12; +} + +// Parameters for the FastCDC content-defined chunking algorithm. +// +// Implementations MUST follow the FastCDC 2020 paper by Wen Xia, et al.: +// https://ieeexplore.ieee.org/document/9055082 +// +// Supported implementations: +// - Rust: https://docs.rs/fastcdc/3.2.1/fastcdc/v2020/index.html +// - Go: https://github.com/buildbuddy-io/fastcdc2020 +// +// Test vectors can be found in the accompanying fastcdc2020_test_vectors.txt file. +// +// Implementations MUST use normalization level 2, which has been found +// successful for build artifacts with an average chunk size of 512 KiB. +// +// Key algorithm components from the paper: +// +// GEAR table: 256 64-bit integers for the rolling hash, computed as: +// GEAR[i] = high_64_bits(MD5(byte(i))) for i in 0..255 +// +// MASKS table: Bit patterns for chunk boundary detection, derived from +// the C reference implementation. The mask selection based on average +// chunk size SHOULD match the paper. +// +// The minimum and maximum chunk sizes MUST be derived from the average: +// - min_chunk_size = avg_chunk_size_bytes / 4 +// - max_chunk_size = avg_chunk_size_bytes * 4 +// +// Blobs smaller than max_chunk_size (avg_chunk_size_bytes * 4) SHOULD be +// uploaded without chunking. +// +// If any of the advertised parameters are not within the expected range, +// the client SHOULD ignore FastCDC chunking function support. +message FastCdc2020Params { + // The average (expected) chunk size for the FastCDC chunking algorithm. + // The value MUST be between 1 KiB and 1 MiB. The recommended value is + // 524288 (512 KiB). + uint64 avg_chunk_size_bytes = 1; + + // The seed for the FastCDC mask generation. + // The recommended value is 0. + // + // All clients sharing a cache SHOULD use the same seed to maximize + // chunk reuse. + uint32 seed = 2; +} + +// Parameters for the RepMaxCDC content-defined chunking algorithm. +// +// Supported implementations: +// - Go: https://github.com/buildbarn/go-cdc +// +// Key algorithm components: +// +// GEAR table: 256 64-bit integers for the rolling hash, computed as: +// GEAR[i] = high_64_bits(MD5(byte(i))) for i in 0..255 +// +// The algorithm repeatedly applies chunking until all chunks are in the +// range [min_chunk_size_bytes, 2*min_chunk_size_bytes). Cutting points are +// selected where the Gear rolling hash is maximized within a lookahead +// window of horizon_size_bytes. +// +// For sufficiently large files, the average chunk size prior to +// deduplication will approximately be min_chunk_size_bytes divided by +// Rényi's parking constant (0.7475979203...). More details: +// https://mathworld.wolfram.com/RenyisParkingConstants.html +// +// If any of the advertised parameters are not within the expected range, +// the client SHOULD ignore RepMaxCDC chunking function support. +message RepMaxCdcParams { + // The minimum chunk size for the RepMaxCDC chunking algorithm. + // The value MUST be at least 64 bytes (the Gear hash window size). + // All chunks will be in the range [min_chunk_size_bytes, 2*min_chunk_size_bytes). + // The recommended value is 262144 (256 KiB). + uint64 min_chunk_size_bytes = 1; + + // The lookahead window for finding optimal cutting points. + // Larger values improve deduplication quality with diminishing returns. + // Setting to 0 produces uniform chunks of min_chunk_size_bytes. + // The recommended value is 8 * min_chunk_size_bytes. + uint64 horizon_size_bytes = 2; } // Capabilities of the remote execution system. diff --git a/nativelink-proto/genproto/build.bazel.remote.execution.v2.pb.rs b/nativelink-proto/genproto/build.bazel.remote.execution.v2.pb.rs index b7c8f6634..f4b7c43e7 100644 --- a/nativelink-proto/genproto/build.bazel.remote.execution.v2.pb.rs +++ b/nativelink-proto/genproto/build.bazel.remote.execution.v2.pb.rs @@ -1438,6 +1438,104 @@ pub struct GetTreeResponse { pub next_page_token: ::prost::alloc::string::String, } /// A request message for +/// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob]. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SplitBlobRequest { + /// The instance of the execution system to operate against. A server may + /// support multiple instances of the execution system (with their own workers, + /// storage, caches, etc.). The server MAY require use of this field to select + /// between them in an implementation-defined fashion, otherwise it can be + /// omitted. + #[prost(string, tag = "1")] + pub instance_name: ::prost::alloc::string::String, + /// The digest of the blob to be split. + #[prost(message, optional, tag = "2")] + pub blob_digest: ::core::option::Option, + /// The digest function of the blob to be split. + /// + /// If the digest function used is one of MD5, MURMUR3, SHA1, SHA256, + /// SHA384, SHA512, or VSO, the client MAY leave this field unset. In + /// that case the server SHOULD infer the digest function using the + /// length of the blob digest hashes and the digest functions announced + /// in the server's capabilities. + #[prost(enumeration = "digest_function::Value", tag = "3")] + pub digest_function: i32, + /// The chunking function that the client prefers to use. + /// + /// The server MAY use a different chunking function. + #[prost(enumeration = "chunking_function::Value", tag = "4")] + pub chunking_function: i32, +} +/// A response message for +/// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob]. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SplitBlobResponse { + /// The ordered list of digests of the chunks into which the blob was split. + /// The original blob is assembled by concatenating the chunk data according to + /// the order of the digests given by this list. + /// + /// The server MUST use the same digest function as the one explicitly or + /// implicitly (through hash length) specified in the split request. + #[prost(message, repeated, tag = "1")] + pub chunk_digests: ::prost::alloc::vec::Vec, + /// The chunking function used to split the blob. + #[prost(enumeration = "chunking_function::Value", tag = "2")] + pub chunking_function: i32, +} +/// A request message for +/// [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob]. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SpliceBlobRequest { + /// The instance of the execution system to operate against. A server may + /// support multiple instances of the execution system (with their own workers, + /// storage, caches, etc.). The server MAY require use of this field to select + /// between them in an implementation-defined fashion, otherwise it can be + /// omitted. + #[prost(string, tag = "1")] + pub instance_name: ::prost::alloc::string::String, + /// Expected digest of the spliced blob. The client MUST set this field due + /// to the following reasons: + /// 1. It allows the server to perform an early existence check of the blob + /// or existing chunks that assemble the blob before spending the splicing + /// effort, as described in the [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob] + /// documentation. + /// 2. It allows servers with different storage backends to dispatch the + /// request to the correct storage backend based on the size and/or the + /// hash of the blob. + /// 3. If chunking information already exists for the blob, it allows + /// the server to keep the existing chunking information or replace it with + /// new chunking information. + #[prost(message, optional, tag = "2")] + pub blob_digest: ::core::option::Option, + /// The ordered list of digests of the chunks which need to be concatenated to + /// assemble the original blob. + #[prost(message, repeated, tag = "3")] + pub chunk_digests: ::prost::alloc::vec::Vec, + /// The digest function of all chunks to be concatenated and of the blob to be + /// spliced. The server MUST use the same digest function for both cases. + /// + /// If the digest function used is one of MD5, MURMUR3, SHA1, SHA256, SHA384, + /// SHA512, or VSO, the client MAY leave this field unset. In that case the + /// server SHOULD infer the digest function using the length of the blob digest + /// hashes and the digest functions announced in the server's capabilities. + #[prost(enumeration = "digest_function::Value", tag = "4")] + pub digest_function: i32, + /// The chunking function that the client used to split the blob. + #[prost(enumeration = "chunking_function::Value", tag = "5")] + pub chunking_function: i32, +} +/// A response message for +/// [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob]. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SpliceBlobResponse { + /// Computed digest of the spliced blob. + /// + /// The server MUST use the same digest function as the one explicitly or + /// implicitly (through hash length) specified in the splice request. + #[prost(message, optional, tag = "1")] + pub blob_digest: ::core::option::Option, +} +/// A request message for /// [Capabilities.GetCapabilities][build.bazel.remote.execution.v2.Capabilities.GetCapabilities]. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetCapabilitiesRequest { @@ -1749,6 +1847,68 @@ pub mod compressor { } } } +/// The chunking function is used to split a blob into chunks. +/// +/// The server advertises support for a chunking function by setting the +/// corresponding params field in +/// [CacheCapabilities][build.bazel.remote.execution.v2.CacheCapabilities]. +/// For example, if fast_cdc_2020_params is set, the server supports FAST_CDC_2020. +/// +/// For optimal deduplication, clients SHOULD use an advertised chunking function. +/// When clients use UNKNOWN, the server chooses an algorithm for SplitBlob and +/// simply verifies chunk concatenation for SpliceBlob. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ChunkingFunction {} +/// Nested message and enum types in `ChunkingFunction`. +pub mod chunking_function { + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum Value { + /// No specific algorithm. Servers MUST always accept this value. + /// For SplitBlob, the server chooses the algorithm. For SpliceBlob, the + /// server only verifies that chunks concatenate to form the expected blob. + Unknown = 0, + /// The FastCDC chunking algorithm as described in the 2020 paper by + /// Wen Xia, et al. See + /// for details. + FastCdc2020 = 1, + /// The RepMaxCDC chunking algorithm as implemented by buildbarn/go-cdc. + /// See for details. + RepMaxCdc = 2, + } + impl Value { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unknown => "UNKNOWN", + Self::FastCdc2020 => "FAST_CDC_2020", + Self::RepMaxCdc => "REP_MAX_CDC", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "UNKNOWN" => Some(Self::Unknown), + "FAST_CDC_2020" => Some(Self::FastCdc2020), + "REP_MAX_CDC" => Some(Self::RepMaxCdc), + _ => None, + } + } + } +} /// Capabilities of the remote cache system. #[derive(Clone, PartialEq, ::prost::Message)] pub struct CacheCapabilities { @@ -1786,6 +1946,123 @@ pub struct CacheCapabilities { /// requests. #[prost(enumeration = "compressor::Value", repeated, tag = "7")] pub supported_batch_update_compressors: ::prost::alloc::vec::Vec, + /// The maximum blob size that the server will accept for CAS blob uploads. + /// - If it is 0, it means there is no limit set. A client may assume + /// arbitrarily large blobs may be uploaded to and downloaded from the cache. + /// - If it is larger than 0, implementations SHOULD NOT attempt to upload + /// blobs with size larger than the limit. Servers SHOULD reject blob + /// uploads over the `max_cas_blob_size_bytes` limit with response code + /// `INVALID_ARGUMENT` + /// - If the cache implementation returns a given limit, it MAY still serve + /// blobs larger than this limit. + #[prost(int64, tag = "8")] + pub max_cas_blob_size_bytes: i64, + /// Whether blob splitting is supported for the particular server/instance. If + /// yes, the server/instance implements the specified behavior for blob + /// splitting and a meaningful result can be expected from the + /// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// operation. + #[prost(bool, tag = "9")] + pub split_blob_support: bool, + /// Whether blob splicing is supported for the particular server/instance. If + /// yes, the server/instance implements the specified behavior for blob + /// splicing and a meaningful result can be expected from the + /// [ContentAddressableStorage.SpliceBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob] + /// operation. + #[prost(bool, tag = "10")] + pub splice_blob_support: bool, + /// The parameters for the FastCDC 2020 chunking algorithm. + /// If set, the server supports the FastCDC chunking algorithm. + #[prost(message, optional, tag = "11")] + pub fast_cdc_2020_params: ::core::option::Option, + /// The parameters for the RepMaxCDC chunking algorithm. + /// If set, the server supports the RepMaxCDC chunking algorithm. + #[prost(message, optional, tag = "12")] + pub rep_max_cdc_params: ::core::option::Option, +} +/// Parameters for the FastCDC content-defined chunking algorithm. +/// +/// Implementations MUST follow the FastCDC 2020 paper by Wen Xia, et al.: +/// +/// +/// Supported implementations: +/// - Rust: +/// - Go: +/// +/// Test vectors can be found in the accompanying fastcdc2020_test_vectors.txt file. +/// +/// Implementations MUST use normalization level 2, which has been found +/// successful for build artifacts with an average chunk size of 512 KiB. +/// +/// Key algorithm components from the paper: +/// +/// GEAR table: 256 64-bit integers for the rolling hash, computed as: +/// GEAR\[i\] = high_64_bits(MD5(byte(i))) for i in 0..255 +/// +/// MASKS table: Bit patterns for chunk boundary detection, derived from +/// the C reference implementation. The mask selection based on average +/// chunk size SHOULD match the paper. +/// +/// The minimum and maximum chunk sizes MUST be derived from the average: +/// - min_chunk_size = avg_chunk_size_bytes / 4 +/// - max_chunk_size = avg_chunk_size_bytes * 4 +/// +/// Blobs smaller than max_chunk_size (avg_chunk_size_bytes * 4) SHOULD be +/// uploaded without chunking. +/// +/// If any of the advertised parameters are not within the expected range, +/// the client SHOULD ignore FastCDC chunking function support. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FastCdc2020Params { + /// The average (expected) chunk size for the FastCDC chunking algorithm. + /// The value MUST be between 1 KiB and 1 MiB. The recommended value is + /// 524288 (512 KiB). + #[prost(uint64, tag = "1")] + pub avg_chunk_size_bytes: u64, + /// The seed for the FastCDC mask generation. + /// The recommended value is 0. + /// + /// All clients sharing a cache SHOULD use the same seed to maximize + /// chunk reuse. + #[prost(uint32, tag = "2")] + pub seed: u32, +} +/// Parameters for the RepMaxCDC content-defined chunking algorithm. +/// +/// Supported implementations: +/// - Go: +/// +/// Key algorithm components: +/// +/// GEAR table: 256 64-bit integers for the rolling hash, computed as: +/// GEAR\[i\] = high_64_bits(MD5(byte(i))) for i in 0..255 +/// +/// The algorithm repeatedly applies chunking until all chunks are in the +/// range [min_chunk_size_bytes, 2*min_chunk_size_bytes). Cutting points are +/// selected where the Gear rolling hash is maximized within a lookahead +/// window of horizon_size_bytes. +/// +/// For sufficiently large files, the average chunk size prior to +/// deduplication will approximately be min_chunk_size_bytes divided by +/// Rényi's parking constant (0.7475979203...). More details: +/// +/// +/// If any of the advertised parameters are not within the expected range, +/// the client SHOULD ignore RepMaxCDC chunking function support. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RepMaxCdcParams { + /// The minimum chunk size for the RepMaxCDC chunking algorithm. + /// The value MUST be at least 64 bytes (the Gear hash window size). + /// All chunks will be in the range [min_chunk_size_bytes, 2*min_chunk_size_bytes). + /// The recommended value is 262144 (256 KiB). + #[prost(uint64, tag = "1")] + pub min_chunk_size_bytes: u64, + /// The lookahead window for finding optimal cutting points. + /// Larger values improve deduplication quality with diminishing returns. + /// Setting to 0 produces uniform chunks of min_chunk_size_bytes. + /// The recommended value is 8 * min_chunk_size_bytes. + #[prost(uint64, tag = "2")] + pub horizon_size_bytes: u64, } /// Capabilities of the remote execution system. #[derive(Clone, PartialEq, ::prost::Message)] @@ -3340,6 +3617,175 @@ pub mod content_addressable_storage_client { ); self.inner.server_streaming(req, path, codec).await } + /// SplitBlob retrieves information about how a blob is split into chunks. + /// + /// This call returns information about how a blob is split into chunks, and + /// returns a list of the chunk digests. Using the returned list of chunk digests, + /// a client can check which chunks are locally available and only fetch the + /// missing ones. The desired blob can be assembled by concatenating the fetched + /// chunks in the order of the digests in the list. The chunks SHOULD all be + /// available in the CAS. + /// + /// This API can be used to reduce the required data to download a large blob + /// from CAS if some chunks from similar blobs are locally available. For this + /// procedure to work properly, blobs SHOULD be split in a content-defined way, + /// rather than with fixed-sized chunking. + /// + /// If a split request is answered successfully, a client can expect the + /// following guarantees from the server: + /// 1. The blob chunks are stored in CAS. + /// 2. Concatenating the blob chunks in the order of the digest list returned + /// by the server results in the original blob. + /// + /// Servers which implement this functionality MUST declare that they support + /// it by setting the + /// [CacheCapabilities.split_blob_support][build.bazel.remote.execution.v2.CacheCapabilities.split_blob_support] + /// field accordingly. + /// + /// Clients MUST check that the server supports this capability, before using + /// it. + /// + /// Clients SHOULD verify that the digest of the blob assembled by the fetched + /// chunks is equal to the requested blob digest. + /// + /// The lifetimes of the generated chunk blobs MAY be independent of the + /// lifetime of the original blob. In particular: + /// * A blob and any chunk derived from it MAY be evicted from the CAS at + /// different times. + /// * A call to [SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// extends the lifetime of the original blob, and sets the lifetimes of + /// the resulting chunks (or extends the lifetimes of already-existing + /// chunks). + /// * Touching a chunk extends its lifetime, but the server MAY choose not + /// to extend the lifetime of the original blob. + /// * Touching the original blob extends its lifetime, but the server MAY + /// choose not to extend the lifetimes of chunks derived from it. + /// + /// When blob splitting and splicing is used at the same time, the clients and + /// the server SHOULD agree out-of-band upon a chunking algorithm used by both + /// parties to benefit from each other's chunk data and avoid unnecessary data + /// duplication. + /// + /// Errors: + /// + /// * `NOT_FOUND`: The requested blob is not present in the CAS, OR there is no + /// split information available for the blob, OR at least one chunk needed to + /// reconstruct the blob is missing from the CAS. + /// * `RESOURCE_EXHAUSTED`: There is insufficient disk quota to store the blob + /// chunks. + pub async fn split_blob( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/build.bazel.remote.execution.v2.ContentAddressableStorage/SplitBlob", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "build.bazel.remote.execution.v2.ContentAddressableStorage", + "SplitBlob", + ), + ); + self.inner.unary(req, path, codec).await + } + /// SpliceBlob tells the CAS how chunks can compose a blob. + /// + /// This is the complementary operation to the + /// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// function to handle the chunked upload of large blobs to save upload + /// traffic. + /// + /// When uploading a large blob using chunked upload, clients MUST first upload + /// all chunks to the CAS, then call this RPC to tell the server how those chunks + /// compose the original blob. The chunks referenced in the SpliceBlob call SHOULD be + /// available in the CAS before calling this RPC. + /// + /// If a client needs to upload a large blob and is able to split a blob into + /// chunks in such a way that reusable chunks are obtained, e.g., by means of + /// content-defined chunking, it can first determine which parts of the blob + /// are already available in the remote CAS and upload the missing chunks, and + /// then use this API to store information on how the chunks compose the + /// original blob. + /// + /// Servers which implement this functionality MUST declare that they support + /// it by setting the + /// [CacheCapabilities.splice_blob_support][build.bazel.remote.execution.v2.CacheCapabilities.splice_blob_support] + /// field accordingly. + /// + /// Clients MUST check that the server supports this capability, before using + /// it. + /// + /// In order to ensure data consistency of the CAS, the server MUST only add + /// blobs to the CAS after verifying their digests. In particular, servers MUST NOT + /// trust digests provided by the client. The server MAY accept a request as no-op + /// if the client-specified blob is already in CAS or if information on how to + /// construct the blob from chunks is available. If the client-specified blob is + /// not already in the CAS, the server MUST verify that the digest of the newly + /// created blob assembled from chunks matches the digest specified by the + /// client, and reject the request if they differ. Servers MAY choose to allow + /// overwriting existing chunk mappings or to store multiple chunk mappings for + /// the same blob. + /// + /// When blob splitting and splicing is used at the same time, the clients and + /// the server SHOULD agree out-of-band upon a chunking algorithm used by both + /// parties to benefit from each other's chunk data and avoid unnecessary data + /// duplication. + /// + /// Errors: + /// + /// * `NOT_FOUND`: At least one of the blob chunks is not present in the CAS. + /// * `RESOURCE_EXHAUSTED`: There is insufficient disk quota to store the + /// spliced blob. + /// * `INVALID_ARGUMENT`: The digest of the spliced blob is different from the + /// provided expected digest. + /// * `ALREADY_EXISTS`: The blob already exists in CAS and the server did not + /// extend the lifetime of the chunks specified in the request, e.g. because + /// it prefers a different chunking and extended those instead. Clients can + /// call [SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// to check what chunk mapping the server is using. + pub async fn splice_blob( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/build.bazel.remote.execution.v2.ContentAddressableStorage/SpliceBlob", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "build.bazel.remote.execution.v2.ContentAddressableStorage", + "SpliceBlob", + ), + ); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -3461,6 +3907,131 @@ pub mod content_addressable_storage_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + /// SplitBlob retrieves information about how a blob is split into chunks. + /// + /// This call returns information about how a blob is split into chunks, and + /// returns a list of the chunk digests. Using the returned list of chunk digests, + /// a client can check which chunks are locally available and only fetch the + /// missing ones. The desired blob can be assembled by concatenating the fetched + /// chunks in the order of the digests in the list. The chunks SHOULD all be + /// available in the CAS. + /// + /// This API can be used to reduce the required data to download a large blob + /// from CAS if some chunks from similar blobs are locally available. For this + /// procedure to work properly, blobs SHOULD be split in a content-defined way, + /// rather than with fixed-sized chunking. + /// + /// If a split request is answered successfully, a client can expect the + /// following guarantees from the server: + /// 1. The blob chunks are stored in CAS. + /// 2. Concatenating the blob chunks in the order of the digest list returned + /// by the server results in the original blob. + /// + /// Servers which implement this functionality MUST declare that they support + /// it by setting the + /// [CacheCapabilities.split_blob_support][build.bazel.remote.execution.v2.CacheCapabilities.split_blob_support] + /// field accordingly. + /// + /// Clients MUST check that the server supports this capability, before using + /// it. + /// + /// Clients SHOULD verify that the digest of the blob assembled by the fetched + /// chunks is equal to the requested blob digest. + /// + /// The lifetimes of the generated chunk blobs MAY be independent of the + /// lifetime of the original blob. In particular: + /// * A blob and any chunk derived from it MAY be evicted from the CAS at + /// different times. + /// * A call to [SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// extends the lifetime of the original blob, and sets the lifetimes of + /// the resulting chunks (or extends the lifetimes of already-existing + /// chunks). + /// * Touching a chunk extends its lifetime, but the server MAY choose not + /// to extend the lifetime of the original blob. + /// * Touching the original blob extends its lifetime, but the server MAY + /// choose not to extend the lifetimes of chunks derived from it. + /// + /// When blob splitting and splicing is used at the same time, the clients and + /// the server SHOULD agree out-of-band upon a chunking algorithm used by both + /// parties to benefit from each other's chunk data and avoid unnecessary data + /// duplication. + /// + /// Errors: + /// + /// * `NOT_FOUND`: The requested blob is not present in the CAS, OR there is no + /// split information available for the blob, OR at least one chunk needed to + /// reconstruct the blob is missing from the CAS. + /// * `RESOURCE_EXHAUSTED`: There is insufficient disk quota to store the blob + /// chunks. + async fn split_blob( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// SpliceBlob tells the CAS how chunks can compose a blob. + /// + /// This is the complementary operation to the + /// [ContentAddressableStorage.SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// function to handle the chunked upload of large blobs to save upload + /// traffic. + /// + /// When uploading a large blob using chunked upload, clients MUST first upload + /// all chunks to the CAS, then call this RPC to tell the server how those chunks + /// compose the original blob. The chunks referenced in the SpliceBlob call SHOULD be + /// available in the CAS before calling this RPC. + /// + /// If a client needs to upload a large blob and is able to split a blob into + /// chunks in such a way that reusable chunks are obtained, e.g., by means of + /// content-defined chunking, it can first determine which parts of the blob + /// are already available in the remote CAS and upload the missing chunks, and + /// then use this API to store information on how the chunks compose the + /// original blob. + /// + /// Servers which implement this functionality MUST declare that they support + /// it by setting the + /// [CacheCapabilities.splice_blob_support][build.bazel.remote.execution.v2.CacheCapabilities.splice_blob_support] + /// field accordingly. + /// + /// Clients MUST check that the server supports this capability, before using + /// it. + /// + /// In order to ensure data consistency of the CAS, the server MUST only add + /// blobs to the CAS after verifying their digests. In particular, servers MUST NOT + /// trust digests provided by the client. The server MAY accept a request as no-op + /// if the client-specified blob is already in CAS or if information on how to + /// construct the blob from chunks is available. If the client-specified blob is + /// not already in the CAS, the server MUST verify that the digest of the newly + /// created blob assembled from chunks matches the digest specified by the + /// client, and reject the request if they differ. Servers MAY choose to allow + /// overwriting existing chunk mappings or to store multiple chunk mappings for + /// the same blob. + /// + /// When blob splitting and splicing is used at the same time, the clients and + /// the server SHOULD agree out-of-band upon a chunking algorithm used by both + /// parties to benefit from each other's chunk data and avoid unnecessary data + /// duplication. + /// + /// Errors: + /// + /// * `NOT_FOUND`: At least one of the blob chunks is not present in the CAS. + /// * `RESOURCE_EXHAUSTED`: There is insufficient disk quota to store the + /// spliced blob. + /// * `INVALID_ARGUMENT`: The digest of the spliced blob is different from the + /// provided expected digest. + /// * `ALREADY_EXISTS`: The blob already exists in CAS and the server did not + /// extend the lifetime of the chunks specified in the request, e.g. because + /// it prefers a different chunking and extended those instead. Clients can + /// call [SplitBlob][build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob] + /// to check what chunk mapping the server is using. + async fn splice_blob( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } /// The CAS (content-addressable storage) is used to store the inputs to and /// outputs from the execution service. Each piece of content is addressed by the @@ -3876,6 +4447,104 @@ pub mod content_addressable_storage_server { }; Box::pin(fut) } + "/build.bazel.remote.execution.v2.ContentAddressableStorage/SplitBlob" => { + #[allow(non_camel_case_types)] + struct SplitBlobSvc(pub Arc); + impl< + T: ContentAddressableStorage, + > tonic::server::UnaryService + for SplitBlobSvc { + type Response = super::SplitBlobResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::split_blob( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SplitBlobSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/build.bazel.remote.execution.v2.ContentAddressableStorage/SpliceBlob" => { + #[allow(non_camel_case_types)] + struct SpliceBlobSvc(pub Arc); + impl< + T: ContentAddressableStorage, + > tonic::server::UnaryService + for SpliceBlobSvc { + type Response = super::SpliceBlobResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::splice_blob( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SpliceBlobSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( diff --git a/nativelink-service/BUILD.bazel b/nativelink-service/BUILD.bazel index 2d13c305b..0ec442857 100644 --- a/nativelink-service/BUILD.bazel +++ b/nativelink-service/BUILD.bazel @@ -34,6 +34,7 @@ rust_library( "//nativelink-util", "@crates//:axum", "@crates//:bytes", + "@crates//:fastcdc", "@crates//:futures", "@crates//:http-body-util", "@crates//:hyper-1.7.0", @@ -46,6 +47,7 @@ rust_library( "@crates//:serde_json5", "@crates//:sha2", "@crates//:tokio", + "@crates//:tokio-util", "@crates//:tonic", "@crates//:tower", "@crates//:tracing", @@ -62,11 +64,13 @@ rust_test_suite( "tests/bytestream_server_test.rs", "tests/cas_server_test.rs", "tests/execution_server_test.rs", + "tests/fastcdc_conformance_test.rs", "tests/fetch_server_test.rs", "tests/health_server_test.rs", "tests/push_server_test.rs", "tests/worker_api_server_test.rs", ], + compile_data = glob(["tests/data/**"]), proc_macro_deps = [ "//nativelink-macro", "@crates//:async-trait", @@ -83,6 +87,7 @@ rust_test_suite( "@crates//:async-lock", "@crates//:axum", "@crates//:bytes", + "@crates//:fastcdc", "@crates//:futures", "@crates//:hex", "@crates//:http-body-util", diff --git a/nativelink-service/Cargo.toml b/nativelink-service/Cargo.toml index b830a129e..6eec30bb0 100644 --- a/nativelink-service/Cargo.toml +++ b/nativelink-service/Cargo.toml @@ -17,6 +17,9 @@ nativelink-util = { path = "../nativelink-util" } axum = { version = "0.8.3", default-features = false } bytes = { version = "1.10.1", default-features = false } +fastcdc = { version = "3.2.1", default-features = false, features = [ + "tokio", +] } futures = { version = "0.3.31", default-features = false } http-body-util = { version = "0.1.3", default-features = false } hyper = { version = "1.6.0", default-features = false } @@ -43,6 +46,9 @@ tokio = { version = "1.52.2", features = [ tokio-stream = { version = "0.1.17", features = [ "fs", ], default-features = false } +tokio-util = { version = "0.7.14", features = [ + "io", +], default-features = false } tonic = { version = "0.14.0", features = [ "gzip", "router", diff --git a/nativelink-service/src/capabilities_server.rs b/nativelink-service/src/capabilities_server.rs index 6e4102033..f2b32bfd4 100644 --- a/nativelink-service/src/capabilities_server.rs +++ b/nativelink-service/src/capabilities_server.rs @@ -15,7 +15,9 @@ use std::collections::HashMap; use std::sync::Arc; -use nativelink_config::cas_server::{CapabilitiesConfig, InstanceName, WithInstanceName}; +use nativelink_config::cas_server::{ + CapabilitiesConfig, CasStoreConfig, InstanceName, WithInstanceName, +}; use nativelink_error::{Error, ResultExt}; use nativelink_proto::build::bazel::remote::execution::v2::capabilities_server::{ Capabilities, CapabilitiesServer as Server, @@ -24,7 +26,7 @@ use nativelink_proto::build::bazel::remote::execution::v2::digest_function::Valu use nativelink_proto::build::bazel::remote::execution::v2::priority_capabilities::PriorityRange; use nativelink_proto::build::bazel::remote::execution::v2::symlink_absolute_path_strategy::Value as SymlinkAbsolutePathStrategy; use nativelink_proto::build::bazel::remote::execution::v2::{ - ActionCacheUpdateCapabilities, CacheCapabilities, ExecutionCapabilities, + ActionCacheUpdateCapabilities, CacheCapabilities, ExecutionCapabilities, FastCdc2020Params, GetCapabilitiesRequest, PriorityCapabilities, ServerCapabilities, }; use nativelink_proto::build::bazel::semver::SemVer; @@ -38,13 +40,36 @@ const MAX_BATCH_TOTAL_SIZE: i64 = 64 * 1024; #[derive(Debug, Default)] pub struct CapabilitiesServer { supported_node_properties_for_instance: HashMap>, + chunking_params_for_instance: HashMap, } impl CapabilitiesServer { pub async fn new( configs: &[WithInstanceName], scheduler_map: &HashMap>, + cas_configs: &[WithInstanceName], ) -> Result { + let mut chunking_params_for_instance = HashMap::new(); + for cas_config in cas_configs { + if let Some(chunking_config) = &cas_config.experimental_chunking { + let avg_chunk_size_bytes = chunking_config + .validated_avg_chunk_size_bytes() + .err_tip(|| { + format!( + "In 'experimental_chunking' of instance '{}'", + cas_config.instance_name + ) + })?; + chunking_params_for_instance.insert( + cas_config.instance_name.clone(), + FastCdc2020Params { + avg_chunk_size_bytes, + seed: 0, + }, + ); + } + } + let mut supported_node_properties_for_instance = HashMap::new(); for config in configs { let mut properties = Vec::new(); @@ -75,6 +100,7 @@ impl CapabilitiesServer { } Ok(Self { supported_node_properties_for_instance, + chunking_params_for_instance, }) } @@ -119,6 +145,7 @@ impl Capabilities for CapabilitiesServer { ], }); + let chunking_params = self.chunking_params_for_instance.get(&instance_name); let resp = ServerCapabilities { cache_capabilities: Some(CacheCapabilities { digest_functions: vec![ @@ -133,6 +160,11 @@ impl Capabilities for CapabilitiesServer { symlink_absolute_path_strategy: SymlinkAbsolutePathStrategy::Disallowed.into(), supported_compressors: vec![], supported_batch_update_compressors: vec![], + max_cas_blob_size_bytes: 0, + split_blob_support: chunking_params.is_some(), + splice_blob_support: chunking_params.is_some(), + fast_cdc_2020_params: chunking_params.copied(), + rep_max_cdc_params: None, }), execution_capabilities, deprecated_api_version: None, diff --git a/nativelink-service/src/cas_server.rs b/nativelink-service/src/cas_server.rs index 68e146686..61f481dce 100644 --- a/nativelink-service/src/cas_server.rs +++ b/nativelink-service/src/cas_server.rs @@ -13,38 +13,157 @@ // limitations under the License. use core::convert::Into; -use core::pin::Pin; +use core::pin::{Pin, pin}; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use core::time::Duration; use std::collections::{HashMap, VecDeque}; use bytes::Bytes; +use fastcdc::v2020::{AsyncStreamCDC, Normalization}; use futures::stream::{FuturesUnordered, Stream}; use futures::{StreamExt, TryStreamExt}; use nativelink_config::cas_server::{CasStoreConfig, WithInstanceName}; use nativelink_error::{Code, Error, ResultExt, error_if, make_err, make_input_err}; +use nativelink_metric::{ + MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, group, publish, +}; use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_storage_server::{ ContentAddressableStorage, ContentAddressableStorageServer as Server, }; use nativelink_proto::build::bazel::remote::execution::v2::{ BatchReadBlobsRequest, BatchReadBlobsResponse, BatchUpdateBlobsRequest, - BatchUpdateBlobsResponse, Directory, FindMissingBlobsRequest, FindMissingBlobsResponse, - GetTreeRequest, GetTreeResponse, batch_read_blobs_response, batch_update_blobs_response, + BatchUpdateBlobsResponse, Digest, Directory, FindMissingBlobsRequest, FindMissingBlobsResponse, + GetTreeRequest, GetTreeResponse, SpliceBlobRequest, SpliceBlobResponse, SplitBlobRequest, + SplitBlobResponse, batch_read_blobs_response, batch_update_blobs_response, chunking_function, compressor, }; use nativelink_proto::google::rpc::Status as GrpcStatus; use nativelink_store::ac_utils::get_and_decode_digest; use nativelink_store::grpc_store::GrpcStore; use nativelink_store::store_manager::StoreManager; +use nativelink_util::buf_channel::make_buf_channel_pair; use nativelink_util::common::DigestInfo; -use nativelink_util::digest_hasher::make_ctx_for_hash_func; -use nativelink_util::store_trait::{Store, StoreLike}; +use nativelink_util::digest_hasher::{ + DigestHasher, digest_hasher_func_from_context, make_ctx_for_hash_func, +}; +use nativelink_util::store_trait::{Store, StoreLike, UploadSizeInfo}; use opentelemetry::context::FutureExt; +use prost::Message; +use tokio_util::io::StreamReader; use tonic::{Request, Response, Status}; use tracing::{Instrument, Level, debug, error_span, instrument}; +/// Metrics for the experimental `SplitBlob`/`SpliceBlob` chunking RPCs. +/// The split hit rate (`split_hits` / `split_requests_total`) indicates how +/// often chunked downloads could be served; the spliced/split byte totals +/// bound the transfer volume flowing through the chunked paths. +#[derive(Debug, Default)] +pub struct ChunkingMetrics { + /// Total `SpliceBlob` requests received on chunking-enabled instances. + pub splice_requests_total: AtomicU64, + /// `SpliceBlob` requests that were no-ops because the blob and its chunk + /// layout were already registered. + pub splice_already_exists: AtomicU64, + /// `SpliceBlob` requests rejected because the re-assembled blob did not + /// match the expected digest or size. + pub splice_verification_failures: AtomicU64, + /// Total bytes of blobs successfully re-assembled by `SpliceBlob`. + pub splice_bytes_total: AtomicU64, + /// Total `SplitBlob` requests received on chunking-enabled instances. + pub split_requests_total: AtomicU64, + /// `SplitBlob` requests served from a stored chunk layout. + pub split_hits: AtomicU64, + /// `SplitBlob` requests that could not be served because the blob was + /// not present in the CAS. + pub split_misses: AtomicU64, + /// `SplitBlob` requests served by chunking the blob on demand because + /// no stored layout was available (or its chunks were evicted). + pub split_chunked_on_demand: AtomicU64, + /// Total bytes of blobs served as chunk layouts by `SplitBlob`. + pub split_bytes_total: AtomicU64, +} + +impl MetricsComponent for ChunkingMetrics { + fn publish( + &self, + _kind: MetricKind, + field_metadata: MetricFieldData, + ) -> Result { + let _enter = group!(field_metadata.name).entered(); + + publish!( + "splice_requests_total", + &self.splice_requests_total, + MetricKind::Counter, + "Total SpliceBlob requests received" + ); + publish!( + "splice_already_exists", + &self.splice_already_exists, + MetricKind::Counter, + "SpliceBlob requests that were no-ops because blob and layout already existed" + ); + publish!( + "splice_verification_failures", + &self.splice_verification_failures, + MetricKind::Counter, + "SpliceBlob requests rejected due to digest or size mismatch" + ); + publish!( + "splice_bytes_total", + &self.splice_bytes_total, + MetricKind::Counter, + "Total bytes of blobs re-assembled by SpliceBlob" + ); + publish!( + "split_requests_total", + &self.split_requests_total, + MetricKind::Counter, + "Total SplitBlob requests received" + ); + publish!( + "split_hits", + &self.split_hits, + MetricKind::Counter, + "SplitBlob requests served from a stored chunk layout" + ); + publish!( + "split_misses", + &self.split_misses, + MetricKind::Counter, + "SplitBlob requests where the blob was not present" + ); + publish!( + "split_chunked_on_demand", + &self.split_chunked_on_demand, + MetricKind::Counter, + "SplitBlob requests served by chunking the blob on demand" + ); + publish!( + "split_bytes_total", + &self.split_bytes_total, + MetricKind::Counter, + "Total bytes of blobs served as chunk layouts by SplitBlob" + ); + + Ok(MetricPublishKnownKindData::Component) + } +} + +/// Per-instance state for the experimental chunking RPCs. +#[derive(Debug, Clone)] +struct ChunkingInstance { + /// Store holding blob-digest -> chunk-layout mappings. + index_store: Store, + /// Average chunk size used for server-side `FastCDC` 2020 chunking. + avg_chunk_size_bytes: u32, +} + #[derive(Debug)] pub struct CasServer { stores: HashMap, + chunking_instances: HashMap, + chunking_metrics: ChunkingMetrics, } type GetTreeStream = Pin> + Send + 'static>>; @@ -52,25 +171,102 @@ type GetTreeStream = Pin> /// Per-blob deadline applied inside `BatchReadBlobs` / `BatchUpdateBlobs`. const BATCH_PER_BLOB_TIMEOUT: Duration = Duration::from_secs(30); +/// Maximum size of a single chunk accepted in a `SpliceBlob` request. +/// Deliberately looser than the largest chunk the server ever advertises +/// (4x the maximum allowed average = 4 MiB) so clients using their own +/// chunking function are still accepted. Together with `CHUNK_CONCURRENCY` +/// this bounds the memory a single splice request can pin. +const MAX_SPLICE_CHUNK_SIZE: u64 = 16 * 1024 * 1024; + +/// Maximum number of chunks accepted in a `SpliceBlob` request or produced +/// by on-demand chunking in `SplitBlob`. Bounds the size of stored chunk +/// layouts and `SplitBlobResponse` messages (roughly 80 bytes per chunk, +/// ~4 MiB at the cap) independently of how small a client's chunks are. +const MAX_CHUNK_COUNT: usize = 50_000; + +/// Maximum serialized chunk layout size read back from the index store. +/// Layouts written by this server are bounded by `MAX_CHUNK_COUNT`, so a +/// larger entry is corrupt. A truncated read is detected (and treated as no +/// layout) by the size consistency check in `read_chunk_layout`. +const MAX_CHUNK_LAYOUT_SIZE: u64 = 16 * 1024 * 1024; + +/// Number of chunk reads/writes kept in flight while re-assembling or +/// chunking a blob. Matches the `DedupStore` concurrency default. +const CHUNK_CONCURRENCY: usize = 10; + impl CasServer { pub fn new( configs: &[WithInstanceName], store_manager: &StoreManager, ) -> Result { let mut stores = HashMap::with_capacity(configs.len()); + let mut chunking_instances = HashMap::new(); for config in configs { let store = store_manager.get_store(&config.cas_store).ok_or_else(|| { make_input_err!("'cas_store': '{}' does not exist", config.cas_store) })?; + if let Some(chunking_config) = &config.experimental_chunking { + // Chunk layouts are stored under the digests of the blobs + // they describe but do not hash to them, so writing them + // into the CAS itself would overwrite blob content. + error_if!( + chunking_config.index_store == config.cas_store, + "'experimental_chunking.index_store' of instance '{}' must not be the same store as 'cas_store'", + config.instance_name + ); + // Chunking against a grpc proxy store would download and + // re-upload entire blobs through the proxy instead of + // forwarding the RPCs; reject it until native forwarding is + // implemented. + error_if!( + store.downcast_ref::(None).is_some(), + "'experimental_chunking' of instance '{}' is not supported when 'cas_store' is a grpc store", + config.instance_name + ); + let index_store = store_manager + .get_store(&chunking_config.index_store) + .ok_or_else(|| { + make_input_err!( + "'experimental_chunking.index_store': '{}' does not exist", + chunking_config.index_store + ) + })?; + let avg_chunk_size_bytes = chunking_config + .validated_avg_chunk_size_bytes() + .err_tip(|| { + format!( + "In 'experimental_chunking' of instance '{}'", + config.instance_name + ) + })?; + let avg_chunk_size_bytes = u32::try_from(avg_chunk_size_bytes) + .err_tip(|| "avg_chunk_size_bytes did not fit in u32")?; + chunking_instances.insert( + config.instance_name.clone(), + ChunkingInstance { + index_store, + avg_chunk_size_bytes, + }, + ); + } stores.insert(config.instance_name.clone(), store); } - Ok(Self { stores }) + Ok(Self { + stores, + chunking_instances, + chunking_metrics: ChunkingMetrics::default(), + }) } pub fn into_service(self) -> Server { Server::new(self) } + /// Metrics for the experimental `SplitBlob`/`SpliceBlob` RPCs. + pub const fn chunking_metrics(&self) -> &ChunkingMetrics { + &self.chunking_metrics + } + async fn inner_find_missing_blobs( &self, request: FindMissingBlobsRequest, @@ -330,6 +526,415 @@ impl CasServer { }) .right_stream()) } + + /// Returns the CAS store and chunking state for an instance, or + /// `Unimplemented` when chunking is not enabled for it. + fn chunking_instance(&self, instance_name: &str) -> Result<(Store, ChunkingInstance), Error> { + let store = self + .stores + .get(instance_name) + .err_tip(|| format!("'instance_name' not configured for '{instance_name}'"))? + .clone(); + let chunking_instance = self + .chunking_instances + .get(instance_name) + .ok_or_else(|| { + make_err!( + Code::Unimplemented, + "Blob chunking is not enabled for instance '{instance_name}'" + ) + })? + .clone(); + Ok((store, chunking_instance)) + } + + /// Returns the display names of the chunks missing from the CAS. The + /// existence check also touches present chunks, which extends their + /// lifetimes on a best-effort basis (stores that answer existence from a + /// cache may not promote the underlying entries). + async fn missing_chunks(store: &Store, chunk_digests: &[Digest]) -> Result, Error> { + let mut digest_infos = Vec::with_capacity(chunk_digests.len()); + for digest in chunk_digests { + digest_infos + .push(DigestInfo::try_from(digest.clone()).err_tip(|| "Invalid chunk digest")?); + } + let chunk_keys: Vec<_> = digest_infos.iter().map(|digest| (*digest).into()).collect(); + let sizes = store + .has_many(&chunk_keys) + .await + .err_tip(|| "In missing_chunks")?; + Ok(sizes + .iter() + .zip(&digest_infos) + .filter(|(maybe_size, _)| maybe_size.is_none()) + .map(|(_, digest)| digest.to_string()) + .collect()) + } + + /// Reads the chunk layout registered for a blob. Returns `None` when no + /// usable layout exists: not registered, undecodable, or inconsistent + /// with the blob size (which also rejects entries truncated by the read + /// cap below). + async fn read_chunk_layout( + index_store: &Store, + blob_digest: DigestInfo, + ) -> Option { + let layout_bytes = index_store + .get_part_unchunked(blob_digest, 0, Some(MAX_CHUNK_LAYOUT_SIZE)) + .await + .ok()?; + let layout = SplitBlobResponse::decode(layout_bytes).ok()?; + // A usable layout must reproduce the blob exactly, so the chunk + // sizes have to add up to the blob size. + let mut total_size: u64 = 0; + for digest in &layout.chunk_digests { + total_size = total_size.checked_add(u64::try_from(digest.size_bytes).ok()?)?; + } + (total_size == blob_digest.size_bytes()).then_some(layout) + } + + /// Writes the chunk layout for a blob to the index store. This is the + /// write side of the format `read_chunk_layout` expects. + async fn write_chunk_layout( + index_store: &Store, + blob_digest: DigestInfo, + layout: &SplitBlobResponse, + ) -> Result<(), Error> { + index_store + .update_oneshot(blob_digest, layout.encode_to_vec().into()) + .await + .err_tip(|| "Failed to write chunk layout to index store") + } + + async fn inner_split_blob( + &self, + request: SplitBlobRequest, + ) -> Result, Error> { + let (store, chunking_instance) = self.chunking_instance(&request.instance_name)?; + self.chunking_metrics + .split_requests_total + .fetch_add(1, Ordering::Relaxed); + + let blob_digest: DigestInfo = request + .blob_digest + .err_tip(|| "Expected blob_digest to exist in SplitBlobRequest")? + .try_into() + .err_tip(|| "In SplitBlobRequest::blob_digest")?; + + // The existence check also touches the blob, extending its lifetime + // (best effort) as suggested by the REAPI spec for SplitBlob. + let (blob_exists, maybe_layout) = futures::join!( + store.has(blob_digest), + Self::read_chunk_layout(&chunking_instance.index_store, blob_digest), + ); + if blob_exists.err_tip(|| "In split_blob")?.is_none() { + self.chunking_metrics + .split_misses + .fetch_add(1, Ordering::Relaxed); + return Err(make_err!( + Code::NotFound, + "Blob {blob_digest} not present in the CAS in split_blob" + )); + } + + // Serve the registered layout if it is still fully backed by chunks + // in the CAS. Any problem with it (missing, corrupt, evicted chunks, + // or a transient chunk existence-check failure) falls back to + // re-chunking the blob below. + if let Some(layout) = maybe_layout + && matches!( + Self::missing_chunks(&store, &layout.chunk_digests).await, + Ok(missing) if missing.is_empty() + ) + { + self.chunking_metrics + .split_hits + .fetch_add(1, Ordering::Relaxed); + self.chunking_metrics + .split_bytes_total + .fetch_add(blob_digest.size_bytes(), Ordering::Relaxed); + return Ok(Response::new(layout)); + } + + // No usable layout: chunk the blob on demand with FastCDC 2020, + // store the chunks and the layout, and serve the result. This is the + // path taken for blobs that were uploaded whole (e.g. outputs + // produced by remote execution workers). + let split_response = self + .chunk_blob_on_demand(&store, &chunking_instance, blob_digest) + .await?; + self.chunking_metrics + .split_chunked_on_demand + .fetch_add(1, Ordering::Relaxed); + self.chunking_metrics + .split_bytes_total + .fetch_add(blob_digest.size_bytes(), Ordering::Relaxed); + Ok(Response::new(split_response)) + } + + /// Chunks the blob with `FastCDC` 2020 (normalization level 2, parameters + /// derived from the configured average chunk size per the REAPI spec), + /// uploads any missing chunks to the CAS, registers the layout in the + /// index store, and returns it. + async fn chunk_blob_on_demand( + &self, + store: &Store, + chunking_instance: &ChunkingInstance, + blob_digest: DigestInfo, + ) -> Result { + let avg_size = chunking_instance.avg_chunk_size_bytes; + let (min_size, max_size) = (avg_size / 4, avg_size * 4); + let hasher_func = digest_hasher_func_from_context(); + + let (tx, rx) = make_buf_channel_pair(); + let read_store = store.clone(); + // `tx` is moved into the future so that when the read finishes or + // fails it is dropped, which terminates the chunking stream. + let read_fut = async move { + let mut tx = tx; + read_store + .get_part(blob_digest, &mut tx, 0, None) + .await + .err_tip(|| format!("Failed to read blob {blob_digest} in chunk_blob_on_demand")) + }; + // `rx` is owned by this future so an early error return drops it, + // which aborts the in-flight read instead of leaving it blocked. + let chunk_fut = async move { + let mut bytes_reader = StreamReader::new(rx); + let mut cdc = AsyncStreamCDC::with_level( + &mut bytes_reader, + min_size, + avg_size, + max_size, + Normalization::Level2, + ); + // Chunks are hashed and stored CHUNK_CONCURRENCY at a time while + // the blob keeps streaming; `buffered` preserves chunk order. + let chunk_digests: Vec = pin!(cdc.as_stream()) + .map(|chunk_result| async { + let chunk = chunk_result + .map_err(|e| make_err!(Code::Internal, "Failed to chunk blob: {e:?}")) + .err_tip(|| "In chunk_blob_on_demand")?; + let mut hasher = hasher_func.hasher(); + hasher.update(&chunk.data); + let chunk_digest = hasher.finalize_digest(); + // The existence check also touches pre-existing chunks, + // extending their lifetimes (best effort). FastCDC is + // deterministic, so repeated splits of similar blobs + // mostly find their chunks present. + if store + .has(chunk_digest) + .await + .err_tip(|| "In chunk_blob_on_demand")? + .is_none() + { + store + .update_oneshot(chunk_digest, chunk.data.into()) + .await + .err_tip(|| { + format!( + "Failed to store chunk {chunk_digest} in chunk_blob_on_demand" + ) + })?; + } + Ok::(chunk_digest.into()) + }) + .buffered(CHUNK_CONCURRENCY) + .try_collect() + .await?; + Ok::, Error>(chunk_digests) + }; + let (read_res, chunk_res) = futures::join!(read_fut, chunk_fut); + // Prefer the read error (the chunker error is usually a consequence + // of it); merge keeps both messages when both fail. + let chunk_digests = read_res + .merge(chunk_res) + .err_tip(|| "Failed to chunk blob in chunk_blob_on_demand")?; + if chunk_digests.len() > MAX_CHUNK_COUNT { + return Err(make_err!( + Code::NotFound, + "Blob {blob_digest} produced {} chunks, exceeding the supported maximum of {MAX_CHUNK_COUNT}; no split information available", + chunk_digests.len() + )); + } + + let split_response = SplitBlobResponse { + chunk_digests, + chunking_function: chunking_function::Value::FastCdc2020.into(), + }; + Self::write_chunk_layout(&chunking_instance.index_store, blob_digest, &split_response) + .await?; + Ok(split_response) + } + + async fn inner_splice_blob( + &self, + request: SpliceBlobRequest, + ) -> Result, Error> { + let (store, chunking_instance) = self.chunking_instance(&request.instance_name)?; + let index_store = chunking_instance.index_store; + self.chunking_metrics + .splice_requests_total + .fetch_add(1, Ordering::Relaxed); + + let blob_digest: DigestInfo = request + .blob_digest + .err_tip(|| "Expected blob_digest to exist in SpliceBlobRequest")? + .try_into() + .err_tip(|| "In SpliceBlobRequest::blob_digest")?; + + error_if!( + request.chunk_digests.is_empty(), + "chunk_digests must not be empty in splice_blob" + ); + error_if!( + request.chunk_digests.len() > MAX_CHUNK_COUNT, + "Request has {} chunk_digests, expected at most {MAX_CHUNK_COUNT} in splice_blob", + request.chunk_digests.len() + ); + let mut chunk_digests = Vec::with_capacity(request.chunk_digests.len()); + let mut total_size: u64 = 0; + for digest in &request.chunk_digests { + let digest_info = DigestInfo::try_from(digest.clone()) + .err_tip(|| "In SpliceBlobRequest::chunk_digests")?; + error_if!( + digest_info.size_bytes() == 0 || digest_info.size_bytes() > MAX_SPLICE_CHUNK_SIZE, + "Chunk {digest_info} has invalid size, expected to be in range (0, {MAX_SPLICE_CHUNK_SIZE}] in splice_blob" + ); + total_size += digest_info.size_bytes(); + chunk_digests.push(digest_info); + } + if total_size != blob_digest.size_bytes() { + self.chunking_metrics + .splice_verification_failures + .fetch_add(1, Ordering::Relaxed); + return Err(make_err!( + Code::InvalidArgument, + "Sum of chunk sizes ({total_size}) does not match the expected blob size ({}) in splice_blob", + blob_digest.size_bytes() + )); + } + + // One round of existence checks: the chunks (which also touches + // them, best-effort extending their lifetimes), the blob, and the + // registered layout. + let (missing_chunks, blob_exists, layout_exists) = futures::join!( + Self::missing_chunks(&store, &request.chunk_digests), + store.has(blob_digest), + index_store.has(blob_digest), + ); + let missing_chunks = missing_chunks.err_tip(|| "In splice_blob")?; + if !missing_chunks.is_empty() { + return Err(make_err!( + Code::NotFound, + "Chunk(s) [{}] not present in the CAS in splice_blob", + missing_chunks.join(", ") + )); + } + // Fast path: if the blob and its chunk layout are already registered + // this request is a no-op. + if blob_exists.err_tip(|| "In splice_blob")?.is_some() + && layout_exists.err_tip(|| "In splice_blob")?.is_some() + { + self.chunking_metrics + .splice_already_exists + .fetch_add(1, Ordering::Relaxed); + return Ok(Response::new(SpliceBlobResponse { + blob_digest: Some(blob_digest.into()), + })); + } + + // Re-assemble the blob into the store: chunk reads are pipelined + // CHUNK_CONCURRENCY at a time while hashing and channel writes stay + // in chunk order. The digest is verified before the final EOF is + // sent, so a digest mismatch aborts the upload before the store + // commits it. + let hasher_func = digest_hasher_func_from_context(); + let verification_failed = AtomicBool::new(false); + let verification_failed_ref = &verification_failed; + let (tx, rx) = make_buf_channel_pair(); + let send_store = store.clone(); + // `tx` is moved into the future so that an early error return drops + // it without an EOF, which aborts the in-flight store update instead + // of leaving it waiting for more data. + let send_fut = async move { + let mut tx = tx; + let mut hasher = hasher_func.hasher(); + let mut fetch_stream = futures::stream::iter(chunk_digests.into_iter().map( + move |chunk_digest| { + let store = send_store.clone(); + async move { + let data = store + .get_part_unchunked(chunk_digest, 0, None) + .await + .err_tip(|| { + format!("Failed to read chunk {chunk_digest} in splice_blob") + })?; + if u64::try_from(data.len()).unwrap_or(0) != chunk_digest.size_bytes() { + return Err(make_err!( + Code::Internal, + "Chunk {chunk_digest} content has length {}, expected {}, in splice_blob", + data.len(), + chunk_digest.size_bytes() + )); + } + Ok::(data) + } + }, + )) + .buffered(CHUNK_CONCURRENCY); + while let Some(data) = fetch_stream.next().await { + let data = data?; + hasher.update(&data); + tx.send(data) + .await + .err_tip(|| "Failed to send chunk data in splice_blob")?; + } + drop(fetch_stream); + let computed_digest = hasher.finalize_digest(); + if computed_digest != blob_digest { + verification_failed_ref.store(true, Ordering::Relaxed); + return Err(make_err!( + Code::InvalidArgument, + "Digest of spliced blob ({computed_digest}) does not match the expected digest ({blob_digest}) in splice_blob" + )); + } + tx.send_eof() + .err_tip(|| "Failed to send EOF in splice_blob")?; + Ok::<(), Error>(()) + }; + let update_fut = store.update( + blob_digest, + rx, + UploadSizeInfo::ExactSize(blob_digest.size_bytes()), + ); + let (send_res, update_res) = futures::join!(send_fut, update_fut); + if verification_failed.load(Ordering::Relaxed) { + self.chunking_metrics + .splice_verification_failures + .fetch_add(1, Ordering::Relaxed); + } + // Prefer the sender error: it carries the reason the upload was + // aborted (e.g. the digest mismatch), the store error is usually a + // consequence; merge keeps both messages when both fail. + send_res + .merge(update_res) + .err_tip(|| "Failed to write spliced blob to store in splice_blob")?; + + // Persist the chunk layout so SplitBlob can serve it later. + let split_response = SplitBlobResponse { + chunk_digests: request.chunk_digests, + chunking_function: request.chunking_function, + }; + Self::write_chunk_layout(&index_store, blob_digest, &split_response).await?; + + self.chunking_metrics + .splice_bytes_total + .fetch_add(blob_digest.size_bytes(), Ordering::Relaxed); + Ok(Response::new(SpliceBlobResponse { + blob_digest: Some(blob_digest.into()), + })) + } } #[tonic::async_trait] @@ -443,4 +1048,59 @@ impl ContentAddressableStorage for CasServer { } resp } + + #[instrument( + err, + ret(level = Level::DEBUG), + level = Level::ERROR, + skip_all, + fields( + request.instance_name = ?grpc_request.get_ref().instance_name, + request.blob_digest = ?grpc_request.get_ref().blob_digest, + request.digest_function = ?grpc_request.get_ref().digest_function, + ) + )] + async fn split_blob( + &self, + grpc_request: Request, + ) -> Result, Status> { + let request = grpc_request.into_inner(); + let digest_function = request.digest_function; + self.inner_split_blob(request) + .instrument(error_span!("cas_server_split_blob")) + .with_context( + make_ctx_for_hash_func(digest_function).err_tip(|| "In CasServer::split_blob")?, + ) + .await + .err_tip(|| "Failed on split_blob() command") + .map_err(Into::into) + } + + #[instrument( + err, + ret(level = Level::DEBUG), + level = Level::ERROR, + skip_all, + fields( + // Skip request.chunk_digests which is sometimes enormous. + request.instance_name = ?grpc_request.get_ref().instance_name, + request.blob_digest = ?grpc_request.get_ref().blob_digest, + request.digest_function = ?grpc_request.get_ref().digest_function, + ) + )] + async fn splice_blob( + &self, + grpc_request: Request, + ) -> Result, Status> { + let request = grpc_request.into_inner(); + let digest_function = request.digest_function; + self.inner_splice_blob(request) + .instrument(error_span!("cas_server_splice_blob")) + .with_context( + make_ctx_for_hash_func(digest_function).err_tip(|| "In CasServer::splice_blob")?, + ) + .await + .err_tip(|| "Failed on splice_blob() command") + .map_err(Into::into) + } } diff --git a/nativelink-service/tests/cas_server_test.rs b/nativelink-service/tests/cas_server_test.rs index dccc90208..73c93319f 100644 --- a/nativelink-service/tests/cas_server_test.rs +++ b/nativelink-service/tests/cas_server_test.rs @@ -13,6 +13,7 @@ // limitations under the License. use core::pin::Pin; +use core::sync::atomic::Ordering; use core::time::Duration; use std::sync::Arc; @@ -27,8 +28,9 @@ use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_s use nativelink_proto::build::bazel::remote::execution::v2::{ BatchReadBlobsRequest, BatchReadBlobsResponse, BatchUpdateBlobsRequest, BatchUpdateBlobsResponse, Digest, Directory, DirectoryNode, FindMissingBlobsRequest, - GetTreeRequest, GetTreeResponse, NodeProperties, batch_read_blobs_response, - batch_update_blobs_request, batch_update_blobs_response, compressor, digest_function, + GetTreeRequest, GetTreeResponse, NodeProperties, SpliceBlobRequest, SplitBlobRequest, + SplitBlobResponse, batch_read_blobs_response, batch_update_blobs_request, + batch_update_blobs_response, chunking_function, compressor, digest_function, }; use nativelink_proto::google::rpc::Status as GrpcStatus; use nativelink_service::cas_server::CasServer; @@ -37,12 +39,13 @@ use nativelink_store::default_store_factory::store_factory; use nativelink_store::store_manager::StoreManager; use nativelink_util::buf_channel::{DropCloserReadHalf, DropCloserWriteHalf}; use nativelink_util::common::DigestInfo; -use nativelink_util::digest_hasher::DigestHasherFunc; +use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc}; use nativelink_util::health_utils::{HealthStatusIndicator, default_health_status_indicator}; use nativelink_util::store_trait::{ RemoveItemCallback, Store, StoreDriver, StoreKey, StoreLike, UploadSizeInfo, }; use pretty_assertions::assert_eq; +use prost::Message; use prost_types::Timestamp; use tonic::{Code, Request}; @@ -72,6 +75,7 @@ fn make_cas_server(store_manager: &StoreManager) -> Result { instance_name: "foo_instance_name".to_string(), config: nativelink_config::cas_server::CasStoreConfig { cas_store: "main_cas".to_string(), + experimental_chunking: None, }, }], store_manager, @@ -747,6 +751,7 @@ fn make_cas_server_with_stall_store(delay: Duration) -> Result instance_name: INSTANCE_NAME.to_string(), config: nativelink_config::cas_server::CasStoreConfig { cas_store: "main_cas".to_string(), + experimental_chunking: None, }, }], &store_manager, @@ -835,3 +840,505 @@ async fn batch_read_blobs_per_blob_timeout_returns_deadline_exceeded() ); Ok(()) } + +const CHUNK1_VALUE: &str = "hello "; +const CHUNK2_VALUE: &str = "world"; + +async fn make_chunking_store_manager() -> Result, Error> { + let store_manager = make_store_manager().await?; + store_manager.add_store( + "chunk_index", + store_factory( + &StoreSpec::Memory(MemorySpec::default()), + &store_manager, + None, + ) + .await?, + ); + Ok(store_manager) +} + +fn make_chunking_cas_server(store_manager: &StoreManager) -> Result { + make_chunking_cas_server_with_avg(store_manager, 0) +} + +fn make_chunking_cas_server_with_avg( + store_manager: &StoreManager, + avg_chunk_size_bytes: u64, +) -> Result { + CasServer::new( + &[WithInstanceName { + instance_name: INSTANCE_NAME.to_string(), + config: nativelink_config::cas_server::CasStoreConfig { + cas_store: "main_cas".to_string(), + experimental_chunking: Some(nativelink_config::cas_server::CasChunkingConfig { + index_store: "chunk_index".to_string(), + avg_chunk_size_bytes, + }), + }, + }], + store_manager, + ) +} + +/// Uploads the two test chunks to the store and returns their digests and +/// the digest of their concatenation. +async fn upload_test_chunks(store: &Store) -> Result<(Digest, Digest, Digest), Error> { + let chunk1_digest = Digest { + hash: HASH1.to_string(), + size_bytes: CHUNK1_VALUE.len() as i64, + }; + let chunk2_digest = Digest { + hash: HASH2.to_string(), + size_bytes: CHUNK2_VALUE.len() as i64, + }; + store + .update_oneshot( + DigestInfo::try_from(chunk1_digest.clone())?, + CHUNK1_VALUE.into(), + ) + .await?; + store + .update_oneshot( + DigestInfo::try_from(chunk2_digest.clone())?, + CHUNK2_VALUE.into(), + ) + .await?; + let mut hasher = DigestHasherFunc::Sha256.hasher(); + hasher.update(CHUNK1_VALUE.as_bytes()); + hasher.update(CHUNK2_VALUE.as_bytes()); + let blob_digest: Digest = hasher.finalize_digest().into(); + Ok((chunk1_digest, chunk2_digest, blob_digest)) +} + +#[nativelink_test] +async fn splice_and_split_round_trip() -> Result<(), Box> { + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + + let (chunk1_digest, chunk2_digest, blob_digest) = upload_test_chunks(&store).await?; + + let splice_response = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest.clone()), + chunk_digests: vec![chunk1_digest.clone(), chunk2_digest.clone()], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(splice_response.blob_digest.as_ref(), Some(&blob_digest)); + + // The spliced blob must be materialized in the CAS so non-chunking + // clients can read it. + let blob_data = store + .get_part_unchunked(DigestInfo::try_from(blob_digest.clone())?, 0, None) + .await?; + assert_eq!(blob_data, format!("{CHUNK1_VALUE}{CHUNK2_VALUE}")); + + let split_response = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!( + split_response.chunk_digests, + vec![chunk1_digest, chunk2_digest] + ); + assert_eq!( + split_response.chunking_function, + i32::from(chunking_function::Value::FastCdc2020) + ); + + let metrics = cas_server.chunking_metrics(); + assert_eq!(metrics.splice_requests_total.load(Ordering::Relaxed), 1); + assert_eq!( + metrics.splice_bytes_total.load(Ordering::Relaxed), + (CHUNK1_VALUE.len() + CHUNK2_VALUE.len()) as u64 + ); + assert_eq!(metrics.split_requests_total.load(Ordering::Relaxed), 1); + assert_eq!(metrics.split_hits.load(Ordering::Relaxed), 1); + assert_eq!(metrics.split_misses.load(Ordering::Relaxed), 0); + Ok(()) +} + +#[nativelink_test] +async fn splice_blob_rejects_digest_mismatch() -> Result<(), Box> { + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + + let (chunk1_digest, chunk2_digest, _blob_digest) = upload_test_chunks(&store).await?; + let total_size = chunk1_digest.size_bytes + chunk2_digest.size_bytes; + let wrong_blob_digest = Digest { + hash: HASH3.to_string(), + size_bytes: total_size, + }; + + let status = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(wrong_blob_digest.clone()), + chunk_digests: vec![chunk1_digest, chunk2_digest], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert!( + status + .message() + .contains("does not match the expected digest"), + "unexpected message: {}", + status.message() + ); + + // The blob must not have been committed to the CAS. + let blob_exists = store.has(DigestInfo::try_from(wrong_blob_digest)?).await?; + assert_eq!(blob_exists, None); + assert_eq!( + cas_server + .chunking_metrics() + .splice_verification_failures + .load(Ordering::Relaxed), + 1 + ); + Ok(()) +} + +#[nativelink_test] +async fn splice_blob_rejects_size_mismatch() -> Result<(), Box> { + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + + let (chunk1_digest, chunk2_digest, blob_digest) = upload_test_chunks(&store).await?; + let wrong_blob_digest = Digest { + size_bytes: blob_digest.size_bytes + 1, + ..blob_digest + }; + + let status = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(wrong_blob_digest), + chunk_digests: vec![chunk1_digest, chunk2_digest], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert!( + status + .message() + .contains("does not match the expected blob size"), + "unexpected message: {}", + status.message() + ); + Ok(()) +} + +#[nativelink_test] +async fn splice_blob_missing_chunk_returns_not_found() -> Result<(), Box> { + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + + // Only upload the first chunk. + let chunk1_digest = Digest { + hash: HASH1.to_string(), + size_bytes: CHUNK1_VALUE.len() as i64, + }; + store + .update_oneshot( + DigestInfo::try_from(chunk1_digest.clone())?, + CHUNK1_VALUE.into(), + ) + .await?; + let missing_chunk_digest = Digest { + hash: HASH2.to_string(), + size_bytes: CHUNK2_VALUE.len() as i64, + }; + let mut hasher = DigestHasherFunc::Sha256.hasher(); + hasher.update(CHUNK1_VALUE.as_bytes()); + hasher.update(CHUNK2_VALUE.as_bytes()); + let blob_digest: Digest = hasher.finalize_digest().into(); + + let status = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest), + chunk_digests: vec![chunk1_digest, missing_chunk_digest], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(status.code(), Code::NotFound); + Ok(()) +} + +#[nativelink_test] +async fn split_blob_absent_blob_returns_not_found() -> Result<(), Box> { + const VALUE: &str = "1"; + + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + + // The blob was never uploaded. + let status = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(Digest { + hash: HASH1.to_string(), + size_bytes: VALUE.len() as i64, + }), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(status.code(), Code::NotFound); + let metrics = cas_server.chunking_metrics(); + assert_eq!(metrics.split_requests_total.load(Ordering::Relaxed), 1); + assert_eq!(metrics.split_hits.load(Ordering::Relaxed), 0); + assert_eq!(metrics.split_misses.load(Ordering::Relaxed), 1); + Ok(()) +} + +#[nativelink_test] +async fn split_and_splice_disabled_return_unimplemented() -> Result<(), Box> +{ + const VALUE: &str = "1"; + + let store_manager = make_store_manager().await?; + let cas_server = make_cas_server(&store_manager)?; + + let digest = Digest { + hash: HASH1.to_string(), + size_bytes: VALUE.len() as i64, + }; + let split_status = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(digest.clone()), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(split_status.code(), Code::Unimplemented); + + let splice_status = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(digest.clone()), + chunk_digests: vec![digest], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(splice_status.code(), Code::Unimplemented); + Ok(()) +} + +#[nativelink_test] +async fn split_blob_chunks_small_blob_on_demand() -> Result<(), Box> { + const VALUE: &str = "1"; + + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + + // Upload the blob whole (as a remote execution worker would) under its + // real digest, without ever calling SpliceBlob. + let mut hasher = DigestHasherFunc::Sha256.hasher(); + hasher.update(VALUE.as_bytes()); + let blob_digest: Digest = hasher.finalize_digest().into(); + store + .update_oneshot(DigestInfo::try_from(blob_digest.clone())?, VALUE.into()) + .await?; + + let split_response = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest.clone()), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + // A blob smaller than the minimum chunk size is a single chunk whose + // digest equals the blob digest. + assert_eq!(split_response.chunk_digests, vec![blob_digest]); + assert_eq!( + split_response.chunking_function, + i32::from(chunking_function::Value::FastCdc2020) + ); + let metrics = cas_server.chunking_metrics(); + assert_eq!(metrics.split_chunked_on_demand.load(Ordering::Relaxed), 1); + assert_eq!(metrics.split_hits.load(Ordering::Relaxed), 0); + Ok(()) +} + +#[nativelink_test] +async fn split_blob_chunks_large_blob_on_demand_and_reuses_layout() +-> Result<(), Box> { + // Use the smallest allowed average (1 KiB -> min 256, max 4096) so a + // small test blob still produces multiple chunks. + const AVG_CHUNK_SIZE: u64 = 1024; + const BLOB_SIZE: usize = 16 * 1024; + + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server_with_avg(&store_manager, AVG_CHUNK_SIZE)?; + let store = store_manager.get_store("main_cas").unwrap(); + + // Deterministic pseudo-random content so FastCDC finds content-defined + // boundaries. + let mut state = 0x9e37_79b9_u32; + let data: Vec = (0..BLOB_SIZE) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 24) as u8 + }) + .collect(); + let blob_digest = Digest { + hash: HASH1.to_string(), + size_bytes: BLOB_SIZE as i64, + }; + store + .update_oneshot( + DigestInfo::try_from(blob_digest.clone())?, + bytes::Bytes::from(data.clone()), + ) + .await?; + + let split_response = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest.clone()), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert!( + split_response.chunk_digests.len() > 1, + "expected multiple chunks, got {}", + split_response.chunk_digests.len() + ); + + // All chunks must be stored in the CAS and concatenate to the original + // blob in order. + let mut reassembled = Vec::with_capacity(BLOB_SIZE); + for chunk_digest in &split_response.chunk_digests { + let chunk_data = store + .get_part_unchunked(DigestInfo::try_from(chunk_digest.clone())?, 0, None) + .await?; + reassembled.extend_from_slice(&chunk_data); + } + assert_eq!(reassembled, data); + + // A second split must be served from the stored layout. + let second_response = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(second_response.chunk_digests, split_response.chunk_digests); + let metrics = cas_server.chunking_metrics(); + assert_eq!(metrics.split_requests_total.load(Ordering::Relaxed), 2); + assert_eq!(metrics.split_chunked_on_demand.load(Ordering::Relaxed), 1); + assert_eq!(metrics.split_hits.load(Ordering::Relaxed), 1); + Ok(()) +} + +#[nativelink_test] +async fn split_blob_falls_back_when_layout_unusable() -> Result<(), Box> { + const VALUE: &str = "1"; + + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + let index_store = store_manager.get_store("chunk_index").unwrap(); + + let mut hasher = DigestHasherFunc::Sha256.hasher(); + hasher.update(VALUE.as_bytes()); + let blob_digest: Digest = hasher.finalize_digest().into(); + store + .update_oneshot(DigestInfo::try_from(blob_digest.clone())?, VALUE.into()) + .await?; + + // Register a layout whose only chunk is not present in the CAS, + // simulating a chunk that was evicted after the layout was stored. + let stale_layout = SplitBlobResponse { + chunk_digests: vec![Digest { + hash: HASH2.to_string(), + size_bytes: VALUE.len() as i64, + }], + chunking_function: chunking_function::Value::FastCdc2020.into(), + }; + index_store + .update_oneshot( + DigestInfo::try_from(blob_digest.clone())?, + stale_layout.encode_to_vec().into(), + ) + .await?; + + // The unusable layout must be ignored and the blob re-chunked on demand. + let split_response = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest.clone()), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(split_response.chunk_digests, vec![blob_digest]); + let metrics = cas_server.chunking_metrics(); + assert_eq!(metrics.split_hits.load(Ordering::Relaxed), 0); + assert_eq!(metrics.split_chunked_on_demand.load(Ordering::Relaxed), 1); + Ok(()) +} + +#[nativelink_test] +async fn chunking_rejects_index_store_same_as_cas_store() -> Result<(), Box> +{ + let store_manager = make_store_manager().await?; + let error = CasServer::new( + &[WithInstanceName { + instance_name: INSTANCE_NAME.to_string(), + config: nativelink_config::cas_server::CasStoreConfig { + cas_store: "main_cas".to_string(), + experimental_chunking: Some(nativelink_config::cas_server::CasChunkingConfig { + index_store: "main_cas".to_string(), + avg_chunk_size_bytes: 0, + }), + }, + }], + &store_manager, + ) + .err() + .expect("expected same-store index_store to be rejected"); + assert!( + error + .to_string() + .contains("must not be the same store as 'cas_store'"), + "unexpected error: {error}" + ); + Ok(()) +} diff --git a/nativelink-service/tests/data/SekienAkashita.jpg b/nativelink-service/tests/data/SekienAkashita.jpg new file mode 100644 index 0000000000000000000000000000000000000000..71b09702c447a34208cb3df86a0a5bb70ab4e0ad GIT binary patch literal 109466 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 +//! +//! The chunk boundaries produced by the server MUST match these vectors +//! byte-for-byte; otherwise chunks produced by clients (e.g. Bazel with +//! `--experimental_remote_cache_chunking`) never deduplicate against chunks +//! produced by the server and the feature silently loses its value. + +use fastcdc::v2020::{AsyncStreamCDC, FastCDC, Normalization}; +use futures::StreamExt; +use nativelink_macro::nativelink_test; +use pretty_assertions::assert_eq; +use sha2::{Digest as _, Sha256}; + +/// The canonical test input named in the vectors file header +/// (SHA256 d9e749d9367fc908876749d6502eb212fee88c9a94892fb07da5ef3ba8bc39ed). +const TEST_INPUT: &[u8] = include_bytes!("data/SekienAkashita.jpg"); +const TEST_VECTORS: &str = include_str!("data/fastcdc2020_test_vectors.txt"); + +/// Parameters stated in the vectors file header. +const MIN_SIZE: u32 = 4096; +const AVG_SIZE: u32 = 16384; +const MAX_SIZE: u32 = 65535; + +struct ExpectedChunk { + offset: u64, + length: usize, + sha256_hex: String, + fingerprint: u64, +} + +/// Parses the `# Seed: ` sections of the vectors file into +/// (seed, expected chunks) pairs. +fn parse_test_vectors() -> Vec<(u64, Vec)> { + let mut sections = Vec::new(); + for line in TEST_VECTORS.lines() { + let line = line.trim(); + if let Some(seed) = line.strip_prefix("# Seed: ") { + sections.push((seed.parse::().unwrap(), Vec::new())); + continue; + } + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let chunk = ExpectedChunk { + offset: fields.next().unwrap().parse().unwrap(), + length: fields.next().unwrap().parse().unwrap(), + sha256_hex: fields.next().unwrap().to_string(), + fingerprint: fields.next().unwrap().parse().unwrap(), + }; + sections + .last_mut() + .expect("chunk line before any '# Seed:' section") + .1 + .push(chunk); + } + assert!(!sections.is_empty(), "no seed sections parsed"); + sections +} + +#[nativelink_test] +async fn fastcdc2020_matches_reapi_test_vectors() -> Result<(), Box> { + assert_eq!( + hex::encode(Sha256::digest(TEST_INPUT)), + "d9e749d9367fc908876749d6502eb212fee88c9a94892fb07da5ef3ba8bc39ed", + "test fixture does not match the input named in the vectors file" + ); + + for (seed, expected_chunks) in parse_test_vectors() { + let chunks: Vec<_> = FastCDC::with_level_and_seed( + TEST_INPUT, + MIN_SIZE, + AVG_SIZE, + MAX_SIZE, + Normalization::Level2, + seed, + ) + .collect(); + assert_eq!( + chunks.len(), + expected_chunks.len(), + "chunk count mismatch for seed {seed}" + ); + for (chunk, expected) in chunks.iter().zip(&expected_chunks) { + assert_eq!(chunk.offset as u64, expected.offset, "offset, seed {seed}"); + assert_eq!(chunk.length, expected.length, "length, seed {seed}"); + assert_eq!(chunk.hash, expected.fingerprint, "fingerprint, seed {seed}"); + let data = &TEST_INPUT[chunk.offset..chunk.offset + chunk.length]; + assert_eq!( + hex::encode(Sha256::digest(data)), + expected.sha256_hex, + "chunk content sha256, seed {seed}" + ); + } + } + Ok(()) +} + +/// The streaming chunker (the variant `SplitBlob` actually uses) must +/// produce the same boundaries as the in-memory reference. +#[nativelink_test] +async fn fastcdc2020_streaming_matches_reapi_test_vectors() +-> Result<(), Box> { + let sections = parse_test_vectors(); + let (_, expected_chunks) = sections + .iter() + .find(|(seed, _)| *seed == 0) + .expect("seed 0 section missing"); + + let mut cdc = AsyncStreamCDC::with_level( + TEST_INPUT, + MIN_SIZE, + AVG_SIZE, + MAX_SIZE, + Normalization::Level2, + ); + let stream = cdc.as_stream(); + let mut stream = core::pin::pin!(stream); + let mut chunks = Vec::new(); + while let Some(chunk) = stream.next().await { + chunks.push(chunk.expect("chunking the test input failed")); + } + assert_eq!(chunks.len(), expected_chunks.len()); + for (chunk, expected) in chunks.iter().zip(expected_chunks) { + assert_eq!(chunk.offset, expected.offset); + assert_eq!(chunk.length, expected.length); + assert_eq!(chunk.hash, expected.fingerprint); + assert_eq!( + hex::encode(Sha256::digest(&chunk.data)), + expected.sha256_hex + ); + } + Ok(()) +} diff --git a/nativelink-store/src/verify_store.rs b/nativelink-store/src/verify_store.rs index e3722ff48..1b313e08e 100644 --- a/nativelink-store/src/verify_store.rs +++ b/nativelink-store/src/verify_store.rs @@ -23,13 +23,12 @@ use nativelink_util::buf_channel::{ DropCloserReadHalf, DropCloserWriteHalf, make_buf_channel_pair, }; use nativelink_util::common::PackedHash; -use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc, default_digest_hasher_func}; +use nativelink_util::digest_hasher::{DigestHasher, digest_hasher_func_from_context}; use nativelink_util::health_utils::{HealthStatusIndicator, default_health_status_indicator}; use nativelink_util::metrics_utils::CounterWithTime; use nativelink_util::store_trait::{ RemoveItemCallback, Store, StoreDriver, StoreKey, StoreLike, UploadSizeInfo, }; -use opentelemetry::context::Context; #[derive(Debug, MetricsComponent)] pub struct VerifyStore { @@ -184,12 +183,7 @@ impl StoreDriver for VerifyStore { } let mut hasher = if self.verify_hash { - Some( - Context::current() - .get::() - .map_or_else(default_digest_hasher_func, |v| *v) - .hasher(), - ) + Some(digest_hasher_func_from_context().hasher()) } else { None }; diff --git a/nativelink-util/src/digest_hasher.rs b/nativelink-util/src/digest_hasher.rs index 51c911c63..e0a37b0af 100644 --- a/nativelink-util/src/digest_hasher.rs +++ b/nativelink-util/src/digest_hasher.rs @@ -53,6 +53,14 @@ pub fn default_digest_hasher_func() -> DigestHasherFunc { *DEFAULT_DIGEST_HASHER_FUNC.get_or_init(|| DigestHasherFunc::Sha256) } +/// Get the hasher requested by the client from the active context (set via +/// [`make_ctx_for_hash_func`]), falling back to the default hasher. +pub fn digest_hasher_func_from_context() -> DigestHasherFunc { + Context::current() + .get::() + .map_or_else(default_digest_hasher_func, |v| *v) +} + /// Sets the default hasher to use if no hasher was requested by the client. pub fn set_default_digest_hasher_func(hasher: DigestHasherFunc) -> Result<(), Error> { DEFAULT_DIGEST_HASHER_FUNC diff --git a/src/bin/nativelink.rs b/src/bin/nativelink.rs index 33e1c6ad5..0ff240386 100644 --- a/src/bin/nativelink.rs +++ b/src/bin/nativelink.rs @@ -30,8 +30,8 @@ use hyper_util::server::conn::auto; use hyper_util::service::TowerToHyperService; use mimalloc::MiMalloc; use nativelink_config::cas_server::{ - CasConfig, GlobalConfig, HttpCompressionAlgorithm, ListenerConfig, SchedulerConfig, - ServerConfig, StoreConfig, WorkerConfig, + CasConfig, CasStoreConfig, GlobalConfig, HttpCompressionAlgorithm, ListenerConfig, + SchedulerConfig, ServerConfig, StoreConfig, WithInstanceName, WorkerConfig, }; use nativelink_config::stores::ConfigDigestHashFunction; use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err}; @@ -263,6 +263,17 @@ async fn inner_main( let server_cfgs: Vec = cfg.servers.into_iter().collect(); + // The capabilities service advertises chunking support for CAS instances + // that may be served from a different server block (e.g. behind an L7 + // router), so collect the CAS configs across all blocks. + let all_cas_configs: Vec> = server_cfgs + .iter() + .filter_map(|server_cfg| server_cfg.services.as_ref()) + .filter_map(|services| services.cas.as_deref()) + .flatten() + .cloned() + .collect(); + for server_cfg in server_cfgs { let services = server_cfg .services @@ -292,8 +303,9 @@ async fn inner_main( .add_optional_service( services .cas + .as_deref() .map_or(Ok(None), |cfg| { - CasServer::new(&cfg, &store_manager) + CasServer::new(cfg, &store_manager) .map(|v| Some(service_setup!(v.into_service(), http_config))) }) .err_tip(|| "Could not create CAS service")?, @@ -334,12 +346,13 @@ async fn inner_main( .err_tip(|| "Could not create ByteStream service")?, ) .add_optional_service( - OptionFuture::from( - services - .capabilities - .as_ref() - .map(|cfg| CapabilitiesServer::new(cfg, &action_schedulers)), - ) + OptionFuture::from(services.capabilities.as_ref().map(|cfg| { + CapabilitiesServer::new( + cfg, + &action_schedulers, + &all_cas_configs, + ) + })) .await .map_or(Ok::, Error>(None), |server| { Ok(Some(server?)) From 3677bed243f1b4c43008844318671cd5aec5aabb Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Thu, 2 Jul 2026 13:52:17 -0700 Subject: [PATCH 02/12] Forward SplitBlob/SpliceBlob natively through grpc proxy stores For grpc-store-backed CAS instances the chunking RPCs are now forwarded verbatim to the backend (with instance-name rewriting and the store's usual retry handling) instead of being rejected at startup. This makes NativeLink relays transparent for content-defined chunking: one RPC in, one RPC out, the backend owns chunking and the layout index. - Add GrpcStore::split_blob/splice_blob following the existing find_missing_blobs/batch_*/get_tree forwarding pattern. - Shortcut to the proxy in the CAS handlers before any local chunking machinery is consulted, mirroring the other four CAS RPCs. - Make experimental_chunking.index_store optional: required for locally chunked instances, rejected for grpc-store instances where the backend owns the chunk layouts. The capabilities service still advertises split/splice + FastCDC params from the same config block, so relay operators set avg_chunk_size_bytes to match their backend. - Test forwarding against a fake CAS backend over a real gRPC round trip (verifies passthrough and instance-name rewriting) and the new constructor rules. Co-Authored-By: Claude Fable 5 --- nativelink-config/src/cas_server.rs | 13 +- nativelink-service/src/cas_server.rs | 82 +++++++---- nativelink-service/tests/cas_server_test.rs | 64 ++++++++- nativelink-store/src/grpc_store.rs | 61 +++++++- nativelink-store/tests/grpc_store_test.rs | 150 +++++++++++++++++++- src/bin/nativelink.rs | 12 +- 6 files changed, 341 insertions(+), 41 deletions(-) diff --git a/nativelink-config/src/cas_server.rs b/nativelink-config/src/cas_server.rs index 1b3172ab6..4ad4f13f9 100644 --- a/nativelink-config/src/cas_server.rs +++ b/nativelink-config/src/cas_server.rs @@ -134,7 +134,9 @@ pub struct CasStoreConfig { /// content-defined chunking clients (e.g. Bazel's /// `--experimental_remote_cache_chunking`). When set, the capabilities /// service advertises blob split/splice support and `FastCDC` 2020 - /// parameters for this instance. + /// parameters for this instance. When `cas_store` is a grpc store the + /// RPCs are forwarded to the backend (which must support chunking with + /// matching parameters); otherwise they are served locally. /// /// Default: not set (chunking RPCs are rejected and not advertised). #[serde(default)] @@ -152,8 +154,13 @@ pub struct CasChunkingConfig { /// verification and MUST NOT be the same store as `cas_store` — writing /// layouts into the CAS would overwrite blob content. Using the same /// store name as `cas_store` is rejected at startup. - #[serde(deserialize_with = "convert_string_with_shellexpand")] - pub index_store: StoreRefName, + /// + /// Required unless `cas_store` is a grpc store: for proxied instances + /// the `SplitBlob`/`SpliceBlob` RPCs are forwarded to the backend, which + /// owns the chunk layouts, and setting an `index_store` is rejected at + /// startup. + #[serde(default, deserialize_with = "convert_optional_string_with_shellexpand")] + pub index_store: Option, /// The average chunk size in bytes advertised to clients through the /// `FastCDC` 2020 capability parameters and used for server-side diff --git a/nativelink-service/src/cas_server.rs b/nativelink-service/src/cas_server.rs index 61f481dce..d1156ada6 100644 --- a/nativelink-service/src/cas_server.rs +++ b/nativelink-service/src/cas_server.rs @@ -206,31 +206,6 @@ impl CasServer { make_input_err!("'cas_store': '{}' does not exist", config.cas_store) })?; if let Some(chunking_config) = &config.experimental_chunking { - // Chunk layouts are stored under the digests of the blobs - // they describe but do not hash to them, so writing them - // into the CAS itself would overwrite blob content. - error_if!( - chunking_config.index_store == config.cas_store, - "'experimental_chunking.index_store' of instance '{}' must not be the same store as 'cas_store'", - config.instance_name - ); - // Chunking against a grpc proxy store would download and - // re-upload entire blobs through the proxy instead of - // forwarding the RPCs; reject it until native forwarding is - // implemented. - error_if!( - store.downcast_ref::(None).is_some(), - "'experimental_chunking' of instance '{}' is not supported when 'cas_store' is a grpc store", - config.instance_name - ); - let index_store = store_manager - .get_store(&chunking_config.index_store) - .ok_or_else(|| { - make_input_err!( - "'experimental_chunking.index_store': '{}' does not exist", - chunking_config.index_store - ) - })?; let avg_chunk_size_bytes = chunking_config .validated_avg_chunk_size_bytes() .err_tip(|| { @@ -239,6 +214,39 @@ impl CasServer { config.instance_name ) })?; + if store.downcast_ref::(None).is_some() { + // SplitBlob/SpliceBlob for grpc-store-backed instances + // are forwarded to the backend, which owns the chunk + // layouts; a local index store is meaningless there. + error_if!( + chunking_config.index_store.is_some(), + "'experimental_chunking.index_store' of instance '{}' must not be set when 'cas_store' is a grpc store: SplitBlob/SpliceBlob are forwarded to the backend", + config.instance_name + ); + // No ChunkingInstance: the forwarding shortcut in the + // handlers takes over before local chunking is reached. + stores.insert(config.instance_name.clone(), store); + continue; + } + let index_store_name = chunking_config.index_store.as_ref().ok_or_else(|| { + make_input_err!( + "'experimental_chunking.index_store' of instance '{}' is required", + config.instance_name + ) + })?; + // Chunk layouts are stored under the digests of the blobs + // they describe but do not hash to them, so writing them + // into the CAS itself would overwrite blob content. + error_if!( + index_store_name == &config.cas_store, + "'experimental_chunking.index_store' of instance '{}' must not be the same store as 'cas_store'", + config.instance_name + ); + let index_store = store_manager.get_store(index_store_name).ok_or_else(|| { + make_input_err!( + "'experimental_chunking.index_store': '{index_store_name}' does not exist" + ) + })?; let avg_chunk_size_bytes = u32::try_from(avg_chunk_size_bytes) .err_tip(|| "avg_chunk_size_bytes did not fit in u32")?; chunking_instances.insert( @@ -527,8 +535,10 @@ impl CasServer { .right_stream()) } - /// Returns the CAS store and chunking state for an instance, or - /// `Unimplemented` when chunking is not enabled for it. + /// Returns the CAS store for an instance and its chunking state, or + /// `Unimplemented` when chunking is not enabled for it. Grpc-store-backed + /// instances never reach this: their handlers forward the RPC to the + /// backend first. fn chunking_instance(&self, instance_name: &str) -> Result<(Store, ChunkingInstance), Error> { let store = self .stores @@ -548,6 +558,14 @@ impl CasServer { Ok((store, chunking_instance)) } + /// Returns the backend `GrpcStore` when the instance's CAS is a grpc + /// proxy store, in which case chunking RPCs are forwarded verbatim. + fn grpc_store_for_instance(&self, instance_name: &str) -> Option<&GrpcStore> { + self.stores + .get(instance_name) + .and_then(|store| store.downcast_ref::(None)) + } + /// Returns the display names of the chunks missing from the CAS. The /// existence check also touches present chunks, which extends their /// lifetimes on a best-effort basis (stores that answer existence from a @@ -610,6 +628,11 @@ impl CasServer { &self, request: SplitBlobRequest, ) -> Result, Error> { + // If we are a GrpcStore we forward the RPC to the backend, which + // owns chunking and the layout index for proxied instances. + if let Some(grpc_store) = self.grpc_store_for_instance(&request.instance_name) { + return grpc_store.split_blob(Request::new(request)).await; + } let (store, chunking_instance) = self.chunking_instance(&request.instance_name)?; self.chunking_metrics .split_requests_total @@ -771,6 +794,11 @@ impl CasServer { &self, request: SpliceBlobRequest, ) -> Result, Error> { + // If we are a GrpcStore we forward the RPC to the backend, which + // owns chunking and the layout index for proxied instances. + if let Some(grpc_store) = self.grpc_store_for_instance(&request.instance_name) { + return grpc_store.splice_blob(Request::new(request)).await; + } let (store, chunking_instance) = self.chunking_instance(&request.instance_name)?; let index_store = chunking_instance.index_store; self.chunking_metrics diff --git a/nativelink-service/tests/cas_server_test.rs b/nativelink-service/tests/cas_server_test.rs index 73c93319f..fe6cb1e33 100644 --- a/nativelink-service/tests/cas_server_test.rs +++ b/nativelink-service/tests/cas_server_test.rs @@ -872,7 +872,7 @@ fn make_chunking_cas_server_with_avg( config: nativelink_config::cas_server::CasStoreConfig { cas_store: "main_cas".to_string(), experimental_chunking: Some(nativelink_config::cas_server::CasChunkingConfig { - index_store: "chunk_index".to_string(), + index_store: Some("chunk_index".to_string()), avg_chunk_size_bytes, }), }, @@ -1325,7 +1325,7 @@ async fn chunking_rejects_index_store_same_as_cas_store() -> Result<(), Box Result<(), Box Result<(), Box> { + let store_manager = Arc::new(StoreManager::new()); + store_manager.add_store( + "grpc_cas", + store_factory( + &StoreSpec::Grpc(nativelink_config::stores::GrpcSpec { + instance_name: "backend".to_string(), + endpoints: vec![nativelink_config::stores::GrpcEndpoint { + address: "http://localhost:1".to_string(), + tls_config: None, + concurrency_limit: None, + connect_timeout_s: 0, + tcp_keepalive_s: 0, + http2_keepalive_interval_s: 0, + http2_keepalive_timeout_s: 0, + }], + store_type: nativelink_config::stores::StoreType::Cas, + retry: nativelink_config::stores::Retry::default(), + max_concurrent_requests: 0, + connections_per_endpoint: 0, + rpc_timeout_s: 1, + use_legacy_resource_names: false, + headers: std::collections::HashMap::new(), + forward_headers: vec![], + }), + &store_manager, + None, + ) + .await?, + ); + + let make_config = |index_store: Option| { + vec![WithInstanceName { + instance_name: INSTANCE_NAME.to_string(), + config: nativelink_config::cas_server::CasStoreConfig { + cas_store: "grpc_cas".to_string(), + experimental_chunking: Some(nativelink_config::cas_server::CasChunkingConfig { + index_store, + avg_chunk_size_bytes: 0, + }), + }, + }] + }; + + // A local index store is meaningless when the RPCs are forwarded. + let error = CasServer::new(&make_config(Some("grpc_cas".to_string())), &store_manager) + .err() + .expect("expected index_store on grpc store to be rejected"); + assert!( + error.to_string().contains("must not be set"), + "unexpected error: {error}" + ); + + // Without an index_store the configuration is valid: SplitBlob and + // SpliceBlob are forwarded to the backend. + CasServer::new(&make_config(None), &store_manager)?; + Ok(()) +} diff --git a/nativelink-store/src/grpc_store.rs b/nativelink-store/src/grpc_store.rs index 40edc4dc0..79d866179 100644 --- a/nativelink-store/src/grpc_store.rs +++ b/nativelink-store/src/grpc_store.rs @@ -29,7 +29,8 @@ use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_s use nativelink_proto::build::bazel::remote::execution::v2::{ ActionResult, BatchReadBlobsRequest, BatchReadBlobsResponse, BatchUpdateBlobsRequest, BatchUpdateBlobsResponse, FindMissingBlobsRequest, FindMissingBlobsResponse, - GetActionResultRequest, GetTreeRequest, GetTreeResponse, UpdateActionResultRequest, + GetActionResultRequest, GetTreeRequest, GetTreeResponse, SpliceBlobRequest, SpliceBlobResponse, + SplitBlobRequest, SplitBlobResponse, UpdateActionResultRequest, }; use nativelink_proto::google::bytestream::byte_stream_client::ByteStreamClient; use nativelink_proto::google::bytestream::{ @@ -335,6 +336,64 @@ impl GrpcStore { .await } + pub async fn split_blob( + &self, + grpc_request: Request, + ) -> Result, Error> { + error_if!( + matches!(self.store_type, nativelink_config::stores::StoreType::Ac), + "CAS operation on AC store" + ); + + let mut request = grpc_request.into_inner(); + request.instance_name.clone_from(&self.instance_name); + self.perform_request(request, |request| async move { + let channel = self + .connection_manager + .connection(format!("split_blob: {:?}", request.blob_digest)) + .await + .err_tip(|| "in split_blob")?; + ContentAddressableStorageClient::new(channel) + .split_blob(enrich_request( + Request::new(request), + &self.headers, + &self.forward_headers, + )) + .await + .err_tip(|| "in GrpcStore::split_blob") + }) + .await + } + + pub async fn splice_blob( + &self, + grpc_request: Request, + ) -> Result, Error> { + error_if!( + matches!(self.store_type, nativelink_config::stores::StoreType::Ac), + "CAS operation on AC store" + ); + + let mut request = grpc_request.into_inner(); + request.instance_name.clone_from(&self.instance_name); + self.perform_request(request, |request| async move { + let channel = self + .connection_manager + .connection(format!("splice_blob: {:?}", request.blob_digest)) + .await + .err_tip(|| "in splice_blob")?; + ContentAddressableStorageClient::new(channel) + .splice_blob(enrich_request( + Request::new(request), + &self.headers, + &self.forward_headers, + )) + .await + .err_tip(|| "in GrpcStore::splice_blob") + }) + .await + } + fn get_read_request(&self, mut request: ReadRequest) -> Result { const IS_UPLOAD_FALSE: bool = false; let mut resource_info = ResourceInfo::new(&request.resource_name, IS_UPLOAD_FALSE)?; diff --git a/nativelink-store/tests/grpc_store_test.rs b/nativelink-store/tests/grpc_store_test.rs index 3466997b2..858d5a653 100644 --- a/nativelink-store/tests/grpc_store_test.rs +++ b/nativelink-store/tests/grpc_store_test.rs @@ -9,8 +9,14 @@ use futures::{Stream, StreamExt}; use nativelink_config::stores::{GrpcEndpoint, GrpcSpec, Retry, StoreType}; use nativelink_error::{Error, ResultExt}; use nativelink_macro::nativelink_test; +use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_storage_server::{ + ContentAddressableStorage, ContentAddressableStorageServer, +}; use nativelink_proto::build::bazel::remote::execution::v2::{ - FindMissingBlobsRequest, digest_function, + BatchReadBlobsRequest, BatchReadBlobsResponse, BatchUpdateBlobsRequest, + BatchUpdateBlobsResponse, Digest, FindMissingBlobsRequest, FindMissingBlobsResponse, + GetTreeRequest, GetTreeResponse, SpliceBlobRequest, SpliceBlobResponse, SplitBlobRequest, + SplitBlobResponse, chunking_function, digest_function, }; use nativelink_proto::google::bytestream::byte_stream_server::{ByteStream, ByteStreamServer}; use nativelink_proto::google::bytestream::{ @@ -322,3 +328,145 @@ async fn read_works_with_headers() -> Result<(), Error> { Ok(()) } + +#[derive(Debug, Clone)] +struct FakeCasServer { + split_requests: Arc>>, + splice_requests: Arc>>, +} + +impl FakeCasServer { + fn new() -> Self { + Self { + split_requests: Arc::new(Mutex::new(vec![])), + splice_requests: Arc::new(Mutex::new(vec![])), + } + } +} + +type GetTreeStream = Pin> + Send + 'static>>; + +#[tonic::async_trait] +impl ContentAddressableStorage for FakeCasServer { + type GetTreeStream = GetTreeStream; + + #[allow(clippy::unimplemented)] + async fn find_missing_blobs( + &self, + _grpc_request: Request, + ) -> Result, Status> { + unimplemented!(); + } + + #[allow(clippy::unimplemented)] + async fn batch_update_blobs( + &self, + _grpc_request: Request, + ) -> Result, Status> { + unimplemented!(); + } + + #[allow(clippy::unimplemented)] + async fn batch_read_blobs( + &self, + _grpc_request: Request, + ) -> Result, Status> { + unimplemented!(); + } + + #[allow(clippy::unimplemented)] + async fn get_tree( + &self, + _grpc_request: Request, + ) -> Result, Status> { + unimplemented!(); + } + + async fn split_blob( + &self, + grpc_request: Request, + ) -> Result, Status> { + let request = grpc_request.into_inner(); + self.split_requests.lock().await.push(request.clone()); + Ok(Response::new(SplitBlobResponse { + chunk_digests: request.blob_digest.into_iter().collect(), + chunking_function: request.chunking_function, + })) + } + + async fn splice_blob( + &self, + grpc_request: Request, + ) -> Result, Status> { + let request = grpc_request.into_inner(); + self.splice_requests.lock().await.push(request.clone()); + Ok(Response::new(SpliceBlobResponse { + blob_digest: request.blob_digest, + })) + } +} + +async fn make_fake_cas_server() -> (FakeCasServer, u16) { + let fake_cas_server = FakeCasServer::new(); + let server = ContentAddressableStorageServer::new(fake_cas_server.clone()); + let listener = TcpIncoming::bind("127.0.0.1:0".parse().unwrap()).unwrap(); + let port = listener.local_addr().unwrap().port(); + + background_spawn!("server", async move { + Server::builder() + .add_service(server) + .serve_with_incoming(listener) + .await + .unwrap(); + }); + + (fake_cas_server, port) +} + +#[nativelink_test] +async fn split_and_splice_blob_forward_to_backend() -> Result<(), Error> { + let (server, port) = make_fake_cas_server().await; + let mut spec = test_spec(format!("http://localhost:{port}"), false); + spec.instance_name = "backend_instance".to_string(); + let store = GrpcStore::new(&spec).await?; + + let digest = Digest { + hash: VALID_HASH.to_string(), + size_bytes: RAW_INPUT.len() as i64, + }; + + let split_response = store + .split_blob(Request::new(SplitBlobRequest { + instance_name: "local_instance".to_string(), + blob_digest: Some(digest.clone()), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(split_response.chunk_digests, vec![digest.clone()]); + { + let split_requests = server.split_requests.lock().await; + assert_eq!(split_requests.len(), 1); + // The instance name must be rewritten to the backend's. + assert_eq!(split_requests[0].instance_name, "backend_instance"); + } + + let splice_response = store + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: "local_instance".to_string(), + blob_digest: Some(digest.clone()), + chunk_digests: vec![digest.clone()], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(splice_response.blob_digest, Some(digest)); + { + let splice_requests = server.splice_requests.lock().await; + assert_eq!(splice_requests.len(), 1); + assert_eq!(splice_requests[0].instance_name, "backend_instance"); + } + Ok(()) +} diff --git a/src/bin/nativelink.rs b/src/bin/nativelink.rs index 0ff240386..4137940c2 100644 --- a/src/bin/nativelink.rs +++ b/src/bin/nativelink.rs @@ -346,13 +346,11 @@ async fn inner_main( .err_tip(|| "Could not create ByteStream service")?, ) .add_optional_service( - OptionFuture::from(services.capabilities.as_ref().map(|cfg| { - CapabilitiesServer::new( - cfg, - &action_schedulers, - &all_cas_configs, - ) - })) + OptionFuture::from( + services.capabilities.as_ref().map(|cfg| { + CapabilitiesServer::new(cfg, &action_schedulers, &all_cas_configs) + }), + ) .await .map_or(Ok::, Error>(None), |server| { Ok(Some(server?)) From d7bbd095cf3819eeee18237ec63f98d72c0a68fa Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Thu, 2 Jul 2026 15:38:52 -0700 Subject: [PATCH 03/12] Fix pre-commit hooks and make max chunk count configurable - Reuse the FastCDC test fixture already vendored at nativelink-util/tests/data/SekienAkashita.jpg (and already excluded from the forbid-binary-files hook) instead of adding a duplicate binary copy; export it from nativelink-util for the conformance test. - Format nativelink-service/Cargo.toml per taplo. - Replace the hardcoded 50k chunk cap with a per-instance experimental_chunking.max_chunk_count knob (default 50000). Blobs above the cap are served without chunking; the layout read cap is derived from the configured count so the two can never disagree. Co-Authored-By: Claude Fable 5 --- nativelink-config/src/cas_server.rs | 26 ++++++ nativelink-service/BUILD.bazel | 4 +- nativelink-service/Cargo.toml | 8 +- nativelink-service/src/cas_server.rs | 54 ++++++----- nativelink-service/tests/cas_server_test.rs | 85 ++++++++++++++++++ .../tests/data/SekienAkashita.jpg | Bin 109466 -> 0 bytes .../tests/fastcdc_conformance_test.rs | 5 +- nativelink-util/BUILD.bazel | 3 + 8 files changed, 155 insertions(+), 30 deletions(-) delete mode 100644 nativelink-service/tests/data/SekienAkashita.jpg diff --git a/nativelink-config/src/cas_server.rs b/nativelink-config/src/cas_server.rs index 4ad4f13f9..b4434452b 100644 --- a/nativelink-config/src/cas_server.rs +++ b/nativelink-config/src/cas_server.rs @@ -171,6 +171,20 @@ pub struct CasChunkingConfig { /// Default: 524288 (512 KiB) #[serde(default)] pub avg_chunk_size_bytes: u64, + + /// Maximum number of chunks accepted in a `SpliceBlob` request or + /// produced by on-demand chunking in `SplitBlob`. Blobs that would + /// produce more chunks are served without chunking (`SplitBlob` returns + /// `NOT_FOUND` and clients fall back to a regular download). This bounds + /// the size of stored chunk layouts and of `SplitBlobResponse` messages + /// (roughly 80-140 bytes per chunk). At the default average chunk size + /// the default cap supports blobs up to ~25 GiB; note that values above + /// ~50000 may produce responses that exceed default gRPC message size + /// limits on clients. + /// + /// Default: 50000 + #[serde(default)] + pub max_chunk_count: u64, } impl CasChunkingConfig { @@ -181,6 +195,8 @@ impl CasChunkingConfig { /// `FastCdc2020Params`. pub const MIN_AVG_CHUNK_SIZE_BYTES: u64 = 1024; pub const MAX_AVG_CHUNK_SIZE_BYTES: u64 = 1024 * 1024; + /// Default for `max_chunk_count`. + pub const DEFAULT_MAX_CHUNK_COUNT: u64 = 50_000; /// Returns `avg_chunk_size_bytes` with the default applied. #[must_use] @@ -192,6 +208,16 @@ impl CasChunkingConfig { } } + /// Returns `max_chunk_count` with the default applied. + #[must_use] + pub const fn resolved_max_chunk_count(&self) -> u64 { + if self.max_chunk_count == 0 { + Self::DEFAULT_MAX_CHUNK_COUNT + } else { + self.max_chunk_count + } + } + /// Returns `avg_chunk_size_bytes` with the default applied, or an error /// when the configured value is outside the REAPI-mandated bounds. pub fn validated_avg_chunk_size_bytes(&self) -> Result { diff --git a/nativelink-service/BUILD.bazel b/nativelink-service/BUILD.bazel index 0ec442857..80bc06dd4 100644 --- a/nativelink-service/BUILD.bazel +++ b/nativelink-service/BUILD.bazel @@ -70,7 +70,9 @@ rust_test_suite( "tests/push_server_test.rs", "tests/worker_api_server_test.rs", ], - compile_data = glob(["tests/data/**"]), + compile_data = glob(["tests/data/**"]) + [ + "//nativelink-util:tests/data/SekienAkashita.jpg", + ], proc_macro_deps = [ "//nativelink-macro", "@crates//:async-trait", diff --git a/nativelink-service/Cargo.toml b/nativelink-service/Cargo.toml index 6eec30bb0..212387ef0 100644 --- a/nativelink-service/Cargo.toml +++ b/nativelink-service/Cargo.toml @@ -17,9 +17,7 @@ nativelink-util = { path = "../nativelink-util" } axum = { version = "0.8.3", default-features = false } bytes = { version = "1.10.1", default-features = false } -fastcdc = { version = "3.2.1", default-features = false, features = [ - "tokio", -] } +fastcdc = { version = "3.2.1", default-features = false, features = ["tokio"] } futures = { version = "0.3.31", default-features = false } http-body-util = { version = "0.1.3", default-features = false } hyper = { version = "1.6.0", default-features = false } @@ -46,9 +44,7 @@ tokio = { version = "1.52.2", features = [ tokio-stream = { version = "0.1.17", features = [ "fs", ], default-features = false } -tokio-util = { version = "0.7.14", features = [ - "io", -], default-features = false } +tokio-util = { version = "0.7.14", features = ["io"], default-features = false } tonic = { version = "0.14.0", features = [ "gzip", "router", diff --git a/nativelink-service/src/cas_server.rs b/nativelink-service/src/cas_server.rs index d1156ada6..c64a2c889 100644 --- a/nativelink-service/src/cas_server.rs +++ b/nativelink-service/src/cas_server.rs @@ -157,6 +157,16 @@ struct ChunkingInstance { index_store: Store, /// Average chunk size used for server-side `FastCDC` 2020 chunking. avg_chunk_size_bytes: u32, + /// Maximum number of chunks accepted in a `SpliceBlob` request or + /// produced by on-demand chunking. + max_chunk_count: usize, +} + +impl ChunkingInstance { + /// Maximum serialized layout size consistent with `max_chunk_count`. + const fn max_layout_size(&self) -> u64 { + self.max_chunk_count as u64 * MAX_LAYOUT_BYTES_PER_CHUNK + } } #[derive(Debug)] @@ -178,17 +188,13 @@ const BATCH_PER_BLOB_TIMEOUT: Duration = Duration::from_secs(30); /// this bounds the memory a single splice request can pin. const MAX_SPLICE_CHUNK_SIZE: u64 = 16 * 1024 * 1024; -/// Maximum number of chunks accepted in a `SpliceBlob` request or produced -/// by on-demand chunking in `SplitBlob`. Bounds the size of stored chunk -/// layouts and `SplitBlobResponse` messages (roughly 80 bytes per chunk, -/// ~4 MiB at the cap) independently of how small a client's chunks are. -const MAX_CHUNK_COUNT: usize = 50_000; - -/// Maximum serialized chunk layout size read back from the index store. -/// Layouts written by this server are bounded by `MAX_CHUNK_COUNT`, so a -/// larger entry is corrupt. A truncated read is detected (and treated as no -/// layout) by the size consistency check in `read_chunk_layout`. -const MAX_CHUNK_LAYOUT_SIZE: u64 = 16 * 1024 * 1024; +/// Generous upper bound for the serialized size of one chunk entry in a +/// stored layout (hash string of up to 128 hex characters plus varints and +/// field tags). Multiplied by the configured `max_chunk_count` this caps +/// layout reads from the index store; a larger entry is corrupt. A truncated +/// read is detected (and treated as no layout) by the size consistency check +/// in `read_chunk_layout`. +const MAX_LAYOUT_BYTES_PER_CHUNK: u64 = 160; /// Number of chunk reads/writes kept in flight while re-assembling or /// chunking a blob. Matches the `DedupStore` concurrency default. @@ -249,11 +255,14 @@ impl CasServer { })?; let avg_chunk_size_bytes = u32::try_from(avg_chunk_size_bytes) .err_tip(|| "avg_chunk_size_bytes did not fit in u32")?; + let max_chunk_count = usize::try_from(chunking_config.resolved_max_chunk_count()) + .err_tip(|| "max_chunk_count did not fit in usize")?; chunking_instances.insert( config.instance_name.clone(), ChunkingInstance { index_store, avg_chunk_size_bytes, + max_chunk_count, }, ); } @@ -594,11 +603,12 @@ impl CasServer { /// with the blob size (which also rejects entries truncated by the read /// cap below). async fn read_chunk_layout( - index_store: &Store, + chunking_instance: &ChunkingInstance, blob_digest: DigestInfo, ) -> Option { - let layout_bytes = index_store - .get_part_unchunked(blob_digest, 0, Some(MAX_CHUNK_LAYOUT_SIZE)) + let layout_bytes = chunking_instance + .index_store + .get_part_unchunked(blob_digest, 0, Some(chunking_instance.max_layout_size())) .await .ok()?; let layout = SplitBlobResponse::decode(layout_bytes).ok()?; @@ -648,7 +658,7 @@ impl CasServer { // (best effort) as suggested by the REAPI spec for SplitBlob. let (blob_exists, maybe_layout) = futures::join!( store.has(blob_digest), - Self::read_chunk_layout(&chunking_instance.index_store, blob_digest), + Self::read_chunk_layout(&chunking_instance, blob_digest), ); if blob_exists.err_tip(|| "In split_blob")?.is_none() { self.chunking_metrics @@ -773,11 +783,12 @@ impl CasServer { let chunk_digests = read_res .merge(chunk_res) .err_tip(|| "Failed to chunk blob in chunk_blob_on_demand")?; - if chunk_digests.len() > MAX_CHUNK_COUNT { + if chunk_digests.len() > chunking_instance.max_chunk_count { return Err(make_err!( Code::NotFound, - "Blob {blob_digest} produced {} chunks, exceeding the supported maximum of {MAX_CHUNK_COUNT}; no split information available", - chunk_digests.len() + "Blob {blob_digest} produced {} chunks, exceeding the configured max_chunk_count of {}; no split information available", + chunk_digests.len(), + chunking_instance.max_chunk_count )); } @@ -816,9 +827,10 @@ impl CasServer { "chunk_digests must not be empty in splice_blob" ); error_if!( - request.chunk_digests.len() > MAX_CHUNK_COUNT, - "Request has {} chunk_digests, expected at most {MAX_CHUNK_COUNT} in splice_blob", - request.chunk_digests.len() + request.chunk_digests.len() > chunking_instance.max_chunk_count, + "Request has {} chunk_digests, expected at most {} in splice_blob", + request.chunk_digests.len(), + chunking_instance.max_chunk_count ); let mut chunk_digests = Vec::with_capacity(request.chunk_digests.len()); let mut total_size: u64 = 0; diff --git a/nativelink-service/tests/cas_server_test.rs b/nativelink-service/tests/cas_server_test.rs index fe6cb1e33..29b888d18 100644 --- a/nativelink-service/tests/cas_server_test.rs +++ b/nativelink-service/tests/cas_server_test.rs @@ -874,6 +874,7 @@ fn make_chunking_cas_server_with_avg( experimental_chunking: Some(nativelink_config::cas_server::CasChunkingConfig { index_store: Some("chunk_index".to_string()), avg_chunk_size_bytes, + max_chunk_count: 0, }), }, }], @@ -1327,6 +1328,7 @@ async fn chunking_rejects_index_store_same_as_cas_store() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { + // avg 1024 (min allowed) with max_chunk_count 2: the 16 KiB test blob + // chunks to more than 2 pieces, so on-demand splitting must refuse. + const AVG_CHUNK_SIZE: u64 = 1024; + const BLOB_SIZE: usize = 16 * 1024; + + let store_manager = make_chunking_store_manager().await?; + let cas_server = CasServer::new( + &[WithInstanceName { + instance_name: INSTANCE_NAME.to_string(), + config: nativelink_config::cas_server::CasStoreConfig { + cas_store: "main_cas".to_string(), + experimental_chunking: Some(nativelink_config::cas_server::CasChunkingConfig { + index_store: Some("chunk_index".to_string()), + avg_chunk_size_bytes: AVG_CHUNK_SIZE, + max_chunk_count: 2, + }), + }, + }], + &store_manager, + )?; + let store = store_manager.get_store("main_cas").unwrap(); + + let mut state = 0x9e37_79b9_u32; + let data: Vec = (0..BLOB_SIZE) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 24) as u8 + }) + .collect(); + let blob_digest = Digest { + hash: HASH1.to_string(), + size_bytes: BLOB_SIZE as i64, + }; + store + .update_oneshot( + DigestInfo::try_from(blob_digest.clone())?, + bytes::Bytes::from(data), + ) + .await?; + + let status = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest.clone()), + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(status.code(), Code::NotFound); + assert!( + status.message().contains("max_chunk_count"), + "unexpected message: {}", + status.message() + ); + + // Splices above the cap are rejected outright. + let chunk_digest = Digest { + hash: HASH2.to_string(), + size_bytes: 1, + }; + let status = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest), + chunk_digests: vec![chunk_digest.clone(), chunk_digest.clone(), chunk_digest], + digest_function: digest_function::Value::Sha256.into(), + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await + .unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert!( + status.message().contains("expected at most 2"), + "unexpected message: {}", + status.message() + ); + Ok(()) +} diff --git a/nativelink-service/tests/data/SekienAkashita.jpg b/nativelink-service/tests/data/SekienAkashita.jpg deleted file mode 100644 index 71b09702c447a34208cb3df86a0a5bb70ab4e0ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 109466 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 Date: Mon, 6 Jul 2026 14:10:57 -0700 Subject: [PATCH 04/12] Add chunking configuration example and clarify optionality Address review feedback: state explicitly that experimental_chunking is optional (with unchanged behavior when unset) and add a complete, test-validated configuration example at nativelink-config/examples/chunking_cas.json5. Co-Authored-By: Claude Fable 5 --- nativelink-config/examples/chunking_cas.json5 | 101 ++++++++++++++++++ nativelink-config/src/cas_server.rs | 10 +- 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 nativelink-config/examples/chunking_cas.json5 diff --git a/nativelink-config/examples/chunking_cas.json5 b/nativelink-config/examples/chunking_cas.json5 new file mode 100644 index 000000000..f603892ab --- /dev/null +++ b/nativelink-config/examples/chunking_cas.json5 @@ -0,0 +1,101 @@ +// Demonstrates REAPI content-defined chunking: the SplitBlob/SpliceBlob +// RPCs used by Bazel's --experimental_remote_cache_chunking flag +// (available in Bazel 8.7.0+ / 9.1.0+). +// +// Chunking is entirely optional and disabled by default: without the +// `experimental_chunking` block below, NativeLink behaves exactly as +// before and does not advertise chunking support. When enabled, clients +// upload and download large blobs as content-defined chunks, so small +// changes to large outputs only transfer the chunks that changed. +{ + stores: [ + { + name: "CAS_MAIN_STORE", + filesystem: { + content_path: "/tmp/nativelink/data/content_path-cas", + temp_path: "/tmp/nativelink/data/tmp_path-cas", + eviction_policy: { + // 10gb. + max_bytes: 10000000000, + }, + }, + }, + { + // Holds the blob-to-chunks layouts registered via SpliceBlob or + // created by on-demand chunking in SplitBlob. Layout entries are + // small (roughly 80-140 bytes per chunk). This store must not verify + // content digests and must not be the same store as the CAS itself. + name: "CHUNK_INDEX_STORE", + filesystem: { + content_path: "/tmp/nativelink/data/content_path-chunk-index", + temp_path: "/tmp/nativelink/data/tmp_path-chunk-index", + eviction_policy: { + // 100mb. + max_bytes: 100000000, + }, + }, + }, + { + name: "AC_MAIN_STORE", + filesystem: { + content_path: "/tmp/nativelink/data/content_path-ac", + temp_path: "/tmp/nativelink/data/tmp_path-ac", + eviction_policy: { + // 500mb. + max_bytes: 500000000, + }, + }, + }, + ], + servers: [ + { + listener: { + http: { + socket_address: "0.0.0.0:50051", + }, + }, + services: { + cas: [ + { + instance_name: "main", + cas_store: "CAS_MAIN_STORE", + // Optional: omit this block to disable chunking entirely. + experimental_chunking: { + // Required, unless `cas_store` is a grpc store — in that + // case the chunking RPCs are forwarded to the backend and + // `index_store` must be omitted. + index_store: "CHUNK_INDEX_STORE", + // Optional: the average chunk size in bytes advertised to + // clients and used for server-side chunking. Must be between + // 1 KiB and 1 MiB. + // Default: 524288 (512 KiB). + avg_chunk_size_bytes: 524288, + // Optional: blobs that would produce more chunks than this + // are served without chunking. + // Default: 50000. + max_chunk_count: 50000, + }, + }, + ], + ac: [ + { + instance_name: "main", + ac_store: "AC_MAIN_STORE", + }, + ], + // The capabilities service advertises chunking support; Bazel only + // issues SplitBlob/SpliceBlob when it is advertised. + capabilities: [ + { + instance_name: "main", + }, + ], + bytestream: { + cas_stores: { + main: "CAS_MAIN_STORE", + }, + }, + }, + }, + ], +} diff --git a/nativelink-config/src/cas_server.rs b/nativelink-config/src/cas_server.rs index b4434452b..5f8b72984 100644 --- a/nativelink-config/src/cas_server.rs +++ b/nativelink-config/src/cas_server.rs @@ -130,15 +130,19 @@ pub struct CasStoreConfig { #[serde(deserialize_with = "convert_string_with_shellexpand")] pub cas_store: StoreRefName, - /// Experimental: Enables the REAPI `SplitBlob`/`SpliceBlob` RPCs used by - /// content-defined chunking clients (e.g. Bazel's + /// Optional and experimental: enables the REAPI `SplitBlob`/`SpliceBlob` + /// RPCs used by content-defined chunking clients (e.g. Bazel's /// `--experimental_remote_cache_chunking`). When set, the capabilities /// service advertises blob split/splice support and `FastCDC` 2020 /// parameters for this instance. When `cas_store` is a grpc store the /// RPCs are forwarded to the backend (which must support chunking with /// matching parameters); otherwise they are served locally. /// - /// Default: not set (chunking RPCs are rejected and not advertised). + /// See `nativelink-config/examples/chunking_cas.json5` for a complete + /// configuration example. + /// + /// Default: not set — chunking RPCs are rejected, nothing is advertised, + /// and behavior is identical to when this option did not exist. #[serde(default)] pub experimental_chunking: Option, } From a65ed7158bbc27b10a83cb7da24191cbbf2c0c35 Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 14:40:13 -0700 Subject: [PATCH 05/12] Format chunking example per formatjson5 Co-Authored-By: Claude Fable 5 --- nativelink-config/examples/chunking_cas.json5 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nativelink-config/examples/chunking_cas.json5 b/nativelink-config/examples/chunking_cas.json5 index f603892ab..f2ca4b966 100644 --- a/nativelink-config/examples/chunking_cas.json5 +++ b/nativelink-config/examples/chunking_cas.json5 @@ -59,17 +59,20 @@ { instance_name: "main", cas_store: "CAS_MAIN_STORE", + // Optional: omit this block to disable chunking entirely. experimental_chunking: { // Required, unless `cas_store` is a grpc store — in that // case the chunking RPCs are forwarded to the backend and // `index_store` must be omitted. index_store: "CHUNK_INDEX_STORE", + // Optional: the average chunk size in bytes advertised to // clients and used for server-side chunking. Must be between // 1 KiB and 1 MiB. // Default: 524288 (512 KiB). avg_chunk_size_bytes: 524288, + // Optional: blobs that would produce more chunks than this // are served without chunking. // Default: 50000. @@ -83,6 +86,7 @@ ac_store: "AC_MAIN_STORE", }, ], + // The capabilities service advertises chunking support; Bazel only // issues SplitBlob/SpliceBlob when it is advertised. capabilities: [ From 43678a919a9ab1ca7f49559d23f2854ebf23c21a Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 16:40:07 -0700 Subject: [PATCH 06/12] Add chunking integration test to CI Now that the repo's Bazel is 9.1.1 (which supports --experimental_remote_cache_chunking), exercise the SplitBlob/SpliceBlob paths end-to-end in the existing integration-tests job: enable experimental_chunking in the docker-compose CAS config and add chunking_cache_test.sh, which uploads a ~6.9MB artifact as chunks, asserts the server registered a chunk layout, then re-fetches it through the chunked download path and verifies it is byte-identical. Co-Authored-By: Claude Fable 5 --- BUILD.bazel | 9 +++ .../docker-compose/local-storage-cas.json5 | 21 ++++++ integration_tests/chunking_cache_test.sh | 64 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100755 integration_tests/chunking_cache_test.sh diff --git a/BUILD.bazel b/BUILD.bazel index f9a482dea..700ba5dd3 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -133,3 +133,12 @@ sh_test( timeout = "short", srcs = [":dummy_test_sh"], ) + +# Large deterministic output used by integration_tests/chunking_cache_test.sh +# to exercise --experimental_remote_cache_chunking (must exceed the chunking +# threshold of 4x the average chunk size). +genrule( + name = "chunking_test_artifact", + outs = ["chunking_test_artifact.txt"], + cmd = "seq 1 1000000 > \"$@\"", +) diff --git a/deployment-examples/docker-compose/local-storage-cas.json5 b/deployment-examples/docker-compose/local-storage-cas.json5 index 6d4acdeed..9997323a5 100644 --- a/deployment-examples/docker-compose/local-storage-cas.json5 +++ b/deployment-examples/docker-compose/local-storage-cas.json5 @@ -23,6 +23,20 @@ }, }, }, + { + // Holds blob-to-chunks layouts for the SplitBlob/SpliceBlob RPCs used + // by Bazel's --experimental_remote_cache_chunking. Must not verify + // digests and must not be the same store as the CAS. + name: "CHUNK_INDEX_STORE", + filesystem: { + content_path: "~/.cache/nativelink/content_path-chunk-index", + temp_path: "~/.cache/nativelink/tmp_path-chunk-index", + eviction_policy: { + // 100mb. + max_bytes: 100000000, + }, + }, + }, { name: "AC_MAIN_STORE", filesystem: { @@ -46,6 +60,13 @@ cas: [ { cas_store: "CAS_MAIN_STORE", + + // Optional: enables content-defined chunking + // (SplitBlob/SpliceBlob) for Bazel clients running with + // --experimental_remote_cache_chunking. + experimental_chunking: { + index_store: "CHUNK_INDEX_STORE", + }, }, ], ac: [ diff --git a/integration_tests/chunking_cache_test.sh b/integration_tests/chunking_cache_test.sh new file mode 100755 index 000000000..a9da82bc5 --- /dev/null +++ b/integration_tests/chunking_cache_test.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Copyright 2026 The NativeLink Authors. All rights reserved. +# +# Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# See LICENSE file for details +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Sanity check for REAPI content-defined chunking: uploads a large blob via +# SpliceBlob (Bazel --experimental_remote_cache_chunking), verifies the +# server registered a chunk layout, then re-fetches the blob from the remote +# cache via the chunked download path and checks it is byte-identical. + +if [[ $UNDER_TEST_RUNNER -ne 1 ]]; then + echo "This script should be run under run_integration_tests.sh" + exit 1 +fi +set -x + +CHUNKING_FLAGS=(--config self_test --experimental_remote_cache_chunking) +EXPECTED_SHA=$(seq 1 1000000 | sha256sum | awk '{print $1}') + +# First build executes the action locally and uploads the ~6.9MB output as +# chunks (SpliceBlob). +bazel --output_base="$BAZEL_CACHE_DIR" build "${CHUNKING_FLAGS[@]}" //:chunking_test_artifact +FIRST_SHA=$(sha256sum bazel-bin/chunking_test_artifact.txt | awk '{print $1}') +if [[ $FIRST_SHA != "$EXPECTED_SHA" ]]; then + echo "Expected locally built artifact to have sha $EXPECTED_SHA, got $FIRST_SHA." + exit 1 +fi + +# The server must have registered a chunk layout for the spliced blob. The +# index store is mounted from the host by docker-compose. +CHUNK_INDEX_DIR="${NATIVELINK_DIR:-$HOME/.cache/nativelink}/content_path-chunk-index" +if [[ -z $(find "$CHUNK_INDEX_DIR" -type f 2> /dev/null) ]]; then + echo "Expected a chunk layout in $CHUNK_INDEX_DIR after a chunked upload." + echo "SpliceBlob was likely not used; check that the server advertises" + echo "chunking support and that bazel supports the chunking flag." + exit 1 +fi + +# Clean our local cache and re-fetch from the remote cache through the +# chunked download path. +bazel --output_base="$BAZEL_CACHE_DIR" clean +OUTPUT=$(bazel --output_base="$BAZEL_CACHE_DIR" build "${CHUNKING_FLAGS[@]}" //:chunking_test_artifact 2>&1) +if [[ ! $OUTPUT =~ 'remote cache hit' ]]; then + echo "Expected second bazel run to be a remote cache hit." + echo "STDOUT:" + echo "$OUTPUT" + exit 1 +fi +SECOND_SHA=$(sha256sum bazel-bin/chunking_test_artifact.txt | awk '{print $1}') +if [[ $SECOND_SHA != "$EXPECTED_SHA" ]]; then + echo "Artifact fetched through the chunked download path is corrupt:" + echo "expected sha $EXPECTED_SHA, got $SECOND_SHA." + exit 1 +fi From dd69a174a1a0b9cb8d04dbd24c20550be45f42ab Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 16:55:25 -0700 Subject: [PATCH 07/12] Infer digest function when unset in chunking RPCs Bazel 9.1.1 leaves digest_function unset in SplitBlob/SpliceBlob even when running with --digest_function=blake3 (surfaced by the new CI chunking integration test, which runs with the repo's blake3 default). REAPI length-based inference cannot disambiguate SHA256 from BLAKE3 (both 32 bytes), so: - SpliceBlob hashes the re-assembled blob with both candidates when the field is unset and accepts whichever reproduces the expected digest. - SplitBlob's on-demand chunking infers the blob's digest function with an extra content pass so chunk digests use the right function. Explicitly-set digest functions keep the single-hasher fast path. Co-Authored-By: Claude Fable 5 --- nativelink-service/src/cas_server.rs | 120 ++++++++++++++++++-- nativelink-service/tests/cas_server_test.rs | 58 ++++++++++ 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/nativelink-service/src/cas_server.rs b/nativelink-service/src/cas_server.rs index c64a2c889..f79df7f12 100644 --- a/nativelink-service/src/cas_server.rs +++ b/nativelink-service/src/cas_server.rs @@ -35,7 +35,7 @@ use nativelink_proto::build::bazel::remote::execution::v2::{ BatchUpdateBlobsResponse, Digest, Directory, FindMissingBlobsRequest, FindMissingBlobsResponse, GetTreeRequest, GetTreeResponse, SpliceBlobRequest, SpliceBlobResponse, SplitBlobRequest, SplitBlobResponse, batch_read_blobs_response, batch_update_blobs_response, chunking_function, - compressor, + compressor, digest_function, }; use nativelink_proto::google::rpc::Status as GrpcStatus; use nativelink_store::ac_utils::get_and_decode_digest; @@ -43,9 +43,7 @@ use nativelink_store::grpc_store::GrpcStore; use nativelink_store::store_manager::StoreManager; use nativelink_util::buf_channel::make_buf_channel_pair; use nativelink_util::common::DigestInfo; -use nativelink_util::digest_hasher::{ - DigestHasher, digest_hasher_func_from_context, make_ctx_for_hash_func, -}; +use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc, make_ctx_for_hash_func}; use nativelink_util::store_trait::{Store, StoreLike, UploadSizeInfo}; use opentelemetry::context::FutureExt; use prost::Message; @@ -575,6 +573,68 @@ impl CasServer { .and_then(|store| store.downcast_ref::(None)) } + /// Returns the digest function explicitly requested by the client, or + /// `None` when the field was left unset. REAPI's length-based inference + /// cannot be used as a fallback here: SHA256 and BLAKE3 digests are both + /// 32 bytes, and `NativeLink` announces support for both. Notably Bazel + /// (9.1.1) leaves this field unset even when running with + /// `--digest_function=blake3`. + fn explicit_hasher_func(digest_function_value: i32) -> Option { + digest_function::Value::try_from(digest_function_value) + .ok() + .and_then(|value| DigestHasherFunc::try_from(value).ok()) + } + + /// Determines the digest function of a blob already present in the CAS + /// by hashing its content with each supported function and returning the + /// one that reproduces `blob_digest`. + async fn infer_blob_hasher_func( + store: &Store, + blob_digest: DigestInfo, + ) -> Result { + const CANDIDATES: [DigestHasherFunc; 2] = + [DigestHasherFunc::Sha256, DigestHasherFunc::Blake3]; + let (tx, rx) = make_buf_channel_pair(); + let read_store = store.clone(); + let read_fut = async move { + let mut tx = tx; + read_store + .get_part(blob_digest, &mut tx, 0, None) + .await + .err_tip(|| "Failed to read blob in infer_blob_hasher_func") + }; + let hash_fut = async move { + let mut rx = rx; + let mut hashers = CANDIDATES.map(|func| func.hasher()); + loop { + let data = rx + .recv() + .await + .err_tip(|| "In infer_blob_hasher_func::recv")?; + if data.is_empty() { + break; // EOF. + } + for hasher in &mut hashers { + hasher.update(&data); + } + } + Ok::<_, Error>(hashers.map(|mut hasher| hasher.finalize_digest())) + }; + let (read_res, hash_res) = futures::join!(read_fut, hash_fut); + let computed_digests = read_res.merge(hash_res)?; + CANDIDATES + .iter() + .zip(computed_digests) + .find(|(_, computed)| *computed == blob_digest) + .map(|(func, _)| *func) + .ok_or_else(|| { + make_err!( + Code::NotFound, + "Blob {blob_digest} does not match any supported digest function; no split information available" + ) + }) + } + /// Returns the display names of the chunks missing from the CAS. The /// existence check also touches present chunks, which extends their /// lifetimes on a best-effort basis (stores that answer existence from a @@ -694,7 +754,12 @@ impl CasServer { // path taken for blobs that were uploaded whole (e.g. outputs // produced by remote execution workers). let split_response = self - .chunk_blob_on_demand(&store, &chunking_instance, blob_digest) + .chunk_blob_on_demand( + &store, + &chunking_instance, + blob_digest, + request.digest_function, + ) .await?; self.chunking_metrics .split_chunked_on_demand @@ -714,10 +779,17 @@ impl CasServer { store: &Store, chunking_instance: &ChunkingInstance, blob_digest: DigestInfo, + digest_function_value: i32, ) -> Result { let avg_size = chunking_instance.avg_chunk_size_bytes; let (min_size, max_size) = (avg_size / 4, avg_size * 4); - let hasher_func = digest_hasher_func_from_context(); + // Chunk digests MUST use the blob's digest function. When the client + // leaves the field unset it has to be inferred from the blob content + // (an extra read pass) since the hash length alone is ambiguous. + let hasher_func = match Self::explicit_hasher_func(digest_function_value) { + Some(hasher_func) => hasher_func, + None => Self::infer_blob_hasher_func(store, blob_digest).await?, + }; let (tx, rx) = make_buf_channel_pair(); let read_store = store.clone(); @@ -889,7 +961,15 @@ impl CasServer { // in chunk order. The digest is verified before the final EOF is // sent, so a digest mismatch aborts the upload before the store // commits it. - let hasher_func = digest_hasher_func_from_context(); + // When the client sets the digest function, verify with exactly that + // function. When it is unset the hash length is ambiguous (SHA256 + // and BLAKE3 are both 32 bytes), so hash with both candidates and + // accept whichever reproduces the expected digest. + let candidate_hasher_funcs: Vec = + match Self::explicit_hasher_func(request.digest_function) { + Some(hasher_func) => vec![hasher_func], + None => vec![DigestHasherFunc::Sha256, DigestHasherFunc::Blake3], + }; let verification_failed = AtomicBool::new(false); let verification_failed_ref = &verification_failed; let (tx, rx) = make_buf_channel_pair(); @@ -899,7 +979,10 @@ impl CasServer { // of leaving it waiting for more data. let send_fut = async move { let mut tx = tx; - let mut hasher = hasher_func.hasher(); + let mut hashers: Vec<_> = candidate_hasher_funcs + .iter() + .map(DigestHasherFunc::hasher) + .collect(); let mut fetch_stream = futures::stream::iter(chunk_digests.into_iter().map( move |chunk_digest| { let store = send_store.clone(); @@ -925,18 +1008,31 @@ impl CasServer { .buffered(CHUNK_CONCURRENCY); while let Some(data) = fetch_stream.next().await { let data = data?; - hasher.update(&data); + for hasher in &mut hashers { + hasher.update(&data); + } tx.send(data) .await .err_tip(|| "Failed to send chunk data in splice_blob")?; } drop(fetch_stream); - let computed_digest = hasher.finalize_digest(); - if computed_digest != blob_digest { + let computed_digests: Vec = hashers + .iter_mut() + .map(DigestHasher::finalize_digest) + .collect(); + if !computed_digests + .iter() + .any(|computed| *computed == blob_digest) + { verification_failed_ref.store(true, Ordering::Relaxed); return Err(make_err!( Code::InvalidArgument, - "Digest of spliced blob ({computed_digest}) does not match the expected digest ({blob_digest}) in splice_blob" + "Digest of spliced blob ({}) does not match the expected digest ({blob_digest}) in splice_blob", + computed_digests + .iter() + .map(ToString::to_string) + .collect::>() + .join(" / ") )); } tx.send_eof() diff --git a/nativelink-service/tests/cas_server_test.rs b/nativelink-service/tests/cas_server_test.rs index 29b888d18..2e1b0c7af 100644 --- a/nativelink-service/tests/cas_server_test.rs +++ b/nativelink-service/tests/cas_server_test.rs @@ -1487,3 +1487,61 @@ async fn max_chunk_count_limits_split_and_splice() -> Result<(), Box Result<(), Box> { + const VALUE: &str = "blake3 blob content"; + + let store_manager = make_chunking_store_manager().await?; + let cas_server = make_chunking_cas_server(&store_manager)?; + let store = store_manager.get_store("main_cas").unwrap(); + + let mut hasher = DigestHasherFunc::Blake3.hasher(); + hasher.update(VALUE.as_bytes()); + let blob_digest: Digest = hasher.finalize_digest().into(); + + // Splice: the single chunk is the blob itself, uploaded under its + // BLAKE3 digest, with digest_function left unset. + store + .update_oneshot(DigestInfo::try_from(blob_digest.clone())?, VALUE.into()) + .await?; + let splice_response = cas_server + .splice_blob(Request::new(SpliceBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(blob_digest.clone()), + chunk_digests: vec![blob_digest.clone()], + digest_function: 0, + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(splice_response.blob_digest.as_ref(), Some(&blob_digest)); + + // On-demand split of a fresh blob uploaded whole: the returned chunk + // digests must be BLAKE3 (here a single chunk equal to the blob). + let mut hasher = DigestHasherFunc::Blake3.hasher(); + hasher.update(b"other blake3 content"); + let other_digest: Digest = hasher.finalize_digest().into(); + store + .update_oneshot( + DigestInfo::try_from(other_digest.clone())?, + bytes::Bytes::from_static(b"other blake3 content"), + ) + .await?; + let split_response = cas_server + .split_blob(Request::new(SplitBlobRequest { + instance_name: INSTANCE_NAME.to_string(), + blob_digest: Some(other_digest.clone()), + digest_function: 0, + chunking_function: chunking_function::Value::FastCdc2020.into(), + })) + .await? + .into_inner(); + assert_eq!(split_response.chunk_digests, vec![other_digest]); + Ok(()) +} From 234fbd38c0a02e4f65f754fc9773c76af207e434 Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 17:12:00 -0700 Subject: [PATCH 08/12] Fix artifact path resolution in chunking integration test The test runner's working directory is deployment-examples/docker-compose, not the workspace root, so the bazel-bin convenience symlink is not at the script's cwd. Resolve the output path through bazel info instead. Co-Authored-By: Claude Fable 5 --- integration_tests/chunking_cache_test.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/integration_tests/chunking_cache_test.sh b/integration_tests/chunking_cache_test.sh index a9da82bc5..9a48a5689 100755 --- a/integration_tests/chunking_cache_test.sh +++ b/integration_tests/chunking_cache_test.sh @@ -26,11 +26,14 @@ set -x CHUNKING_FLAGS=(--config self_test --experimental_remote_cache_chunking) EXPECTED_SHA=$(seq 1 1000000 | sha256sum | awk '{print $1}') +# The test runner's working directory is not the workspace root, so resolve +# the output location through bazel itself. +ARTIFACT="$(bazel --output_base="$BAZEL_CACHE_DIR" info "${CHUNKING_FLAGS[@]}" bazel-bin)/chunking_test_artifact.txt" # First build executes the action locally and uploads the ~6.9MB output as # chunks (SpliceBlob). bazel --output_base="$BAZEL_CACHE_DIR" build "${CHUNKING_FLAGS[@]}" //:chunking_test_artifact -FIRST_SHA=$(sha256sum bazel-bin/chunking_test_artifact.txt | awk '{print $1}') +FIRST_SHA=$(sha256sum "$ARTIFACT" | awk '{print $1}') if [[ $FIRST_SHA != "$EXPECTED_SHA" ]]; then echo "Expected locally built artifact to have sha $EXPECTED_SHA, got $FIRST_SHA." exit 1 @@ -56,7 +59,7 @@ if [[ ! $OUTPUT =~ 'remote cache hit' ]]; then echo "$OUTPUT" exit 1 fi -SECOND_SHA=$(sha256sum bazel-bin/chunking_test_artifact.txt | awk '{print $1}') +SECOND_SHA=$(sha256sum "$ARTIFACT" | awk '{print $1}') if [[ $SECOND_SHA != "$EXPECTED_SHA" ]]; then echo "Artifact fetched through the chunked download path is corrupt:" echo "expected sha $EXPECTED_SHA, got $SECOND_SHA." From 9411801dc46f50fd4e27bc9b1bfa8482ed766477 Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 17:16:42 -0700 Subject: [PATCH 09/12] Add docs-site page for content-defined chunking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: document the feature where customers look — what it is, measured savings, client requirements (Bazel 9.1.1+/8.7+), how to enable it, tuning knobs, and when it does or does not help. Co-Authored-By: Claude Fable 5 --- .../content/docs/configuration/chunking.mdx | 110 ++++++++++++++++++ .../docs/content/docs/configuration/meta.json | 3 +- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 web/apps/docs/content/docs/configuration/chunking.mdx diff --git a/web/apps/docs/content/docs/configuration/chunking.mdx b/web/apps/docs/content/docs/configuration/chunking.mdx new file mode 100644 index 000000000..5074ce44a --- /dev/null +++ b/web/apps/docs/content/docs/configuration/chunking.mdx @@ -0,0 +1,110 @@ +--- +title: Content-defined chunking +description: Cut remote cache transfer bytes by 80-90% for incrementally changing artifacts with the REAPI SplitBlob/SpliceBlob extension and Bazel's --experimental_remote_cache_chunking. +--- + +When a large build output changes slightly — a relinked binary, a container +layer with one file modified — its digest changes, and a conventional remote +cache re-transfers the whole blob. Content-defined chunking (CDC) splits +blobs into chunks at content-derived boundaries, so clients upload and +download only the chunks that actually changed. In our measurements a +one-file change to a 16.8 MB tar layer re-uploaded 2.1 MB (87.5% less), and +a one-source-file change to a linked binary re-uploaded 80.8% less. + +NativeLink implements the server side of the +[REAPI blob split/splice extension](https://github.com/bazelbuild/remote-apis/pull/282): +`SpliceBlob` re-assembles chunked uploads (verifying the digest before +committing anything), and `SplitBlob` serves chunk layouts for downloads — +chunking blobs on demand with FastCDC 2020 when they were uploaded whole, +which is what makes chunked downloads work for outputs produced by remote +execution workers. + +## Requirements + +- **Bazel 9.1.1+ or 8.7.0+** on the client, with + `--experimental_remote_cache_chunking`. Avoid 9.1.0: it has a client bug + that corrupts outputs when the chunking flag is combined with + `--disk_cache` (fixed in 9.1.1). +- Chunking is **optional and off by default**. Without the configuration + below, NativeLink behaves exactly as before and does not advertise + chunking support, so clients fall back to regular transfers. + +## Enabling it + +Add an `experimental_chunking` block to the CAS service and give it a small +store for chunk layouts. The index store must not verify content digests +and must not be the CAS store itself: + +```json5 +stores: [ + { + name: "CAS_MAIN_STORE", + // ... your existing CAS store ... + }, + { + // Blob-to-chunks layouts: roughly 80-140 bytes per chunk. + name: "CHUNK_INDEX_STORE", + filesystem: { + content_path: "~/.cache/nativelink/content_path-chunk-index", + temp_path: "~/.cache/nativelink/tmp_path-chunk-index", + eviction_policy: { max_bytes: 100000000 }, + }, + }, +], +servers: [ + { + // ... + services: { + cas: [ + { + cas_store: "CAS_MAIN_STORE", + experimental_chunking: { + index_store: "CHUNK_INDEX_STORE", + // Optional; the REAPI-recommended default. Must be between + // 1 KiB and 1 MiB. Blobs smaller than 4x this value are + // never chunked. + avg_chunk_size_bytes: 524288, + // Optional; blobs producing more chunks than this are served + // without chunking (~25 GiB at the default average). + max_chunk_count: 50000, + }, + }, + ], + // The capabilities service advertises chunking support; clients + // only use it when advertised. + capabilities: [{}], + // ... + }, + }, +], +``` + +A complete runnable example lives at +[`nativelink-config/examples/chunking_cas.json5`](https://github.com/TraceMachina/nativelink/blob/main/nativelink-config/examples/chunking_cas.json5). +For instances whose `cas_store` is a grpc proxy store, omit `index_store`: +the chunking RPCs are forwarded to the backend, which owns the layouts. + +Then build with: + +```sh +bazel build //... \ + --remote_cache=grpc://your-nativelink:50051 \ + --experimental_remote_cache_chunking +``` + +## When it helps, and when it doesn't + +Chunking pays off when clients reach the cache across a real network (WAN, +metered links, cross-region) and artifacts change incrementally: +uncompressed archives, linked binaries, and container layers typically save +80–90% of transfer bytes per change. It does little on same-rack links — +saved bytes only save time when the wire is the bottleneck — and little for +compressed artifacts, where everything after the first changed byte +re-transfers. Small blobs (below 4x the average chunk size) are never +chunked, so hot small-object traffic is unaffected. + +The server verifies every spliced blob's digest before committing it and +materializes the full blob, so non-chunking clients and every existing read +path see ordinary blobs. Storage grows by roughly the chunk bytes for +chunk-eligible blobs; pairing the CAS with a `dedup` or `compression` store +composes normally. diff --git a/web/apps/docs/content/docs/configuration/meta.json b/web/apps/docs/content/docs/configuration/meta.json index 3f037a30c..73c150728 100644 --- a/web/apps/docs/content/docs/configuration/meta.json +++ b/web/apps/docs/content/docs/configuration/meta.json @@ -2,7 +2,8 @@ "pages": [ "intro", "basic", - "production" + "production", + "chunking" ], "title": "Configuring NativeLink" } From 14acf729f4e06cf7b0850abbaab5d37e9605f485 Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 17:36:10 -0700 Subject: [PATCH 10/12] Disable async cache uploads in chunking integration test Bazel has uploaded cache entries in the background by default since Bazel 8 (--remote_cache_async), so `bazel build` can return before the chunked upload and SpliceBlob complete. The CI logs show exactly this: the splice reached the server ~0.9s after "Build completed successfully", while the test asserted on the chunk index ~0.8s after the build returned. Forcing synchronous uploads makes both the chunk-layout assertion and the clean-rebuild cache-hit check deterministic. Verified by running the full script locally against a clean server: splice on upload, layout written, remote cache hit via SplitBlob on rebuild, SHA-identical output. Co-Authored-By: Claude Fable 5 --- integration_tests/chunking_cache_test.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/integration_tests/chunking_cache_test.sh b/integration_tests/chunking_cache_test.sh index 9a48a5689..d75ff6feb 100755 --- a/integration_tests/chunking_cache_test.sh +++ b/integration_tests/chunking_cache_test.sh @@ -24,7 +24,11 @@ if [[ $UNDER_TEST_RUNNER -ne 1 ]]; then fi set -x -CHUNKING_FLAGS=(--config self_test --experimental_remote_cache_chunking) +# Bazel uploads cache entries in the background by default +# (--remote_cache_async, on since Bazel 8), so a build can return before the +# chunked upload and SpliceBlob complete. Force synchronous uploads so the +# chunk-index assertions below cannot race the upload. +CHUNKING_FLAGS=(--config self_test --experimental_remote_cache_chunking --remote_cache_async=false) EXPECTED_SHA=$(seq 1 1000000 | sha256sum | awk '{print $1}') # The test runner's working directory is not the workspace root, so resolve # the output location through bazel itself. From 2ae919281cc6e55413f6738d375b4c7af0dd659a Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 17:49:29 -0700 Subject: [PATCH 11/12] Pass NATIVELINK_DIR through sudo to docker compose in test harness The integration test harness exports NATIVELINK_DIR but launches the containers with `sudo env RUST_LOG=info docker compose up`, and sudo's env_reset strips the variable. Docker compose therefore fell back to mounting root's ~/.cache/nativelink instead of the per-run cache dir, so the per-test `find "$NATIVELINK_DIR" -delete` cleanup never touched real store state, and chunking_cache_test.sh asserted on a directory the CAS container never wrote to (its chunk layouts landed in /root/.cache/nativelink on the host). Pass the variable through sudo explicitly, and add diagnostics to the chunking test's failure path that list both candidate locations. Co-Authored-By: Claude Fable 5 --- integration_tests/chunking_cache_test.sh | 3 +++ run_integration_tests.sh | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/integration_tests/chunking_cache_test.sh b/integration_tests/chunking_cache_test.sh index d75ff6feb..16351c1c1 100755 --- a/integration_tests/chunking_cache_test.sh +++ b/integration_tests/chunking_cache_test.sh @@ -50,6 +50,9 @@ if [[ -z $(find "$CHUNK_INDEX_DIR" -type f 2> /dev/null) ]]; then echo "Expected a chunk layout in $CHUNK_INDEX_DIR after a chunked upload." echo "SpliceBlob was likely not used; check that the server advertises" echo "chunking support and that bazel supports the chunking flag." + echo "Diagnostics: contents of the mounted cache dir and root's default:" + sudo find "${NATIVELINK_DIR:-$HOME/.cache/nativelink}" -maxdepth 1 2> /dev/null || true + sudo find /root/.cache/nativelink -maxdepth 1 2> /dev/null || true exit 1 fi diff --git a/run_integration_tests.sh b/run_integration_tests.sh index c1009a909..451cf1a27 100755 --- a/run_integration_tests.sh +++ b/run_integration_tests.sh @@ -127,7 +127,10 @@ for pattern in "${TEST_PATTERNS[@]}"; do bazel --output_base="$BAZEL_CACHE_DIR" clean FILENAME=$(basename "$fullpath") echo "Running test $FILENAME" - sudo env RUST_LOG=info docker compose up -d + # sudo resets the environment, so NATIVELINK_DIR must be passed + # through explicitly or docker compose falls back to mounting + # root's ~/.cache/nativelink instead of the per-run cache dir. + sudo env RUST_LOG=info NATIVELINK_DIR="$NATIVELINK_DIR" docker compose up -d if perl -e 'alarm shift; exec @ARGV' 30 bash -c 'until sudo docker compose logs | grep -q "Ready, listening on"; do sleep 1; done'; then echo "String 'Ready, listening on' found in the logs." else From 01c33424b3dc74a81b7af5b2404328b2bd579cba Mon Sep 17 00:00:00 2001 From: Ernesto Cambuston Date: Mon, 6 Jul 2026 18:04:01 -0700 Subject: [PATCH 12/12] Use absolute store paths in docker-compose CAS config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NativeLink expands only environment variables in config strings (shellexpand::env — the tilde feature is not enabled), so the "~/.cache/nativelink/..." paths in local-storage-cas.json5 were treated as literal relative paths: inside the container the CAS wrote to "/~/.cache/nativelink" under the process cwd, an unmounted ephemeral directory, never to the /root/.cache/nativelink bind mount. worker.json5 already uses absolute paths, which is why executor data was host-visible while CAS data was not. Use absolute paths to match, and drop the same misleading tilde idiom from the docs-site config snippet. Co-Authored-By: Claude Fable 5 --- .../docker-compose/local-storage-cas.json5 | 12 ++++++------ .../docs/content/docs/configuration/chunking.mdx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/deployment-examples/docker-compose/local-storage-cas.json5 b/deployment-examples/docker-compose/local-storage-cas.json5 index 9997323a5..69aae0134 100644 --- a/deployment-examples/docker-compose/local-storage-cas.json5 +++ b/deployment-examples/docker-compose/local-storage-cas.json5 @@ -13,8 +13,8 @@ }, backend: { filesystem: { - content_path: "~/.cache/nativelink/content_path-cas", - temp_path: "~/.cache/nativelink/tmp_path-cas", + content_path: "/root/.cache/nativelink/content_path-cas", + temp_path: "/root/.cache/nativelink/tmp_path-cas", eviction_policy: { // 10gb. max_bytes: 10000000000, @@ -29,8 +29,8 @@ // digests and must not be the same store as the CAS. name: "CHUNK_INDEX_STORE", filesystem: { - content_path: "~/.cache/nativelink/content_path-chunk-index", - temp_path: "~/.cache/nativelink/tmp_path-chunk-index", + content_path: "/root/.cache/nativelink/content_path-chunk-index", + temp_path: "/root/.cache/nativelink/tmp_path-chunk-index", eviction_policy: { // 100mb. max_bytes: 100000000, @@ -40,8 +40,8 @@ { name: "AC_MAIN_STORE", filesystem: { - content_path: "~/.cache/nativelink/content_path-ac", - temp_path: "~/.cache/nativelink/tmp_path-ac", + content_path: "/root/.cache/nativelink/content_path-ac", + temp_path: "/root/.cache/nativelink/tmp_path-ac", eviction_policy: { // 500mb. max_bytes: 500000000, diff --git a/web/apps/docs/content/docs/configuration/chunking.mdx b/web/apps/docs/content/docs/configuration/chunking.mdx index 5074ce44a..605284ed7 100644 --- a/web/apps/docs/content/docs/configuration/chunking.mdx +++ b/web/apps/docs/content/docs/configuration/chunking.mdx @@ -45,8 +45,8 @@ stores: [ // Blob-to-chunks layouts: roughly 80-140 bytes per chunk. name: "CHUNK_INDEX_STORE", filesystem: { - content_path: "~/.cache/nativelink/content_path-chunk-index", - temp_path: "~/.cache/nativelink/tmp_path-chunk-index", + content_path: "/tmp/nativelink/data/content_path-chunk-index", + temp_path: "/tmp/nativelink/data/tmp_path-chunk-index", eviction_policy: { max_bytes: 100000000 }, }, },