From 23b5f14f65132db31cd7abdcc223847b2cef2a44 Mon Sep 17 00:00:00 2001 From: Tyler French Date: Fri, 23 Jan 2026 14:54:53 -0500 Subject: [PATCH] support remote cache chunking using FastCDC 2020 --- .../google/devtools/build/lib/remote/BUILD | 5 + .../lib/remote/ChunkedBlobDownloader.java | 85 +++++ .../build/lib/remote/ChunkedBlobUploader.java | 107 ++++++ .../build/lib/remote/CombinedCache.java | 74 ++++- .../build/lib/remote/GrpcCacheClient.java | 72 +++- .../build/lib/remote/RemoteModule.java | 15 +- .../lib/remote/RemoteServerCapabilities.java | 18 + .../devtools/build/lib/remote/chunking/BUILD | 26 ++ .../lib/remote/chunking/ChunkingConfig.java | 72 ++++ .../lib/remote/chunking/FastCDCChunker.java | 300 +++++++++++++++++ .../lib/remote/common/RemoteCacheClient.java | 21 ++ .../remote/logging/LoggingInterceptor.java | 4 + .../lib/remote/logging/SpliceBlobHandler.java | 44 +++ .../lib/remote/logging/SplitBlobHandler.java | 45 +++ .../lib/remote/options/RemoteOptions.java | 13 + .../build/lib/remote/util/DigestUtil.java | 12 + src/main/protobuf/remote_execution_log.proto | 28 ++ .../google/devtools/build/lib/remote/BUILD | 56 ++++ ...eStreamBuildEventArtifactUploaderTest.java | 3 +- .../lib/remote/ChunkedBlobDownloaderTest.java | 180 ++++++++++ .../lib/remote/ChunkedBlobUploaderTest.java | 202 ++++++++++++ .../remote/ChunkedCacheIntegrationTest.java | 308 ++++++++++++++++++ .../ChunkedDiskCacheIntegrationTest.java | 219 +++++++++++++ .../build/lib/remote/GrpcCacheClientTest.java | 3 +- ...SpawnRunnerWithGrpcRemoteExecutorTest.java | 3 +- .../devtools/build/lib/remote/chunking/BUILD | 67 ++++ .../remote/chunking/ChunkingConfigTest.java | 138 ++++++++ .../lib/remote/chunking/FastCDCBenchmark.java | 65 ++++ .../remote/chunking/FastCDCChunkerTest.java | 271 +++++++++++++++ .../lib/remote/chunking/SekienAkashita.jpg | Bin 0 -> 109466 bytes .../remote/worker/CapabilitiesServer.java | 7 + .../build/remote/worker/CasServer.java | 87 +++++ 32 files changed, 2537 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloader.java create mode 100644 src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobUploader.java create mode 100644 src/main/java/com/google/devtools/build/lib/remote/chunking/BUILD create mode 100644 src/main/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfig.java create mode 100644 src/main/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunker.java create mode 100644 src/main/java/com/google/devtools/build/lib/remote/logging/SpliceBlobHandler.java create mode 100644 src/main/java/com/google/devtools/build/lib/remote/logging/SplitBlobHandler.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloaderTest.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobUploaderTest.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/ChunkedCacheIntegrationTest.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/ChunkedDiskCacheIntegrationTest.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/chunking/BUILD create mode 100644 src/test/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfigTest.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCBenchmark.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunkerTest.java create mode 100644 src/test/java/com/google/devtools/build/lib/remote/chunking/SekienAkashita.jpg diff --git a/src/main/java/com/google/devtools/build/lib/remote/BUILD b/src/main/java/com/google/devtools/build/lib/remote/BUILD index 63f0cb1d844190..13856e021fed59 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/BUILD +++ b/src/main/java/com/google/devtools/build/lib/remote/BUILD @@ -8,6 +8,7 @@ package( filegroup( name = "srcs", srcs = glob(["*"]) + [ + "//src/main/java/com/google/devtools/build/lib/remote/chunking:srcs", "//src/main/java/com/google/devtools/build/lib/remote/circuitbreaker:srcs", "//src/main/java/com/google/devtools/build/lib/remote/common:srcs", "//src/main/java/com/google/devtools/build/lib/remote/disk:srcs", @@ -28,7 +29,9 @@ java_library( srcs = glob( ["*.java"], exclude = [ + "ChunkingConfig.java", "ExecutionStatusException.java", + "FastCDCChunker.java", "ReferenceCountedChannel.java", "ChannelConnectionWithServerCapabilitiesFactory.java", "RemoteRetrier.java", @@ -53,6 +56,7 @@ java_library( ":Retrier", ":abstract_action_input_prefetcher", ":lease_service", + "//src/main/java/com/google/devtools/build/lib/concurrent:task_deduplicator", ":remote_important_output_handler", ":remote_output_checker", ":scrubber", @@ -97,6 +101,7 @@ java_library( "//src/main/java/com/google/devtools/build/lib/exec/local", "//src/main/java/com/google/devtools/build/lib/packages/semantics", "//src/main/java/com/google/devtools/build/lib/profiler", + "//src/main/java/com/google/devtools/build/lib/remote/chunking", "//src/main/java/com/google/devtools/build/lib/remote/circuitbreaker", "//src/main/java/com/google/devtools/build/lib/remote/common", "//src/main/java/com/google/devtools/build/lib/remote/common:bulk_transfer_exception", diff --git a/src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloader.java b/src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloader.java new file mode 100644 index 00000000000000..a402fd65634c88 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloader.java @@ -0,0 +1,85 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +package com.google.devtools.build.lib.remote; + +import static com.google.devtools.build.lib.remote.util.Utils.getFromFuture; + +import build.bazel.remote.execution.v2.Digest; +import build.bazel.remote.execution.v2.SplitBlobResponse; +import com.google.common.flogger.GoogleLogger; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.devtools.build.lib.remote.common.CacheNotFoundException; +import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; +import io.grpc.StatusRuntimeException; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +/** + * Downloads blobs by sequentially fetching chunks via the SplitBlob API. + */ +public class ChunkedBlobDownloader { + private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); + + private final GrpcCacheClient grpcCacheClient; + private final CombinedCache combinedCache; + + public ChunkedBlobDownloader(GrpcCacheClient grpcCacheClient, CombinedCache combinedCache) { + this.grpcCacheClient = grpcCacheClient; + this.combinedCache = combinedCache; + } + + /** + * Downloads a blob using chunked download via the SplitBlob API. This should be called with + * virtual threads, as it blocks on futures via {@link + * com.google.devtools.build.lib.remote.util.Utils#getFromFuture}. + */ + public void downloadChunked( + RemoteActionExecutionContext context, Digest blobDigest, OutputStream out) + throws CacheNotFoundException, IOException, InterruptedException { + List chunkDigests; + try { + chunkDigests = getChunkDigests(context, blobDigest); + } catch (IOException | StatusRuntimeException e) { + logger.atWarning().withCause(e).log( + "SplitBlob failed for %s/%d", blobDigest.getHash(), blobDigest.getSizeBytes()); + throw new CacheNotFoundException(blobDigest); + } + downloadAndReassembleChunks(context, chunkDigests, out); + } + + private List getChunkDigests( + RemoteActionExecutionContext context, Digest blobDigest) + throws IOException, InterruptedException { + ListenableFuture splitResponseFuture = + grpcCacheClient.splitBlob(context, blobDigest); + if (splitResponseFuture == null) { + throw new CacheNotFoundException(blobDigest); + } + List chunkDigests = getFromFuture(splitResponseFuture).getChunkDigestsList(); + if (chunkDigests.isEmpty() && blobDigest.getSizeBytes() > 0) { + throw new CacheNotFoundException(blobDigest); + } + return chunkDigests; + } + + private void downloadAndReassembleChunks( + RemoteActionExecutionContext context, List chunkDigests, OutputStream out) + throws IOException, InterruptedException { + for (Digest chunkDigest : chunkDigests) { + getFromFuture(combinedCache.downloadBlob(context, chunkDigest, out)); + } + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobUploader.java b/src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobUploader.java new file mode 100644 index 00000000000000..d6d9bf51329069 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/ChunkedBlobUploader.java @@ -0,0 +1,107 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +package com.google.devtools.build.lib.remote; + +import static com.google.devtools.build.lib.remote.util.Utils.getFromFuture; + +import build.bazel.remote.execution.v2.Digest; +import com.google.common.collect.ImmutableSet; +import com.google.common.io.ByteStreams; +import com.google.devtools.build.lib.remote.chunking.ChunkingConfig; +import com.google.devtools.build.lib.remote.chunking.FastCDCChunker; +import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; +import com.google.devtools.build.lib.remote.util.DigestUtil; +import com.google.devtools.build.lib.vfs.Path; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Uploads blobs in chunks using Content-Defined Chunking with FastCDC 2020. + * + *

+ * Upload flow for blobs above threshold: + * + *

    + *
  1. Chunk file with FastCDC + *
  2. Call findMissingDigests on chunk digests + *
  3. Upload only missing chunks + *
  4. Call SpliceBlob to register the blob as the concatenation of chunks + *
+ */ +public class ChunkedBlobUploader { + + private final GrpcCacheClient grpcCacheClient; + private final CombinedCache combinedCache; + private final FastCDCChunker chunker; + private final long chunkingThreshold; + + public ChunkedBlobUploader( + GrpcCacheClient grpcCacheClient, + CombinedCache combinedCache, + ChunkingConfig config, + DigestUtil digestUtil) { + this.grpcCacheClient = grpcCacheClient; + this.combinedCache = combinedCache; + this.chunker = new FastCDCChunker(config, digestUtil); + this.chunkingThreshold = config.chunkingThreshold(); + } + + public long getChunkingThreshold() { + return chunkingThreshold; + } + + public void uploadChunked(RemoteActionExecutionContext context, Digest blobDigest, Path file) + throws IOException, InterruptedException { + List chunkDigests; + try (InputStream input = file.getInputStream()) { + chunkDigests = chunker.chunkToDigests(input); + } + if (chunkDigests.isEmpty()) { + return; + } + + ImmutableSet missingDigests = getFromFuture(grpcCacheClient.findMissingDigests(context, chunkDigests)); + uploadMissingChunks(context, missingDigests, chunkDigests, file); + getFromFuture(grpcCacheClient.spliceBlob(context, blobDigest, chunkDigests)); + } + + private void uploadMissingChunks( + RemoteActionExecutionContext context, + ImmutableSet missingDigests, + List chunkDigests, + Path file) + throws IOException, InterruptedException { + if (missingDigests.isEmpty()) { + return; + } + + Set uploaded = new HashSet<>(); + try (InputStream input = file.getInputStream()) { + for (Digest chunkDigest : chunkDigests) { + if (missingDigests.contains(chunkDigest) && uploaded.add(chunkDigest)) { + ByteString.Output out = ByteString.newOutput((int) chunkDigest.getSizeBytes()); + ByteStreams.limit(input, chunkDigest.getSizeBytes()).transferTo(out); + getFromFuture(combinedCache.uploadBlob(context, chunkDigest, out.toByteString())); + } else { + input.skipNBytes(chunkDigest.getSizeBytes()); + } + } + } + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/CombinedCache.java b/src/main/java/com/google/devtools/build/lib/remote/CombinedCache.java index 26cca8fe703b5d..b0ddd989be83a2 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/CombinedCache.java +++ b/src/main/java/com/google/devtools/build/lib/remote/CombinedCache.java @@ -32,9 +32,12 @@ import com.google.common.flogger.GoogleLogger; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; import com.google.devtools.build.lib.concurrent.ThreadSafety; import com.google.devtools.build.lib.exec.SpawnCheckingCacheEvent; import com.google.devtools.build.lib.exec.SpawnProgressEvent; +import com.google.devtools.build.lib.remote.chunking.ChunkingConfig; import com.google.devtools.build.lib.remote.common.CacheNotFoundException; import com.google.devtools.build.lib.remote.common.LazyFileOutputStream; import com.google.devtools.build.lib.remote.common.OutputDigestMismatchException; @@ -64,6 +67,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -93,9 +97,17 @@ public class CombinedCache extends AbstractReferenceCounted { private final CountDownLatch closeCountDownLatch = new CountDownLatch(1); protected final AsyncTaskCache.NoResult casUploadCache = AsyncTaskCache.NoResult.create(); + @SuppressWarnings("AllowVirtualThreads") + private final ListeningExecutorService virtualThreadExecutor = + MoreExecutors.listeningDecorator( + Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("combined-cache-", 0).factory())); + @Nullable protected final RemoteCacheClient remoteCacheClient; @Nullable protected final DiskCacheClient diskCacheClient; @Nullable protected final String symlinkTemplate; + @Nullable private final ChunkingConfig chunkingConfig; + @Nullable private final ChunkedBlobDownloader chunkedDownloader; + @Nullable private final ChunkedBlobUploader chunkedUploader; protected final DigestUtil digestUtil; public CombinedCache( @@ -110,6 +122,18 @@ public CombinedCache( this.diskCacheClient = diskCacheClient; this.symlinkTemplate = symlinkTemplate; this.digestUtil = digestUtil; + + if (remoteCacheClient instanceof GrpcCacheClient grpcClient + && grpcClient.getChunkingConfig() != null) { + ChunkingConfig config = grpcClient.getChunkingConfig(); + this.chunkingConfig = config; + this.chunkedDownloader = new ChunkedBlobDownloader(grpcClient, this); + this.chunkedUploader = new ChunkedBlobUploader(grpcClient, this, config, digestUtil); + } else { + this.chunkingConfig = null; + this.chunkedDownloader = null; + this.chunkedUploader = null; + } } public CacheCapabilities getRemoteCacheCapabilities() throws IOException { @@ -130,6 +154,11 @@ public ServerCapabilities getRemoteServerCapabilities() throws IOException { return remoteCacheClient.getServerCapabilities(); } + @Nullable + public ChunkingConfig getChunkingConfig() { + return chunkingConfig; + } + /** * Class to keep track of which cache (disk or remote) a given [cached] ActionResult comes from. */ @@ -315,13 +344,21 @@ protected ListenableFuture uploadFile( ListenableFuture remoteCacheFuture = Futures.immediateVoidFuture(); if (remoteCacheClient != null && context.getWriteCachePolicy().allowRemoteCache()) { - Completable upload = - casUploadCache.execute( - digest, - RxFutures.toCompletable( - () -> remoteCacheClient.uploadFile(context, digest, file), directExecutor()), - force); - remoteCacheFuture = RxFutures.toListenableFuture(upload); + if (chunkedUploader != null + && digest.getSizeBytes() > chunkingConfig.chunkingThreshold()) { + remoteCacheFuture = virtualThreadExecutor.submit(() -> { + chunkedUploader.uploadChunked(context, digest, file); + return null; + }); + } else { + Completable upload = + casUploadCache.execute( + digest, + RxFutures.toCompletable( + () -> remoteCacheClient.uploadFile(context, digest, file), directExecutor()), + force); + remoteCacheFuture = RxFutures.toListenableFuture(upload); + } } return Futures.whenAllSucceed(diskCacheFuture, remoteCacheFuture) @@ -416,7 +453,7 @@ private ListenableFuture downloadBlob( directExecutor()); } - private ListenableFuture downloadBlob( + ListenableFuture downloadBlob( RemoteActionExecutionContext context, Digest digest, OutputStream out) { ListenableFuture future = immediateFailedFuture(new CacheNotFoundException(digest)); @@ -440,6 +477,27 @@ private ListenableFuture downloadBlobFromRemote( RemoteActionExecutionContext context, Digest digest, OutputStream out) { checkState(remoteCacheClient != null && context.getReadCachePolicy().allowRemoteCache()); + if (chunkedDownloader != null + && digest.getSizeBytes() > chunkingConfig.chunkingThreshold()) { + ListenableFuture chunkedDownloadFuture = + virtualThreadExecutor.submit(() -> { + chunkedDownloader.downloadChunked(context, digest, out); + return null; + }); + return Futures.catchingAsync( + chunkedDownloadFuture, + CacheNotFoundException.class, + (e) -> regularDownloadBlobFromRemote(context, digest, out), + directExecutor()); + } + + return regularDownloadBlobFromRemote(context, digest, out); + } + + private ListenableFuture regularDownloadBlobFromRemote( + RemoteActionExecutionContext context, Digest digest, OutputStream out) { + checkState(remoteCacheClient != null && context.getReadCachePolicy().allowRemoteCache()); + if (diskCacheClient != null && context.getWriteCachePolicy().allowDiskCache()) { Path tempPath = diskCacheClient.getTempPath(); LazyFileOutputStream tempOut = new LazyFileOutputStream(tempPath); diff --git a/src/main/java/com/google/devtools/build/lib/remote/GrpcCacheClient.java b/src/main/java/com/google/devtools/build/lib/remote/GrpcCacheClient.java index 345744ce70cc6d..a73c3dfb614393 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/GrpcCacheClient.java +++ b/src/main/java/com/google/devtools/build/lib/remote/GrpcCacheClient.java @@ -15,11 +15,13 @@ package com.google.devtools.build.lib.remote; import static com.google.common.base.Strings.isNullOrEmpty; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static com.google.devtools.build.lib.remote.util.DigestUtil.isOldStyleDigestFunction; import build.bazel.remote.execution.v2.ActionCacheGrpc; import build.bazel.remote.execution.v2.ActionCacheGrpc.ActionCacheFutureStub; import build.bazel.remote.execution.v2.ActionResult; +import build.bazel.remote.execution.v2.ChunkingFunction; import build.bazel.remote.execution.v2.ContentAddressableStorageGrpc; import build.bazel.remote.execution.v2.ContentAddressableStorageGrpc.ContentAddressableStorageFutureStub; import build.bazel.remote.execution.v2.Digest; @@ -29,6 +31,9 @@ import build.bazel.remote.execution.v2.GetActionResultRequest; import build.bazel.remote.execution.v2.RequestMetadata; import build.bazel.remote.execution.v2.ServerCapabilities; +import build.bazel.remote.execution.v2.SpliceBlobRequest; +import build.bazel.remote.execution.v2.SplitBlobRequest; +import build.bazel.remote.execution.v2.SplitBlobResponse; import build.bazel.remote.execution.v2.UpdateActionResultRequest; import com.google.bytestream.ByteStreamGrpc; import com.google.bytestream.ByteStreamGrpc.ByteStreamStub; @@ -49,6 +54,7 @@ import com.google.devtools.build.lib.authandtls.CallCredentialsProvider; import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe; import com.google.devtools.build.lib.remote.RemoteRetrier.ProgressiveBackoff; +import com.google.devtools.build.lib.remote.chunking.ChunkingConfig; import com.google.devtools.build.lib.remote.common.CacheNotFoundException; import com.google.devtools.build.lib.remote.common.MissingDigestsFinder; import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; @@ -88,6 +94,7 @@ public class GrpcCacheClient implements RemoteCacheClient, MissingDigestsFinder private final RemoteRetrier retrier; private final ByteStreamUploader uploader; private final int maxMissingBlobsDigestsPerMessage; + @Nullable private final ChunkingConfig chunkingConfig; private final AtomicBoolean closed = new AtomicBoolean(); @@ -97,12 +104,14 @@ public GrpcCacheClient( CallCredentialsProvider callCredentialsProvider, RemoteOptions options, RemoteRetrier retrier, - DigestUtil digestUtil) { + DigestUtil digestUtil, + @Nullable ChunkingConfig chunkingConfig) { this.callCredentialsProvider = callCredentialsProvider; this.channel = channel; this.options = options; this.digestUtil = digestUtil; this.retrier = retrier; + this.chunkingConfig = chunkingConfig; this.uploader = new ByteStreamUploader( options.remoteInstanceName, @@ -117,6 +126,7 @@ public GrpcCacheClient( maxMissingBlobsDigestsPerMessage > 0, "Error: gRPC message size too small."); } + private int computeMaxMissingBlobsDigestsPerMessage() { final int overhead = FindMissingBlobsRequest.newBuilder() @@ -164,6 +174,61 @@ private ActionCacheFutureStub acFutureStub( .withDeadlineAfter(options.remoteTimeout.toSeconds(), TimeUnit.SECONDS); } + @Override + public ListenableFuture spliceBlob( + RemoteActionExecutionContext context, + Digest blobDigest, + List chunkDigests) { + if (!options.experimentalRemoteCacheChunking) { + return null; + } + SpliceBlobRequest request = + SpliceBlobRequest.newBuilder() + .setInstanceName(options.remoteInstanceName) + .setBlobDigest(blobDigest) + .addAllChunkDigests(chunkDigests) + .setDigestFunction(digestUtil.getDigestFunction()) + .setChunkingFunction(ChunkingFunction.Value.FAST_CDC_2020) + .build(); + return Futures.transform( + Utils.refreshIfUnauthenticatedAsync( + () -> + retrier.executeAsync( + () -> + channel.withChannelFuture( + ch -> casFutureStub(context, ch).spliceBlob(request))), + callCredentialsProvider), + unused -> null, + directExecutor()); + } + + /** + * Queries the server for chunk information about a blob using the SplitBlob RPC. + * + * @return a future with the split blob response, or null if chunking is not enabled + */ + @Nullable + public ListenableFuture splitBlob( + RemoteActionExecutionContext context, Digest digest) { + if (!options.experimentalRemoteCacheChunking) { + return null; + } + SplitBlobRequest request = + SplitBlobRequest.newBuilder() + .setInstanceName(options.remoteInstanceName) + .setBlobDigest(digest) + .setDigestFunction(digestUtil.getDigestFunction()) + .setChunkingFunction(ChunkingFunction.Value.FAST_CDC_2020) + .build(); + return Utils.refreshIfUnauthenticatedAsync( + () -> + retrier.executeAsync( + () -> + channel.withChannelFuture( + ch -> casFutureStub(context, ch).splitBlob(request))), + callCredentialsProvider); + } + @Override public void close() { if (closed.getAndSet(true)) { @@ -172,6 +237,11 @@ public void close() { channel.release(); } + @Nullable + public ChunkingConfig getChunkingConfig() { + return chunkingConfig; + } + /** Returns true if 'options.remoteCache' uses 'grpc' or an empty scheme */ public static boolean isRemoteCacheOptions(RemoteOptions options) { if (isNullOrEmpty(options.remoteCache)) { diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java index 7da614dfd33306..6904b18e1a22ba 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java +++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java @@ -18,6 +18,7 @@ import build.bazel.remote.execution.v2.Digest; import build.bazel.remote.execution.v2.DigestFunction; +import build.bazel.remote.execution.v2.ServerCapabilities; import com.github.benmanes.caffeine.cache.Cache; import com.google.auth.Credentials; import com.google.common.annotations.VisibleForTesting; @@ -64,6 +65,7 @@ import com.google.devtools.build.lib.remote.RemoteServerCapabilities.ServerCapabilitiesRequirement; import com.google.devtools.build.lib.remote.Retrier.ResultClassifier; import com.google.devtools.build.lib.remote.Retrier.ResultClassifier.Result; +import com.google.devtools.build.lib.remote.chunking.ChunkingConfig; import com.google.devtools.build.lib.remote.circuitbreaker.CircuitBreakerFactory; import com.google.devtools.build.lib.remote.common.RemoteCacheClient; import com.google.devtools.build.lib.remote.common.RemoteExecutionClient; @@ -713,9 +715,20 @@ public void beforeCommand(CommandEnvironment env) throws AbruptExitException { } } + ChunkingConfig chunkingConfig = null; + if (remoteOptions.experimentalRemoteCacheChunking) { + try { + ServerCapabilities capabilities = cacheChannel.getServerCapabilities(); + chunkingConfig = ChunkingConfig.fromServerCapabilities(capabilities); + } catch (IOException e) { + chunkingConfig = ChunkingConfig.defaults(); + } + } + RemoteCacheClient remoteCacheClient = new GrpcCacheClient( - cacheChannel.retain(), callCredentialsProvider, remoteOptions, retrier, digestUtil); + cacheChannel.retain(), callCredentialsProvider, remoteOptions, retrier, digestUtil, + chunkingConfig); cacheChannel.release(); DiskCacheClient diskCacheClient = null; diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteServerCapabilities.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteServerCapabilities.java index 6028de5b1d4257..6d038e77e769bb 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/RemoteServerCapabilities.java +++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteServerCapabilities.java @@ -246,6 +246,24 @@ public static ClientServerCompatibilityStatus checkClientServerCompatibility( "--remote_cache_compression requested but remote does not support compression"); } + if (remoteOptions.experimentalRemoteCacheChunking) { + if (!cacheCap.getSplitBlobSupport()) { + result.addError( + "--experimental_remote_cache_chunking requested but remote does not support" + + " SplitBlob"); + } + if (!cacheCap.getSpliceBlobSupport()) { + result.addError( + "--experimental_remote_cache_chunking requested but remote does not support" + + " SpliceBlob"); + } + if (!cacheCap.hasFastCdc2020Params()) { + result.addError( + "--experimental_remote_cache_chunking requested but remote does not support" + + " FastCDC 2020 chunking algorithm"); + } + } + // Check result cache priority is in the supported range. checkPriorityInRange( remoteOptions.remoteResultCachePriority, diff --git a/src/main/java/com/google/devtools/build/lib/remote/chunking/BUILD b/src/main/java/com/google/devtools/build/lib/remote/chunking/BUILD new file mode 100644 index 00000000000000..f3eb55bdca3f93 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/chunking/BUILD @@ -0,0 +1,26 @@ +load("@rules_java//java:defs.bzl", "java_library") + +package( + default_applicable_licenses = ["//:license"], + default_visibility = ["//src:__subpackages__"], +) + +filegroup( + name = "srcs", + srcs = glob(["*"]), + visibility = ["//src:__subpackages__"], +) + +java_library( + name = "chunking", + srcs = [ + "ChunkingConfig.java", + "FastCDCChunker.java" + ], + deps = [ + "//src/main/java/com/google/devtools/build/lib/remote/options", + "//src/main/java/com/google/devtools/build/lib/remote/util:digest_utils", + "//third_party:guava", + "@remoteapis//:build_bazel_remote_execution_v2_remote_execution_java_proto", + ], +) diff --git a/src/main/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfig.java b/src/main/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfig.java new file mode 100644 index 00000000000000..723cd6dc1246ce --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfig.java @@ -0,0 +1,72 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +package com.google.devtools.build.lib.remote.chunking; + +import build.bazel.remote.execution.v2.CacheCapabilities; +import build.bazel.remote.execution.v2.FastCdc2020Params; +import build.bazel.remote.execution.v2.ServerCapabilities; + +/** Configuration for content-defined chunking. All sizes are in bytes. */ +public record ChunkingConfig(int avgChunkSize, int normalizationLevel, int seed) { + + public static final int DEFAULT_AVG_CHUNK_SIZE = 512 * 1024; + public static final int DEFAULT_NORMALIZATION_LEVEL = 2; + public static final int DEFAULT_SEED = 0; + + public int minChunkSize() { + return avgChunkSize / 4; + } + + public int maxChunkSize() { + return avgChunkSize * 4; + } + + /** Blobs larger than this should be chunked. Equal to maxChunkSize(). */ + public long chunkingThreshold() { + return maxChunkSize(); + } + + public static ChunkingConfig defaults() { + return new ChunkingConfig( + DEFAULT_AVG_CHUNK_SIZE, + DEFAULT_NORMALIZATION_LEVEL, + DEFAULT_SEED); + } + + public static ChunkingConfig fromServerCapabilities(ServerCapabilities capabilities) { + if (!capabilities.hasCacheCapabilities()) { + return null; + } + CacheCapabilities cacheCap = capabilities.getCacheCapabilities(); + + if (!cacheCap.hasFastCdc2020Params()) { + return null; + } + + FastCdc2020Params params = cacheCap.getFastCdc2020Params(); + int avgSize = DEFAULT_AVG_CHUNK_SIZE; + int seed = DEFAULT_SEED; + + long configAvgSize = params.getAvgChunkSizeBytes(); + if (configAvgSize >= 1024 + && configAvgSize <= 1024 * 1024 + && (configAvgSize & (configAvgSize - 1)) == 0) { + avgSize = (int) configAvgSize; + } + seed = params.getSeed(); + + return new ChunkingConfig(avgSize, DEFAULT_NORMALIZATION_LEVEL, seed); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunker.java b/src/main/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunker.java new file mode 100644 index 00000000000000..964ed5802951fe --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunker.java @@ -0,0 +1,300 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +package com.google.devtools.build.lib.remote.chunking; + +import static com.google.common.base.Preconditions.checkArgument; + +import build.bazel.remote.execution.v2.Digest; +import com.google.devtools.build.lib.remote.util.DigestUtil; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * FastCDC 2020 implementation for splitting large blobs. + * + *

+ * This module implements the canonical FastCDC algorithm as described in the + * [paper](https://ieeexplore.ieee.org/document/9055082) by Wen Xia, et al., in + * 2020. + */ +public final class FastCDCChunker { + + // Masks for each of the desired number of bits, where 0 through 5 are unused. + // The values for sizes 64 bytes through 128 kilo-bytes come from the C + // reference implementation (found in the destor repository) while the extra + // values come from the restic-FastCDC repository. The FastCDC paper claims that + // the deduplication ratio is slightly improved when the mask bits are spread + // relatively evenly, hence these seemingly "magic" values. + // @formatter:off + private static final long[] MASKS = { + 0, // 0: padding + 0, // 1: padding + 0, // 2: padding + 0, // 3: padding + 0, // 4: padding + 0x0000000001804110L, // 5: unused except for NC 3 + 0x0000000001803110L, // 6: 64B + 0x0000000018035100L, // 7: 128B + 0x0000001800035300L, // 8: 256B + 0x0000019000353000L, // 9: 512B + 0x0000590003530000L, // 10: 1KB + 0x0000d90003530000L, // 11: 2KB + 0x0000d90103530000L, // 12: 4KB + 0x0000d90303530000L, // 13: 8KB + 0x0000d90313530000L, // 14: 16KB + 0x0000d90f03530000L, // 15: 32KB + 0x0000d90303537000L, // 16: 64KB + 0x0000d90703537000L, // 17: 128KB + 0x0000d90707537000L, // 18: 256KB + 0x0000d91707537000L, // 19: 512KB + 0x0000d91747537000L, // 20: 1MB + 0x0000d91767537000L, // 21: 2MB + 0x0000d93767537000L, // 22: 4MB + 0x0000d93777537000L, // 23: 8MB + 0x0000d93777577000L, // 24: 16MB + 0x0000db3777577000L, // 25: unused except for NC 3 + }; + + // GEAR contains seemingly random numbers which are created by computing the MD5 digest of values + // from 0 to 255, using only the high 8 bytes of the 16-byte digest. This is the "gear hash" + // referred to in the FastCDC paper. + private static final long[] GEAR = { + 0x3b5d3c7d207e37dcL, 0x784d68ba91123086L, 0xcd52880f882e7298L, 0xeacf8e4e19fdcca7L, + 0xc31f385dfbd1632bL, 0x1d5f27001e25abe6L, 0x83130bde3c9ad991L, 0xc4b225676e9b7649L, + 0xaa329b29e08eb499L, 0xb67fcbd21e577d58L, 0x0027baaada2acf6bL, 0xe3ef2d5ac73c2226L, + 0x0890f24d6ed312b7L, 0xa809e036851d7c7eL, 0xf0a6fe5e0013d81bL, 0x1d026304452cec14L, + 0x03864632648e248fL, 0xcdaacf3dcd92b9b4L, 0xf5e012e63c187856L, 0x8862f9d3821c00b6L, + 0xa82f7338750f6f8aL, 0x1e583dc6c1cb0b6fL, 0x7a3145b69743a7f1L, 0xabb20fee404807ebL, + 0xb14b3cfe07b83a5dL, 0xb9dc27898adb9a0fL, 0x3703f5e91baa62beL, 0xcf0bb866815f7d98L, + 0x3d9867c41ea9dcd3L, 0x1be1fa65442bf22cL, 0x14300da4c55631d9L, 0xe698e9cbc6545c99L, + 0x4763107ec64e92a5L, 0xc65821fc65696a24L, 0x76196c064822f0b7L, 0x485be841f3525e01L, + 0xf652bc9c85974ff5L, 0xcad8352face9e3e9L, 0x2a6ed1dceb35e98eL, 0xc6f483badc11680fL, + 0x3cfd8c17e9cf12f1L, 0x89b83c5e2ea56471L, 0xae665cfd24e392a9L, 0xec33c4e504cb8915L, + 0x3fb9b15fc9fe7451L, 0xd7fd1fd1945f2195L, 0x31ade0853443efd8L, 0x255efc9863e1e2d2L, + 0x10eab6008d5642cfL, 0x46f04863257ac804L, 0xa52dc42a789a27d3L, 0xdaaadf9ce77af565L, + 0x6b479cd53d87febbL, 0x6309e2d3f93db72fL, 0xc5738ffbaa1ff9d6L, 0x6bd57f3f25af7968L, + 0x67605486d90d0a4aL, 0xe14d0b9663bfbdaeL, 0xb7bbd8d816eb0414L, 0xdef8a4f16b35a116L, + 0xe7932d85aaaffed6L, 0x08161cbae90cfd48L, 0x855507beb294f08bL, 0x91234ea6ffd399b2L, + 0xad70cf4b2435f302L, 0xd289a97565bc2d27L, 0x8e558437ffca99deL, 0x96d2704b7115c040L, + 0x0889bbcdfc660e41L, 0x5e0d4e67dc92128dL, 0x72a9f8917063ed97L, 0x438b69d409e016e3L, + 0xdf4fed8a5d8a4397L, 0x00f41dcf41d403f7L, 0x4814eb038e52603fL, 0x9dafbacc58e2d651L, + 0xfe2f458e4be170afL, 0x4457ec414df6a940L, 0x06e62f1451123314L, 0xbd1014d173ba92ccL, + 0xdef318e25ed57760L, 0x9fea0de9dfca8525L, 0x459de1e76c20624bL, 0xaeec189617e2d666L, + 0x126a2c06ab5a83cbL, 0xb1321532360f6132L, 0x65421503dbb40123L, 0x2d67c287ea089ab3L, + 0x6c93bff5a56bd6b6L, 0x4ffb2036cab6d98dL, 0xce7b785b1be7ad4fL, 0xedb42ef6189fd163L, + 0xdc905288703988f6L, 0x365f9c1d2c691884L, 0xc640583680d99bfeL, 0x3cd4624c07593ec6L, + 0x7f1ea8d85d7c5805L, 0x014842d480b57149L, 0x0b649bcb5a828688L, 0xbcd5708ed79b18f0L, + 0xe987c862fbd2f2f0L, 0x982731671f0cd82cL, 0xbaf13e8b16d8c063L, 0x8ea3109cbd951bbaL, + 0xd141045bfb385cadL, 0x2acbc1a0af1f7d30L, 0xe6444d89df03bfdfL, 0xa18cc771b8188ff9L, + 0x9834429db01c39bbL, 0x214add07fe086a1fL, 0x8f07c19b1f6b3ff9L, 0x56a297b1bf4ffe55L, + 0x94d558e493c54fc7L, 0x40bfc24c764552cbL, 0x931a706f8a8520cbL, 0x32229d322935bd52L, + 0x2560d0f5dc4fefafL, 0x9dbcc48355969bb6L, 0x0fd81c3985c0b56aL, 0xe03817e1560f2bdaL, + 0xc1bb4f81d892b2d5L, 0xb0c4864f4e28d2d7L, 0x3ecc49f9d9d6c263L, 0x51307e99b52ba65eL, + 0x8af2b688da84a752L, 0xf5d72523b91b20b6L, 0x6d95ff1ff4634806L, 0x562f21555458339aL, + 0xc0ce47f889336346L, 0x487823e5089b40d8L, 0xe4727c7ebc6d9592L, 0x5a8f7277e94970baL, + 0xfca2f406b1c8bb50L, 0x5b1f8a95f1791070L, 0xd304af9fc9028605L, 0x5440ab7fc930e748L, + 0x312d25fbca2ab5a1L, 0x10f4a4b234a4d575L, 0x90301d55047e7473L, 0x3b6372886c61591eL, + 0x293402b77c444e06L, 0x451f34a4d3e97dd7L, 0x3158d814d81bc57bL, 0x034942425b9bda69L, + 0xe2032ff9e532d9bbL, 0x62ae066b8b2179e5L, 0x9545e10c2f8d71d8L, 0x7ff7483eb2d23fc0L, + 0x00945fcebdc98d86L, 0x8764bbbe99b26ca2L, 0x1b1ec62284c0bfc3L, 0x58e0fcc4f0aa362bL, + 0x5f4abefa878d458dL, 0xfd74ac2f9607c519L, 0xa4e3fb37df8cbfa9L, 0xbf697e43cac574e5L, + 0x86f14a3f68f4cd53L, 0x24a23d076f1ce522L, 0xe725cd8048868cc8L, 0xbf3c729eb2464362L, + 0xd8f6cd57b3cc1ed8L, 0x6329e52425541577L, 0x62aa688ad5ae1ac0L, 0x0a242566269bf845L, + 0x168b1a4753aca74bL, 0xf789afefff2e7e3cL, 0x6c3362093b6fccdbL, 0x4ce8f50bd28c09b2L, + 0x006a2db95ae8aa93L, 0x975b0d623c3d1a8cL, 0x18605d3935338c5bL, 0x5bb6f6136cad3c71L, + 0x0f53a20701f8d8a6L, 0xab8c5ad2e7e93c67L, 0x40b5ac5127acaa29L, 0x8c7bf63c2075895fL, + 0x78bd9f7e014a805cL, 0xb2c9e9f4f9c8c032L, 0xefd6049827eb91f3L, 0x2be459f482c16fbdL, + 0xd92ce0c5745aaa8cL, 0x0aaa8fb298d965b9L, 0x2b37f92c6c803b15L, 0x8c54a5e94e0f0e78L, + 0x95f9b6e90c0a3032L, 0xe7939faa436c7874L, 0xd16bfe8f6a8a40c9L, 0x44982b86263fd2faL, + 0xe285fb39f984e583L, 0x779a8df72d7619d3L, 0xf2d79a8de8d5dd1eL, 0xd1037354d66684e2L, + 0x004c82a4e668a8e5L, 0x31d40a7668b044e6L, 0xd70578538bd02c11L, 0xdb45431078c5f482L, + 0x977121bb7f6a51adL, 0x73d5ccbd34eff8ddL, 0xe437a07d356e17cdL, 0x47b2782043c95627L, + 0x9fb251413e41d49aL, 0xccd70b60652513d3L, 0x1c95b31e8a1b49b2L, 0xcae73dfd1bcb4c1bL, + 0x34d98331b1f5b70fL, 0x784e39f22338d92fL, 0x18613d4a064df420L, 0xf1d8dae25f0bcebeL, + 0x33f77c15ae855efcL, 0x3c88b3b912eb109cL, 0x956a2ec96bafeea5L, 0x1aa005b5e0ad0e87L, + 0x5500d70527c4bb8eL, 0xe36c57196421cc44L, 0x13c4d286cc36ee39L, 0x5654a23d818b2a81L, + 0x77b1dc13d161abdcL, 0x734f44de5f8d5eb5L, 0x60717e174a6c89a2L, 0xd47d9649266a211eL, + 0x5b13a4322bb69e90L, 0xf7669609f8b5fc3cL, 0x21e6ac55bedcdac9L, 0x9b56b62b61166deaL, + 0xf48f66b939797e9cL, 0x35f332f9c0e6ae9aL, 0xcc733f6a9a878db0L, 0x3da161e41cc108c2L, + 0xb7d74ae535914d51L, 0x4d493b0b11d36469L, 0xce264d1dfba9741aL, 0xa9d1f2dc7436dc06L, + 0x70738016604c2a27L, 0x231d36e96e93f3d5L, 0x7666881197838d19L, 0x4a2a83090aaad40cL, + 0xf1e761591668b35dL, 0x7363236497f730a7L, 0x301080e37379dd4dL, 0x502dea2971827042L, + 0xc2c5eb858f32625fL, 0x786afb9edfafbdffL, 0xdaee0d868490b2a4L, 0x617366b3268609f6L, + 0xae0e35a0fe46173eL, 0xd1a07de93e824f11L, 0x079b8b115ea4cca8L, 0x93a99274558faebbL, + 0xfb1e6e22e08a03b3L, 0xea635fdba3698dd0L, 0xcf53659328503a5cL, 0xcde3b31e6fd5d780L, + 0x8e3e4221d3614413L, 0xef14d0d86bf1a22cL, 0xe1d830d3f16c5ddbL, 0xaabd2b2a451504e1L, + }; + // @formatter:on + + private static final long[] GEAR_LS = computeGearLs(); + + private static long[] computeGearLs() { + long[] gearLs = new long[GEAR.length]; + for (int i = 0; i < GEAR.length; i++) { + gearLs[i] = GEAR[i] << 1; + } + return gearLs; + } + + private final int minSize; + private final int maxSize; + private final int avgSize; + private final long maskS; + private final long maskL; + private final long maskSLs; + private final long maskLLs; + private final long seed; + private final long shiftedSeed; + private final DigestUtil digestUtil; + + public FastCDCChunker(DigestUtil digestUtil) { + this(ChunkingConfig.defaults(), digestUtil); + } + + public FastCDCChunker(ChunkingConfig config, DigestUtil digestUtil) { + this(config.minChunkSize(), config.avgChunkSize(), config.maxChunkSize(), + config.normalizationLevel(), Integer.toUnsignedLong(config.seed()), digestUtil); + } + + public FastCDCChunker( + int minSize, int avgSize, int maxSize, int normalization, long seed, + DigestUtil digestUtil) { + checkArgument(minSize > 0, "minSize must be positive"); + checkArgument(avgSize >= minSize, "avgSize must be >= minSize"); + checkArgument(maxSize >= avgSize, "maxSize must be >= avgSize"); + checkArgument((avgSize & (avgSize - 1)) == 0, "avgSize must be a power of 2, got %s", avgSize); + checkArgument(normalization >= 0 && normalization <= 3, "normalization must be 0-3"); + + this.minSize = minSize; + this.avgSize = avgSize; + this.maxSize = maxSize; + this.digestUtil = digestUtil; + + int bits = 31 - Integer.numberOfLeadingZeros(avgSize); + int smallBits = bits + normalization; + int largeBits = bits - normalization; + checkArgument(smallBits <= 25 && largeBits >= 5, "normalization level too extreme for avgSize"); + + this.maskS = MASKS[smallBits]; + this.maskL = MASKS[largeBits]; + this.maskSLs = this.maskS << 1; + this.maskLLs = this.maskL << 1; + + this.seed = seed; + this.shiftedSeed = seed << 1; + } + + /** + * Finds the next chunk boundary. + */ + private int cut(byte[] buf, int off, int len) { + if (len <= minSize) { + return len; + } + + int n = Math.min(len, maxSize); + int center = Math.min(n, avgSize); + + // Round down to even boundaries for 2-byte processing so we don't need to + // divide by 2 in the loop. + int minLimit = minSize & ~1; + int centerLimit = center & ~1; + int remainingLimit = n & ~1; + + long s = this.seed; + long sLs = this.shiftedSeed; + long hash = 0; + + // Below avgSize: use maskS to discourage early cuts (too small chunks) + for (int a = minLimit; a < centerLimit; a += 2) { + hash = (hash << 2) + (GEAR_LS[buf[off + a] & 0xFF] ^ sLs); + if ((hash & maskSLs) == 0) { + return a; + } + hash = hash + (GEAR[buf[off + a + 1] & 0xFF] ^ s); + if ((hash & maskS) == 0) { + return a + 1; + } + } + + // Above avgSize: use maskL to encourage cuts (too large chunks) + for (int a = centerLimit; a < remainingLimit; a += 2) { + hash = (hash << 2) + (GEAR_LS[buf[off + a] & 0xFF] ^ sLs); + if ((hash & maskLLs) == 0) { + return a; + } + hash = hash + (GEAR[buf[off + a + 1] & 0xFF] ^ s); + if ((hash & maskL) == 0) { + return a + 1; + } + } + + return n; + } + + /** + * Chunks a file and returns chunk digests. + * + *

+ * This method is used for building MerkleTree entries for large files. It + * returns the content digests in order for each chunk. + * + *

+ * Note: We don't need the raw data here. We can read from the original file + * (seekable) when uploading, similar to how whole blobs work. + */ + public List chunkToDigests(InputStream input) throws IOException { + List digests = new ArrayList<>(); + + byte[] buf = new byte[maxSize * 2]; + int cursor = 0; + int end = 0; + boolean eof = false; + + while (true) { + int available = end - cursor; + if (available < maxSize && !eof) { + if (cursor > 0 && available > 0) { + System.arraycopy(buf, cursor, buf, 0, available); + } + cursor = 0; + end = available; + + while (end < buf.length) { + int n = input.read(buf, end, buf.length - end); + if (n == -1) { + eof = true; + break; + } + end += n; + } + available = end - cursor; + } + + if (available == 0) { + break; + } + + int chunkLen = cut(buf, cursor, available); + digests.add(digestUtil.compute(buf, cursor, chunkLen)); + + cursor += chunkLen; + } + + return digests; + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/common/RemoteCacheClient.java b/src/main/java/com/google/devtools/build/lib/remote/common/RemoteCacheClient.java index f5160436969e40..7a25425ff8ee63 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/common/RemoteCacheClient.java +++ b/src/main/java/com/google/devtools/build/lib/remote/common/RemoteCacheClient.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.List; import java.util.Set; import javax.annotation.Nullable; @@ -160,6 +161,26 @@ default ListenableFuture uploadBlob( return uploadBlob(context, digest, data::newInput); } + /** + * Registers a blob as the concatenation of the given chunks via SpliceBlob RPC. + * + *

This is used for CDC (Content-Defined Chunking) uploads. After uploading all chunks, + * SpliceBlob is called to register the blob with the given digest as the concatenation of + * the chunks. + * + * @param context the context for the action. + * @param blobDigest The digest of the complete blob. + * @param chunkDigests The digests of the chunks that make up the blob, in order. + * @return A future representing pending completion of the splice operation, or null if + * SpliceBlob is not supported by this cache client. + */ + default ListenableFuture spliceBlob( + RemoteActionExecutionContext context, + Digest blobDigest, + List chunkDigests) { + return null; + } + /** Close resources associated with the remote cache. */ void close(); } diff --git a/src/main/java/com/google/devtools/build/lib/remote/logging/LoggingInterceptor.java b/src/main/java/com/google/devtools/build/lib/remote/logging/LoggingInterceptor.java index ce1d0fe77b3cfa..875b17e505d0b2 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/logging/LoggingInterceptor.java +++ b/src/main/java/com/google/devtools/build/lib/remote/logging/LoggingInterceptor.java @@ -70,6 +70,10 @@ protected LoggingHandler selectHandler(MethodDescriptor } else if (method == ContentAddressableStorageGrpc.getFindMissingBlobsMethod()) { return new FindMissingBlobsHandler(); // + } else if (method == ContentAddressableStorageGrpc.getSplitBlobMethod()) { + return new SplitBlobHandler(); // + } else if (method == ContentAddressableStorageGrpc.getSpliceBlobMethod()) { + return new SpliceBlobHandler(); // } else if (method == ByteStreamGrpc.getReadMethod()) { return new ReadHandler(); // } else if (method == ByteStreamGrpc.getWriteMethod()) { diff --git a/src/main/java/com/google/devtools/build/lib/remote/logging/SpliceBlobHandler.java b/src/main/java/com/google/devtools/build/lib/remote/logging/SpliceBlobHandler.java new file mode 100644 index 00000000000000..d5ff02b78b287a --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/logging/SpliceBlobHandler.java @@ -0,0 +1,44 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +package com.google.devtools.build.lib.remote.logging; + +import build.bazel.remote.execution.v2.SpliceBlobRequest; +import build.bazel.remote.execution.v2.SpliceBlobResponse; +import com.google.devtools.build.lib.remote.logging.RemoteExecutionLog.SpliceBlobDetails; +import com.google.devtools.build.lib.remote.logging.RemoteExecutionLog.RpcCallDetails; + +/** + * LoggingHandler for {@link + * build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob} gRPC call. + */ +public class SpliceBlobHandler implements LoggingHandler { + + private final SpliceBlobDetails.Builder builder = SpliceBlobDetails.newBuilder(); + + @Override + public void handleReq(SpliceBlobRequest message) { + builder.setRequest(message); + } + + @Override + public void handleResp(SpliceBlobResponse message) { + builder.setResponse(message); + } + + @Override + public RpcCallDetails getDetails() { + return RpcCallDetails.newBuilder().setSpliceBlob(builder).build(); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/logging/SplitBlobHandler.java b/src/main/java/com/google/devtools/build/lib/remote/logging/SplitBlobHandler.java new file mode 100644 index 00000000000000..088437b06abddb --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/logging/SplitBlobHandler.java @@ -0,0 +1,45 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +package com.google.devtools.build.lib.remote.logging; + +import build.bazel.remote.execution.v2.SplitBlobRequest; +import build.bazel.remote.execution.v2.SplitBlobResponse; +import com.google.devtools.build.lib.remote.logging.RemoteExecutionLog.SplitBlobDetails; +import com.google.devtools.build.lib.remote.logging.RemoteExecutionLog.RpcCallDetails; + +/** + * LoggingHandler for {@link + * build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob} gRPC call. + */ +public class SplitBlobHandler + implements LoggingHandler { + + private final SplitBlobDetails.Builder builder = SplitBlobDetails.newBuilder(); + + @Override + public void handleReq(SplitBlobRequest message) { + builder.setRequest(message); + } + + @Override + public void handleResp(SplitBlobResponse message) { + builder.setResponse(message); + } + + @Override + public RpcCallDetails getDetails() { + return RpcCallDetails.newBuilder().setSplitBlob(builder).build(); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java b/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java index ea1b1992f70b38..46fcb99378703b 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java +++ b/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java @@ -723,6 +723,19 @@ public RemoteOutputsStrategyConverter() { + " output prefixes).") public Scrubber scrubber; + @Option( + name = "experimental_remote_cache_chunking", + defaultValue = "false", + documentationCategory = OptionDocumentationCategory.REMOTE, + metadataTags = OptionMetadataTag.EXPERIMENTAL, + effectTags = {OptionEffectTag.UNKNOWN}, + help = + "If enabled, large blobs are split into content-defined chunks using FastCDC 2020 and " + + "uploaded/downloaded in chunks, enabling deduplication across blobs. The server " + + "must advertise SplitBlob/SpliceBlob RPCs and FastCDC 2020 parameters in its " + + "capabilities.") + public boolean experimentalRemoteCacheChunking; + @Option( name = "experimental_throttle_remote_action_building", defaultValue = "true", diff --git a/src/main/java/com/google/devtools/build/lib/remote/util/DigestUtil.java b/src/main/java/com/google/devtools/build/lib/remote/util/DigestUtil.java index 5c1f2b3c644bd3..978e9c077e2ba2 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/util/DigestUtil.java +++ b/src/main/java/com/google/devtools/build/lib/remote/util/DigestUtil.java @@ -69,6 +69,18 @@ public Digest compute(byte[] blob) { return buildDigest(hashFn.getHashFunction().hashBytes(blob).toString(), blob.length); } + /** + * Computes a digest for a portion of a byte array. This is useful for uploading + * an individual chunk from a larger file. + * + * @param data the byte array + * @param offset the start offset in the array + * @param length the number of bytes to hash + */ + public Digest compute(byte[] data, int offset, int length) { + return buildDigest(hashFn.getHashFunction().hashBytes(data, offset, length).toString(), length); + } + /** * Computes a digest for a file. * diff --git a/src/main/protobuf/remote_execution_log.proto b/src/main/protobuf/remote_execution_log.proto index b3f265aaca959d..7518e4dfd44586 100644 --- a/src/main/protobuf/remote_execution_log.proto +++ b/src/main/protobuf/remote_execution_log.proto @@ -112,6 +112,32 @@ message FindMissingBlobsDetails { build.bazel.remote.execution.v2.FindMissingBlobsResponse response = 2; } +// Details for a call to +// build.bazel.remote.execution.v2.ContentAddressableStorage.SplitBlob. +message SplitBlobDetails { + // The build.bazel.remote.execution.v2.SplitBlobRequest request + // sent. + build.bazel.remote.execution.v2.SplitBlobRequest request = 1; + + // The build.bazel.remote.execution.v2.SplitBlobResponse + // received. + build.bazel.remote.execution.v2.SplitBlobResponse response = 2; +} + + +// Details for a call to +// build.bazel.remote.execution.v2.ContentAddressableStorage.SpliceBlob. +message SpliceBlobDetails { + // The build.bazel.remote.execution.v2.SpliceBlobRequest request + // sent. + build.bazel.remote.execution.v2.SpliceBlobRequest request = 1; + + // The build.bazel.remote.execution.v2.SpliceBlobResponse + // received. + build.bazel.remote.execution.v2.SpliceBlobResponse response = 2; +} + + // Details for a call to google.bytestream.Read. message ReadDetails { // The google.bytestream.ReadRequest sent. @@ -178,5 +204,7 @@ message RpcCallDetails { QueryWriteStatusDetails query_write_status = 14; GetCapabilitiesDetails get_capabilities = 12; UpdateActionResultDetails update_action_result = 13; + SplitBlobDetails split_blob = 15; + SpliceBlobDetails splice_blob = 16; } } diff --git a/src/test/java/com/google/devtools/build/lib/remote/BUILD b/src/test/java/com/google/devtools/build/lib/remote/BUILD index 82a1d58547edde..5a515352abad4e 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/BUILD +++ b/src/test/java/com/google/devtools/build/lib/remote/BUILD @@ -10,6 +10,7 @@ filegroup( name = "srcs", testonly = 0, srcs = glob(["**"]) + [ + "//src/test/java/com/google/devtools/build/lib/remote/chunking:srcs", "//src/test/java/com/google/devtools/build/lib/remote/circuitbreaker:srcs", "//src/test/java/com/google/devtools/build/lib/remote/common:srcs", "//src/test/java/com/google/devtools/build/lib/remote/disk:srcs", @@ -63,6 +64,8 @@ java_library( "RemoteActionFileSystemTestBase.java", "BuildWithoutTheBytesIntegrationTest.java", "BuildWithoutTheBytesIntegrationTestBase.java", + "ChunkedCacheIntegrationTest.java", + "ChunkedDiskCacheIntegrationTest.java", "DiskCacheIntegrationTest.java", ], ), @@ -116,6 +119,7 @@ java_library( "//src/main/java/com/google/devtools/build/lib/remote/disk", "//src/main/java/com/google/devtools/build/lib/remote/downloader", "//src/main/java/com/google/devtools/build/lib/remote/http", + "//src/main/java/com/google/devtools/build/lib/remote/chunking", "//src/main/java/com/google/devtools/build/lib/remote/merkletree", "//src/main/java/com/google/devtools/build/lib/remote/options", "//src/main/java/com/google/devtools/build/lib/remote/util", @@ -270,6 +274,58 @@ java_test( ], ) +java_test( + name = "ChunkedCacheIntegrationTest", + srcs = ["ChunkedCacheIntegrationTest.java"], + jvm_flags = ["-Djava.lang.Thread.allowVirtualThreads=true"], + tags = ["requires-network"], + runtime_deps = [ + "//third_party/grpc-java:grpc-jar", + ], + deps = [ + "//src/main/java/com/google/devtools/build/lib/runtime", + "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper:credential_module", + "//src/main/java/com/google/devtools/build/lib/remote", + "//src/main/java/com/google/devtools/build/lib/remote/util", + "//src/main/java/com/google/devtools/build/lib/standalone", + "//src/main/java/com/google/devtools/build/lib/vfs", + "//src/test/java/com/google/devtools/build/lib/buildtool/util", + "//src/test/java/com/google/devtools/build/lib/remote/util:integration_test_utils", + "//third_party:guava", + "//third_party:junit4", + "//third_party:truth", + "@googleapis//google/bytestream:bytestream_java_grpc", + "@googleapis//google/bytestream:bytestream_java_proto", + "@grpc-java//api", + "@remoteapis//:build_bazel_remote_execution_v2_remote_execution_java_grpc", + "@remoteapis//:build_bazel_remote_execution_v2_remote_execution_java_proto", + ], +) + +java_test( + name = "ChunkedDiskCacheIntegrationTest", + srcs = ["ChunkedDiskCacheIntegrationTest.java"], + jvm_flags = ["-Djava.lang.Thread.allowVirtualThreads=true"], + tags = ["requires-network"], + runtime_deps = [ + "//third_party/grpc-java:grpc-jar", + ], + deps = [ + "//src/main/java/com/google/devtools/build/lib/runtime", + "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper:credential_module", + "//src/main/java/com/google/devtools/build/lib/remote", + "//src/main/java/com/google/devtools/build/lib/standalone", + "//src/main/java/com/google/devtools/build/lib/vfs", + "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment", + "//src/test/java/com/google/devtools/build/lib/buildtool/util", + "//src/test/java/com/google/devtools/build/lib/remote/util:integration_test_utils", + "//src/test/java/com/google/devtools/build/lib/testutil:TestUtils", + "//third_party:guava", + "//third_party:junit4", + "//third_party:truth", + ], +) + java_test( name = "DiskCacheIntegrationTest", srcs = ["DiskCacheIntegrationTest.java"], diff --git a/src/test/java/com/google/devtools/build/lib/remote/ByteStreamBuildEventArtifactUploaderTest.java b/src/test/java/com/google/devtools/build/lib/remote/ByteStreamBuildEventArtifactUploaderTest.java index 770df3ce036493..989fdee850a0d9 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/ByteStreamBuildEventArtifactUploaderTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/ByteStreamBuildEventArtifactUploaderTest.java @@ -531,7 +531,8 @@ private static CombinedCache newCombinedCache( CallCredentialsProvider.NO_CREDENTIALS, remoteOptions, retrier, - DIGEST_UTIL)); + DIGEST_UTIL, + /* chunkingConfig= */ null)); doAnswer( invocationOnMock -> missingDigestsFinder.findMissingDigests( diff --git a/src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloaderTest.java b/src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloaderTest.java new file mode 100644 index 00000000000000..71a22092e9001b --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobDownloaderTest.java @@ -0,0 +1,180 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import build.bazel.remote.execution.v2.Digest; +import build.bazel.remote.execution.v2.SplitBlobResponse; +import com.google.common.util.concurrent.Futures; +import com.google.devtools.build.lib.remote.common.CacheNotFoundException; +import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; +import com.google.devtools.build.lib.remote.util.DigestUtil; +import com.google.devtools.build.lib.vfs.DigestHashFunction; +import com.google.devtools.build.lib.vfs.SyscallCache; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Tests for {@link ChunkedBlobDownloader}. */ +@RunWith(JUnit4.class) +public class ChunkedBlobDownloaderTest { + private static final DigestUtil DIGEST_UTIL = + new DigestUtil(SyscallCache.NO_CACHE, DigestHashFunction.SHA256); + + @Mock private GrpcCacheClient grpcCacheClient; + @Mock private CombinedCache combinedCache; + @Mock private RemoteActionExecutionContext context; + + private ChunkedBlobDownloader downloader; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + downloader = new ChunkedBlobDownloader(grpcCacheClient, combinedCache); + } + + @Test + public void downloadChunked_splitBlobReturnsNull_throwsCacheNotFound() { + Digest blobDigest = DIGEST_UTIL.compute(new byte[]{1, 2, 3}); + when(grpcCacheClient.splitBlob(any(), eq(blobDigest))).thenReturn(null); + + assertThrows( + CacheNotFoundException.class, + () -> downloader.downloadChunked(context, blobDigest, new ByteArrayOutputStream())); + } + + @Test + public void downloadChunked_singleChunk_downloadsAndReassembles() throws Exception { + byte[] chunkData = new byte[]{1, 2, 3, 4, 5}; + Digest chunkDigest = DIGEST_UTIL.compute(chunkData); + Digest blobDigest = chunkDigest; + + SplitBlobResponse splitResponse = SplitBlobResponse.newBuilder() + .addChunkDigests(chunkDigest) + .build(); + when(grpcCacheClient.splitBlob(any(), eq(blobDigest))) + .thenReturn(Futures.immediateFuture(splitResponse)); + when(combinedCache.downloadBlob(any(), eq(chunkDigest), any())) + .thenAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + out.write(chunkData); + return Futures.immediateFuture(null); + }); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + downloader.downloadChunked(context, blobDigest, out); + + assertThat(out.toByteArray()).isEqualTo(chunkData); + } + + @Test + public void downloadChunked_multipleChunks_downloadsAndReassemblesInOrder() throws Exception { + byte[] chunk1Data = new byte[]{1, 2, 3}; + byte[] chunk2Data = new byte[]{4, 5, 6}; + byte[] chunk3Data = new byte[]{7, 8, 9}; + Digest chunk1Digest = DIGEST_UTIL.compute(chunk1Data); + Digest chunk2Digest = DIGEST_UTIL.compute(chunk2Data); + Digest chunk3Digest = DIGEST_UTIL.compute(chunk3Data); + Digest blobDigest = DIGEST_UTIL.compute(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9}); + + SplitBlobResponse splitResponse = SplitBlobResponse.newBuilder() + .addChunkDigests(chunk1Digest) + .addChunkDigests(chunk2Digest) + .addChunkDigests(chunk3Digest) + .build(); + when(grpcCacheClient.splitBlob(any(), eq(blobDigest))) + .thenReturn(Futures.immediateFuture(splitResponse)); + when(combinedCache.downloadBlob(any(), eq(chunk1Digest), any())) + .thenAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + out.write(chunk1Data); + return Futures.immediateFuture(null); + }); + when(combinedCache.downloadBlob(any(), eq(chunk2Digest), any())) + .thenAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + out.write(chunk2Data); + return Futures.immediateFuture(null); + }); + when(combinedCache.downloadBlob(any(), eq(chunk3Digest), any())) + .thenAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + out.write(chunk3Data); + return Futures.immediateFuture(null); + }); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + downloader.downloadChunked(context, blobDigest, out); + + assertThat(out.toByteArray()).isEqualTo(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9}); + verify(combinedCache).downloadBlob(any(), eq(chunk1Digest), any()); + verify(combinedCache).downloadBlob(any(), eq(chunk2Digest), any()); + verify(combinedCache).downloadBlob(any(), eq(chunk3Digest), any()); + } + + @Test + public void downloadChunked_emptyChunkList_producesEmptyOutput() throws Exception { + Digest blobDigest = DIGEST_UTIL.compute(new byte[0]); + + SplitBlobResponse splitResponse = SplitBlobResponse.getDefaultInstance(); + when(grpcCacheClient.splitBlob(any(), eq(blobDigest))) + .thenReturn(Futures.immediateFuture(splitResponse)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + downloader.downloadChunked(context, blobDigest, out); + + assertThat(out.toByteArray()).isEmpty(); + } + + @Test + public void downloadChunked_chunkFailsAfterPartialWrite_throwsIOException() throws Exception { + byte[] chunk1Data = new byte[]{1, 2, 3}; + byte[] chunk2Data = new byte[]{4, 5, 6}; + Digest chunk1Digest = DIGEST_UTIL.compute(chunk1Data); + Digest chunk2Digest = DIGEST_UTIL.compute(chunk2Data); + Digest blobDigest = DIGEST_UTIL.compute(new byte[]{1, 2, 3, 4, 5, 6}); + + SplitBlobResponse splitResponse = SplitBlobResponse.newBuilder() + .addChunkDigests(chunk1Digest) + .addChunkDigests(chunk2Digest) + .build(); + when(grpcCacheClient.splitBlob(any(), eq(blobDigest))) + .thenReturn(Futures.immediateFuture(splitResponse)); + when(combinedCache.downloadBlob(any(), eq(chunk1Digest), any())) + .thenAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + out.write(chunk1Data); + return Futures.immediateFuture(null); + }); + when(combinedCache.downloadBlob(any(), eq(chunk2Digest), any())) + .thenReturn(Futures.immediateFailedFuture(new IOException("connection reset"))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + IOException.class, + () -> downloader.downloadChunked(context, blobDigest, out)); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobUploaderTest.java b/src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobUploaderTest.java new file mode 100644 index 00000000000000..e566a4d4b35fc3 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/ChunkedBlobUploaderTest.java @@ -0,0 +1,202 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import build.bazel.remote.execution.v2.Digest; +import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.Futures; +import com.google.devtools.build.lib.clock.JavaClock; +import com.google.devtools.build.lib.remote.chunking.ChunkingConfig; +import com.google.devtools.build.lib.remote.chunking.FastCDCChunker; +import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; +import com.google.devtools.build.lib.remote.util.DigestUtil; +import com.google.devtools.build.lib.vfs.DigestHashFunction; +import com.google.devtools.build.lib.vfs.FileSystem; +import com.google.devtools.build.lib.vfs.Path; +import com.google.devtools.build.lib.vfs.SyscallCache; +import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Tests for {@link ChunkedBlobUploader}. */ +@RunWith(JUnit4.class) +public class ChunkedBlobUploaderTest { + private static final DigestUtil DIGEST_UTIL = new DigestUtil(SyscallCache.NO_CACHE, DigestHashFunction.SHA256); + + @Mock + private GrpcCacheClient grpcCacheClient; + @Mock + private CombinedCache combinedCache; + @Mock + private RemoteActionExecutionContext context; + + private FileSystem fs; + private Path execRoot; + private ChunkedBlobUploader uploader; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + fs = new InMemoryFileSystem(new JavaClock(), DigestHashFunction.SHA256); + execRoot = fs.getPath("/execroot"); + execRoot.createDirectoryAndParents(); + + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + uploader = new ChunkedBlobUploader(grpcCacheClient, combinedCache, config, DIGEST_UTIL); + } + + @Test + public void getChunkingThreshold_returnsConfiguredValue() { + ChunkingConfig config = new ChunkingConfig(512, 2, 0); + ChunkedBlobUploader uploader = + new ChunkedBlobUploader(grpcCacheClient, combinedCache, config, DIGEST_UTIL); + + assertThat(uploader.getChunkingThreshold()).isEqualTo(512 * 4); + } + + @Test + @SuppressWarnings("unchecked") + public void uploadChunked_allChunksMissing_uploadsAllChunks() throws Exception { + Path file = execRoot.getRelative("test.txt"); + byte[] data = new byte[8192]; + new Random(42).nextBytes(data); + writeFile(file, data); + Digest blobDigest = DIGEST_UTIL.compute(data); + + ArgumentCaptor> digestsCaptor = ArgumentCaptor.forClass(List.class); + when(grpcCacheClient.findMissingDigests(any(), digestsCaptor.capture())) + .thenAnswer(invocation -> { + List digests = invocation.getArgument(1); + return Futures.immediateFuture(ImmutableSet.copyOf(digests)); + }); + when(combinedCache.uploadBlob(any(), any(Digest.class), any())) + .thenReturn(Futures.immediateFuture(null)); + when(grpcCacheClient.spliceBlob(any(), any(), any())) + .thenReturn(Futures.immediateFuture(null)); + + uploader.uploadChunked(context, blobDigest, file); + + List chunkDigests = digestsCaptor.getValue(); + assertThat(chunkDigests.size()).isGreaterThan(1); + long totalSize = chunkDigests.stream().mapToLong(Digest::getSizeBytes).sum(); + assertThat(totalSize).isEqualTo(data.length); + } + + @Test + @SuppressWarnings("unchecked") + public void uploadChunked_noChunksMissing_skipsChunkUpload() throws Exception { + Path file = execRoot.getRelative("test.txt"); + byte[] data = new byte[8192]; + new Random(42).nextBytes(data); + writeFile(file, data); + Digest blobDigest = DIGEST_UTIL.compute(data); + + when(grpcCacheClient.findMissingDigests(any(), any())) + .thenReturn(Futures.immediateFuture(ImmutableSet.of())); + when(grpcCacheClient.spliceBlob(any(), any(), any())) + .thenReturn(Futures.immediateFuture(null)); + + uploader.uploadChunked(context, blobDigest, file); + + verify(combinedCache, never()).uploadBlob(any(), any(Digest.class), any()); + verify(grpcCacheClient).spliceBlob(any(), eq(blobDigest), any()); + } + + @Test + @SuppressWarnings("unchecked") + public void uploadChunked_someChunksMissing_uploadsOnlyMissingWithCorrectData() throws Exception { + Path file = execRoot.getRelative("test_partial.txt"); + byte[] fileData = new byte[16384]; + new Random(42).nextBytes(fileData); + writeFile(file, fileData); + Digest blobDigest = DIGEST_UTIL.compute(fileData); + + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker testChunker = new FastCDCChunker(config, DIGEST_UTIL); + List allChunkDigests; + try (InputStream input = file.getInputStream()) { + allChunkDigests = testChunker.chunkToDigests(input); + } + assertThat(allChunkDigests.size()).isAtLeast(5); + + Set digestsToReportMissing = new LinkedHashSet<>(); + for (int i = 0; i < allChunkDigests.size(); i++) { + boolean isFirst = i == 0; + boolean isLast = i == allChunkDigests.size() - 1; + boolean isOdd = i % 2 == 1; + if (isFirst || isLast || isOdd) { + digestsToReportMissing.add(allChunkDigests.get(i)); + } + } + + Map expectedChunkData = new LinkedHashMap<>(); + try (InputStream input = file.getInputStream()) { + for (Digest digest : allChunkDigests) { + byte[] chunkBytes = input.readNBytes((int) digest.getSizeBytes()); + if (digestsToReportMissing.contains(digest)) { + expectedChunkData.put(digest, ByteString.copyFrom(chunkBytes)); + } + } + } + + when(grpcCacheClient.findMissingDigests(any(), any())) + .thenReturn(Futures.immediateFuture(ImmutableSet.copyOf(digestsToReportMissing))); + Map actualUploads = new HashMap<>(); + when(combinedCache.uploadBlob(any(), any(Digest.class), any())) + .thenAnswer(invocation -> { + Digest d = invocation.getArgument(1); + ByteString bs = invocation.getArgument(2); + actualUploads.put(d, bs); + return Futures.immediateFuture(null); + }); + when(grpcCacheClient.spliceBlob(any(), any(), any())) + .thenReturn(Futures.immediateFuture(null)); + + uploader.uploadChunked(context, blobDigest, file); + + assertThat(actualUploads.keySet()).isEqualTo(expectedChunkData.keySet()); + for (Map.Entry entry : expectedChunkData.entrySet()) { + assertThat(actualUploads.get(entry.getKey())).isEqualTo(entry.getValue()); + } + verify(grpcCacheClient).spliceBlob(any(), eq(blobDigest), eq(allChunkDigests)); + } + + private void writeFile(Path path, byte[] data) throws IOException { + try (var out = path.getOutputStream()) { + out.write(data); + } + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/ChunkedCacheIntegrationTest.java b/src/test/java/com/google/devtools/build/lib/remote/ChunkedCacheIntegrationTest.java new file mode 100644 index 00000000000000..3d4711d3497379 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/ChunkedCacheIntegrationTest.java @@ -0,0 +1,308 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.devtools.build.lib.vfs.FileSystemUtils.readContent; +import static java.nio.charset.StandardCharsets.UTF_8; + +import build.bazel.remote.execution.v2.ContentAddressableStorageGrpc; +import build.bazel.remote.execution.v2.Digest; +import build.bazel.remote.execution.v2.RequestMetadata; +import build.bazel.remote.execution.v2.SplitBlobRequest; +import build.bazel.remote.execution.v2.SplitBlobResponse; +import build.bazel.remote.execution.v2.ToolDetails; +import com.google.bytestream.ByteStreamGrpc; +import com.google.bytestream.ByteStreamProto.ReadRequest; +import com.google.bytestream.ByteStreamProto.ReadResponse; +import com.google.common.collect.ImmutableList; +import com.google.common.hash.HashCode; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteStreams; +import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule; +import com.google.devtools.build.lib.buildtool.util.BuildIntegrationTestCase; +import com.google.devtools.build.lib.remote.util.IntegrationTestUtils; +import com.google.devtools.build.lib.remote.util.IntegrationTestUtils.WorkerInstance; +import com.google.devtools.build.lib.remote.util.TracingMetadataUtils; +import com.google.devtools.build.lib.runtime.BlazeModule; +import com.google.devtools.build.lib.runtime.BlazeRuntime; +import com.google.devtools.build.lib.runtime.BlockWaitingModule; +import com.google.devtools.build.lib.runtime.BuildSummaryStatsModule; +import com.google.devtools.build.lib.standalone.StandaloneModule; +import com.google.devtools.build.lib.vfs.Path; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; +import java.util.List; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Integration tests for chunked remote cache using SplitBlob/SpliceBlob APIs. */ +@RunWith(JUnit4.class) +public class ChunkedCacheIntegrationTest extends BuildIntegrationTestCase { + @ClassRule @Rule public static final WorkerInstance worker = IntegrationTestUtils.createWorker(); + + @Override + protected void setupOptions() throws Exception { + super.setupOptions(); + addOptions( + "--remote_cache=grpc://localhost:" + worker.getPort(), + "--experimental_remote_cache_chunking"); + } + + @Override + protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception { + return super.getRuntimeBuilder() + .addBlazeModule(new RemoteModule()) + .addBlazeModule(new BuildSummaryStatsModule()) + .addBlazeModule(new BlockWaitingModule()); + } + + @Override + protected ImmutableList getSpawnModules() { + return ImmutableList.builder() + .addAll(super.getSpawnModules()) + .add(new StandaloneModule()) + .add(new CredentialModule()) + .build(); + } + + @After + public void waitDownloads() throws Exception { + runtimeWrapper.newCommand(); + } + + private Path getOutputPath(String binRelativePath) { + return getTargetConfiguration().getBinDir().getRoot().getRelative(binRelativePath); + } + + private void cleanAndRestartServer() throws Exception { + getOutputBase().getRelative("action_cache").deleteTreesBelow(); + createRuntimeWrapper(); + } + + private byte[] readFileBytes(Path path) throws IOException { + try (InputStream in = path.getInputStream()) { + return ByteStreams.toByteArray(in); + } + } + + private Digest computeDigest(byte[] data) { + HashCode hash = Hashing.sha256().hashBytes(data); + return Digest.newBuilder().setHash(hash.toString()).setSizeBytes(data.length).build(); + } + + @Test + public void uploadAndDownloadLargeBlob_withChunking_succeeds() throws Exception { + write( + "BUILD", + """ + genrule( + name = "large_file", + srcs = [], + outs = ["large.txt"], + cmd = "dd if=/dev/zero bs=1M count=3 2>/dev/null | tr '\\\\0' 'a' > $@", + ) + """); + + buildTarget("//:large_file"); + + Path output = getOutputPath("large.txt"); + assertThat(output.exists()).isTrue(); + byte[] originalContent = readFileBytes(output); + assertThat(originalContent.length).isAtLeast(2 * 1024 * 1024); + + Digest blobDigest = computeDigest(originalContent); + + // Verify SplitBlob returns multiple chunks and each chunk is individually downloadable. + RequestMetadata metadata = + RequestMetadata.newBuilder() + .setCorrelatedInvocationsId("test-build-id") + .setToolInvocationId("test-command-id") + .setActionId("test-action-id") + .setToolDetails(ToolDetails.newBuilder().setToolName("bazel").setToolVersion("test")) + .build(); + ClientInterceptor interceptor = TracingMetadataUtils.attachMetadataInterceptor(metadata); + + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", worker.getPort()) + .usePlaintext() + .intercept(interceptor) + .build(); + try { + ContentAddressableStorageGrpc.ContentAddressableStorageBlockingStub casStub = + ContentAddressableStorageGrpc.newBlockingStub(channel); + + SplitBlobResponse splitResponse = + casStub.splitBlob(SplitBlobRequest.newBuilder().setBlobDigest(blobDigest).build()); + List chunkDigests = splitResponse.getChunkDigestsList(); + + assertThat(chunkDigests.size()).isGreaterThan(1); + long totalChunkSize = chunkDigests.stream().mapToLong(Digest::getSizeBytes).sum(); + assertThat(totalChunkSize).isEqualTo(originalContent.length); + + // Download each chunk individually and reassemble to verify integrity. + ByteStreamGrpc.ByteStreamBlockingStub bsStub = ByteStreamGrpc.newBlockingStub(channel); + ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (Digest chunkDigest : chunkDigests) { + String resourceName = + "blobs/" + chunkDigest.getHash() + "/" + chunkDigest.getSizeBytes(); + Iterator readIter = + bsStub.read(ReadRequest.newBuilder().setResourceName(resourceName).build()); + int chunkBytesRead = 0; + while (readIter.hasNext()) { + byte[] data = readIter.next().getData().toByteArray(); + reassembled.write(data); + chunkBytesRead += data.length; + } + assertThat(chunkBytesRead).isEqualTo((int) chunkDigest.getSizeBytes()); + } + assertThat(reassembled.toByteArray()).isEqualTo(originalContent); + } finally { + channel.shutdownNow(); + } + + // Delete output and action cache, then rebuild to exercise chunked download. + output.delete(); + assertThat(output.exists()).isFalse(); + cleanAndRestartServer(); + + buildTarget("//:large_file"); + + assertThat(output.exists()).isTrue(); + assertThat(readFileBytes(output)).isEqualTo(originalContent); + } + + @Test + public void multipleTargets_withChunking_allSucceed() throws Exception { + // Multiple large files built in parallel, with a downstream target that depends on them. + // Use deterministic content (filled with distinct byte patterns) so we can verify integrity. + write( + "BUILD", + """ + genrule( + name = "data_a", + srcs = [], + outs = ["a.bin"], + cmd = "dd if=/dev/zero bs=1M count=3 2>/dev/null | tr '\\\\0' 'A' > $@", + ) + genrule( + name = "data_b", + srcs = [], + outs = ["b.bin"], + cmd = "dd if=/dev/zero bs=1M count=4 2>/dev/null | tr '\\\\0' 'B' > $@", + ) + genrule( + name = "combined", + srcs = [":a.bin", ":b.bin"], + outs = ["combined.bin"], + cmd = "cat $(SRCS) > $@", + ) + """); + + buildTarget("//:combined"); + + Path outputA = getOutputPath("a.bin"); + Path outputB = getOutputPath("b.bin"); + Path outputCombined = getOutputPath("combined.bin"); + assertThat(outputA.exists()).isTrue(); + assertThat(outputB.exists()).isTrue(); + assertThat(outputCombined.exists()).isTrue(); + + byte[] contentA = readFileBytes(outputA); + byte[] contentB = readFileBytes(outputB); + byte[] contentCombined = readFileBytes(outputCombined); + assertThat(contentA.length).isEqualTo(3 * 1024 * 1024); + assertThat(contentB.length).isEqualTo(4 * 1024 * 1024); + assertThat(contentCombined.length).isEqualTo(7 * 1024 * 1024); + + // Clean and rebuild from cache. + outputA.delete(); + outputB.delete(); + outputCombined.delete(); + cleanAndRestartServer(); + + buildTarget("//:combined"); + + assertThat(readFileBytes(outputA)).isEqualTo(contentA); + assertThat(readFileBytes(outputB)).isEqualTo(contentB); + assertThat(readFileBytes(outputCombined)).isEqualTo(contentCombined); + } + + @Test + public void buildWithChunking_smallFile_succeeds() throws Exception { + write( + "BUILD", + """ + genrule( + name = "small_file", + srcs = [], + outs = ["small.txt"], + cmd = "echo 'hello world' > $@", + ) + """); + + buildTarget("//:small_file"); + + Path output = getOutputPath("small.txt"); + assertThat(output.exists()).isTrue(); + assertThat(readContent(output, UTF_8)).isEqualTo("hello world\n"); + } + + @Test + public void mixedSizes_largeAndSmallOutputs_allSucceed() throws Exception { + write( + "BUILD", + """ + genrule( + name = "large", + srcs = [], + outs = ["large.bin"], + cmd = "dd if=/dev/zero bs=1M count=3 2>/dev/null | tr '\\\\0' 'X' > $@", + ) + genrule( + name = "small", + srcs = [], + outs = ["small.txt"], + cmd = "echo 'small output' > $@", + ) + """); + + buildTarget("//:large", "//:small"); + + Path largePath = getOutputPath("large.bin"); + Path smallPath = getOutputPath("small.txt"); + byte[] largeContent = readFileBytes(largePath); + assertThat(largeContent.length).isEqualTo(3 * 1024 * 1024); + assertThat(readContent(smallPath, UTF_8)).isEqualTo("small output\n"); + + // Clean and rebuild. + largePath.delete(); + smallPath.delete(); + cleanAndRestartServer(); + + buildTarget("//:large", "//:small"); + + assertThat(readFileBytes(largePath)).isEqualTo(largeContent); + assertThat(readContent(smallPath, UTF_8)).isEqualTo("small output\n"); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/ChunkedDiskCacheIntegrationTest.java b/src/test/java/com/google/devtools/build/lib/remote/ChunkedDiskCacheIntegrationTest.java new file mode 100644 index 00000000000000..851750d300813c --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/ChunkedDiskCacheIntegrationTest.java @@ -0,0 +1,219 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.devtools.build.lib.testutil.TestUtils.tmpDirFile; + +import com.google.common.collect.ImmutableList; +import com.google.common.io.ByteStreams; +import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule; +import com.google.devtools.build.lib.buildtool.util.BuildIntegrationTestCase; +import com.google.devtools.build.lib.remote.util.IntegrationTestUtils; +import com.google.devtools.build.lib.remote.util.IntegrationTestUtils.WorkerInstance; +import com.google.devtools.build.lib.runtime.BlazeModule; +import com.google.devtools.build.lib.runtime.BlazeRuntime; +import com.google.devtools.build.lib.runtime.BlockWaitingModule; +import com.google.devtools.build.lib.runtime.BuildSummaryStatsModule; +import com.google.devtools.build.lib.standalone.StandaloneModule; +import com.google.devtools.build.lib.vfs.Path; +import com.google.devtools.build.lib.vfs.PathFragment; +import java.io.IOException; +import java.io.InputStream; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Integration tests for chunked remote cache with a combined disk + remote cache. + * + *

Verifies that chunks downloaded from the remote cache are properly captured to disk cache, and + * that subsequent builds can serve chunks from disk cache without hitting the remote. + */ +@RunWith(JUnit4.class) +public class ChunkedDiskCacheIntegrationTest extends BuildIntegrationTestCase { + @ClassRule @Rule public static final WorkerInstance worker = IntegrationTestUtils.createWorker(); + + private static PathFragment getDiskCacheDir() { + return PathFragment.create(tmpDirFile().getAbsolutePath()).getRelative("chunked_disk_cache"); + } + + @Override + protected void setupOptions() throws Exception { + super.setupOptions(); + addOptions( + "--remote_cache=grpc://localhost:" + worker.getPort(), + "--disk_cache=" + getDiskCacheDir(), + "--experimental_remote_cache_chunking"); + } + + @Override + protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception { + return super.getRuntimeBuilder() + .addBlazeModule(new RemoteModule()) + .addBlazeModule(new BuildSummaryStatsModule()) + .addBlazeModule(new BlockWaitingModule()); + } + + @Override + protected ImmutableList getSpawnModules() { + return ImmutableList.builder() + .addAll(super.getSpawnModules()) + .add(new StandaloneModule()) + .add(new CredentialModule()) + .build(); + } + + @After + public void tearDown() throws Exception { + runtimeWrapper.newCommand(); + getWorkspace().getFileSystem().getPath(getDiskCacheDir()).deleteTree(); + } + + private Path getOutputPath(String binRelativePath) { + return getTargetConfiguration().getBinDir().getRoot().getRelative(binRelativePath); + } + + private void cleanAndRestartServer() throws Exception { + getOutputBase().getRelative("action_cache").deleteTreesBelow(); + createRuntimeWrapper(); + } + + private byte[] readFileBytes(Path path) throws IOException { + try (InputStream in = path.getInputStream()) { + return ByteStreams.toByteArray(in); + } + } + + @Test + public void largeBlob_uploadedAndDownloaded_throughDiskAndRemoteCache() throws Exception { + write( + "BUILD", + """ + genrule( + name = "large_file", + srcs = [], + outs = ["large.bin"], + cmd = "dd if=/dev/zero bs=1M count=3 2>/dev/null | tr '\\\\0' 'D' > $@", + ) + """); + + // First build: generates the file, uploads chunks to remote + disk cache. + buildTarget("//:large_file"); + + Path output = getOutputPath("large.bin"); + assertThat(output.exists()).isTrue(); + byte[] originalContent = readFileBytes(output); + assertThat(originalContent.length).isEqualTo(3 * 1024 * 1024); + + // Second build: clean outputs + action cache, rebuild. + // Chunks should be served from disk cache (populated during first build's download capture). + output.delete(); + cleanAndRestartServer(); + + buildTarget("//:large_file"); + + assertThat(output.exists()).isTrue(); + assertThat(readFileBytes(output)).isEqualTo(originalContent); + } + + @Test + public void largeBlob_diskCasDeleted_rebuildFromRemote() throws Exception { + write( + "BUILD", + """ + genrule( + name = "large_file", + srcs = [], + outs = ["large.bin"], + cmd = "dd if=/dev/zero bs=1M count=3 2>/dev/null | tr '\\\\0' 'E' > $@", + ) + """); + + // First build: populates both caches. + buildTarget("//:large_file"); + + Path output = getOutputPath("large.bin"); + byte[] originalContent = readFileBytes(output); + + // Delete disk cache CAS entries (simulate cache eviction). + Path diskCasCas = getWorkspace().getFileSystem().getPath(getDiskCacheDir().getRelative("cas")); + if (diskCasCas.exists()) { + diskCasCas.deleteTree(); + } + + // Clean outputs + action cache, rebuild. + // Should fall back to remote cache since disk CAS is gone. + output.delete(); + cleanAndRestartServer(); + + buildTarget("//:large_file"); + + assertThat(output.exists()).isTrue(); + assertThat(readFileBytes(output)).isEqualTo(originalContent); + } + + @Test + public void multipleTargets_withDiskCache_allSucceed() throws Exception { + write( + "BUILD", + """ + genrule( + name = "data_a", + srcs = [], + outs = ["a.bin"], + cmd = "dd if=/dev/zero bs=1M count=3 2>/dev/null | tr '\\\\0' 'F' > $@", + ) + genrule( + name = "data_b", + srcs = [], + outs = ["b.bin"], + cmd = "dd if=/dev/zero bs=1M count=4 2>/dev/null | tr '\\\\0' 'G' > $@", + ) + genrule( + name = "combined", + srcs = [":a.bin", ":b.bin"], + outs = ["combined.bin"], + cmd = "cat $(SRCS) > $@", + ) + """); + + buildTarget("//:data_a", "//:data_b", "//:combined"); + + Path outputA = getOutputPath("a.bin"); + Path outputB = getOutputPath("b.bin"); + Path outputCombined = getOutputPath("combined.bin"); + byte[] contentA = readFileBytes(outputA); + byte[] contentB = readFileBytes(outputB); + byte[] contentCombined = readFileBytes(outputCombined); + assertThat(contentA.length).isEqualTo(3 * 1024 * 1024); + assertThat(contentB.length).isEqualTo(4 * 1024 * 1024); + assertThat(contentCombined.length).isEqualTo(7 * 1024 * 1024); + + // Clean and rebuild from cache. + outputA.delete(); + outputB.delete(); + outputCombined.delete(); + cleanAndRestartServer(); + + buildTarget("//:data_a", "//:data_b", "//:combined"); + + assertThat(readFileBytes(outputA)).isEqualTo(contentA); + assertThat(readFileBytes(outputB)).isEqualTo(contentB); + assertThat(readFileBytes(outputCombined)).isEqualTo(contentCombined); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java b/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java index 9cd74f405652fb..8395e1e4f2dee7 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java @@ -215,7 +215,8 @@ public int maxConcurrency() { }); channels.add(channel); return new GrpcCacheClient( - channel, callCredentialsProvider, remoteOptions, retrier, DIGEST_UTIL); + channel, callCredentialsProvider, remoteOptions, retrier, DIGEST_UTIL, + /* chunkingConfig= */ null); } private static byte[] downloadBlob( diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java index 0c6c0dd80e2a05..9e59d4ad62d0a8 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java @@ -333,7 +333,8 @@ public int maxConcurrency() { GoogleAuthUtils.newCallCredentialsProvider(null); GrpcCacheClient cacheProtocol = new GrpcCacheClient( - channel.retain(), callCredentialsProvider, remoteOptions, retrier, DIGEST_UTIL); + channel.retain(), callCredentialsProvider, remoteOptions, retrier, DIGEST_UTIL, + /* chunkingConfig= */ null); remoteCache = new RemoteExecutionCache( cacheProtocol, /* diskCacheClient= */ null, /* symlinkTemplate= */ null, DIGEST_UTIL); diff --git a/src/test/java/com/google/devtools/build/lib/remote/chunking/BUILD b/src/test/java/com/google/devtools/build/lib/remote/chunking/BUILD new file mode 100644 index 00000000000000..d100d9c64e7f09 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/chunking/BUILD @@ -0,0 +1,67 @@ +load("@rules_java//java:defs.bzl", "java_library", "java_test") +load("//src:java_opt_binary.bzl", "java_opt_binary") + +package( + default_applicable_licenses = ["//:license"], + default_testonly = 1, + default_visibility = ["//src:__subpackages__"], +) + +filegroup( + name = "srcs", + testonly = 0, + srcs = glob(["**"]), + visibility = ["//src:__subpackages__"], +) + +java_library( + name = "ChunkingTests_lib", + srcs = [ + "ChunkingConfigTest.java", + "FastCDCChunkerTest.java", + ], + data = [ + # Public domain image by Toriyama Sekien (1712-1788), + # first published in "Gazu Hyakki Yagyo" (1776). + # Source: https://commons.wikimedia.org/wiki/File:SekienAkashita.jpg + "SekienAkashita.jpg", + ], + deps = [ + "//src/main/java/com/google/devtools/build/lib/remote/chunking", + "//src/main/java/com/google/devtools/build/lib/remote/util:digest_utils", + "//src/main/java/com/google/devtools/build/lib/vfs", + "//third_party:guava", + "//third_party:junit4", + "//third_party:truth", + "@rules_java//java/runfiles", + "@remoteapis//:build_bazel_remote_execution_v2_remote_execution_java_proto", + ], +) + +java_test( + name = "ChunkingTests", + test_class = "com.google.devtools.build.lib.AllTests", + data = [ + "SekienAkashita.jpg", + ], + env = { + "SEKIEN_AKASHITA_PATH": "$(rlocationpath SekienAkashita.jpg)", + }, + runtime_deps = [ + ":ChunkingTests_lib", + "//src/test/java/com/google/devtools/build/lib:test_runner", + ], +) + +java_opt_binary( + name = "FastCDCBenchmark", + srcs = ["FastCDCBenchmark.java"], + main_class = "org.openjdk.jmh.Main", + deps = [ + "//src/main/java/com/google/devtools/build/lib/remote/chunking", + "//src/main/java/com/google/devtools/build/lib/remote/util:digest_utils", + "//src/main/java/com/google/devtools/build/lib/vfs", + "//src/main/java/com/google/devtools/build/lib/vfs/bazel", + "//third_party:jmh", + ], +) diff --git a/src/test/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfigTest.java b/src/test/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfigTest.java new file mode 100644 index 00000000000000..db5866603f292d --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/chunking/ChunkingConfigTest.java @@ -0,0 +1,138 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import build.bazel.remote.execution.v2.CacheCapabilities; +import build.bazel.remote.execution.v2.FastCdc2020Params; +import build.bazel.remote.execution.v2.ServerCapabilities; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link ChunkingConfig}. */ +@RunWith(JUnit4.class) +public class ChunkingConfigTest { + + @Test + public void defaults_returnsExpectedValues() { + ChunkingConfig config = ChunkingConfig.defaults(); + + assertThat(config.avgChunkSize()).isEqualTo(512 * 1024); + assertThat(config.normalizationLevel()).isEqualTo(2); + assertThat(config.seed()).isEqualTo(0); + assertThat(config.chunkingThreshold()).isEqualTo(512 * 1024 * 4); + } + + @Test + public void minChunkSize_returnsQuarterOfAvg() { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + + assertThat(config.minChunkSize()).isEqualTo(256); + } + + @Test + public void maxChunkSize_returnsFourTimesAvg() { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + + assertThat(config.maxChunkSize()).isEqualTo(4096); + } + + @Test + public void chunkingThreshold_equalsMaxChunkSize() { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + + assertThat(config.chunkingThreshold()).isEqualTo(config.maxChunkSize()); + } + + @Test + public void minAndMaxChunkSize_withDefaultConfig() { + ChunkingConfig config = ChunkingConfig.defaults(); + + assertThat(config.minChunkSize()).isEqualTo(128 * 1024); + assertThat(config.maxChunkSize()).isEqualTo(2048 * 1024); + } + + @Test + public void fromServerCapabilities_withoutCacheCapabilities_returnsNull() { + ServerCapabilities capabilities = ServerCapabilities.getDefaultInstance(); + + ChunkingConfig config = ChunkingConfig.fromServerCapabilities(capabilities); + + assertThat(config).isNull(); + } + + @Test + public void fromServerCapabilities_withoutFastCdcParams_returnsNull() { + ServerCapabilities capabilities = ServerCapabilities.newBuilder() + .setCacheCapabilities(CacheCapabilities.getDefaultInstance()) + .build(); + + ChunkingConfig config = ChunkingConfig.fromServerCapabilities(capabilities); + + assertThat(config).isNull(); + } + + @Test + public void fromServerCapabilities_withFastCdcParams_returnsConfig() { + ServerCapabilities capabilities = ServerCapabilities.newBuilder() + .setCacheCapabilities(CacheCapabilities.newBuilder() + .setFastCdc2020Params(FastCdc2020Params.newBuilder() + .setAvgChunkSizeBytes(256 * 1024) + .setSeed(42) + .build()) + .build()) + .build(); + + ChunkingConfig config = ChunkingConfig.fromServerCapabilities(capabilities); + + assertThat(config).isNotNull(); + assertThat(config.avgChunkSize()).isEqualTo(256 * 1024); + assertThat(config.seed()).isEqualTo(42); + assertThat(config.chunkingThreshold()).isEqualTo(256 * 1024 * 4); + } + + @Test + public void fromServerCapabilities_withDefaultFastCdcParams_returnsDefaults() { + ServerCapabilities capabilities = ServerCapabilities.newBuilder() + .setCacheCapabilities(CacheCapabilities.newBuilder() + .setFastCdc2020Params(FastCdc2020Params.newBuilder() + .setAvgChunkSizeBytes(512 * 1024) + .setSeed(0) + .build()) + .build()) + .build(); + + ChunkingConfig config = ChunkingConfig.fromServerCapabilities(capabilities); + + assertThat(config).isEqualTo(ChunkingConfig.defaults()); + } + + @Test + public void fromServerCapabilities_nonPowerOfTwoAvgSize_fallsBackToDefault() { + ServerCapabilities capabilities = ServerCapabilities.newBuilder() + .setCacheCapabilities(CacheCapabilities.newBuilder() + .setFastCdc2020Params(FastCdc2020Params.newBuilder() + .setAvgChunkSizeBytes(300 * 1024) + .build()) + .build()) + .build(); + + ChunkingConfig config = ChunkingConfig.fromServerCapabilities(capabilities); + + assertThat(config).isNotNull(); + assertThat(config.avgChunkSize()).isEqualTo(ChunkingConfig.DEFAULT_AVG_CHUNK_SIZE); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCBenchmark.java b/src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCBenchmark.java new file mode 100644 index 00000000000000..717590da7b588e --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCBenchmark.java @@ -0,0 +1,65 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote.chunking; + +import com.google.devtools.build.lib.remote.util.DigestUtil; +import com.google.devtools.build.lib.vfs.SyscallCache; +import com.google.devtools.build.lib.vfs.bazel.BazelHashFunctions; +import java.io.ByteArrayInputStream; +import java.security.SecureRandom; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +@BenchmarkMode(Mode.Throughput) +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS) +@Fork(3) +public class FastCDCBenchmark { + private static final int AVG_CHUNK_SIZE = 512 * 1024; + + @Param({"1048576", "8388608", "67108864"}) + public int size; + + private byte[] data; + private FastCDCChunker chunker; + + @Setup(Level.Iteration) + public void setup() { + BazelHashFunctions.ensureRegistered(); + data = new byte[size]; + new SecureRandom().nextBytes(data); + + DigestUtil digestUtil = + new DigestUtil(SyscallCache.NO_CACHE, BazelHashFunctions.BLAKE3); + int minSize = AVG_CHUNK_SIZE / 4; + int maxSize = AVG_CHUNK_SIZE * 4; + chunker = new FastCDCChunker(minSize, AVG_CHUNK_SIZE, maxSize, 2, 0, digestUtil); + } + + @Benchmark + public Object chunkToDigests() throws Exception { + return chunker.chunkToDigests(new ByteArrayInputStream(data)); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunkerTest.java b/src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunkerTest.java new file mode 100644 index 00000000000000..890339e6f1e600 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/chunking/FastCDCChunkerTest.java @@ -0,0 +1,271 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +package com.google.devtools.build.lib.remote.chunking; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import build.bazel.remote.execution.v2.Digest; +import com.google.devtools.build.lib.remote.util.DigestUtil; +import com.google.devtools.build.lib.vfs.DigestHashFunction; +import com.google.devtools.build.lib.vfs.SyscallCache; +import com.google.devtools.build.runfiles.Runfiles; +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link FastCDCChunker}. */ +@RunWith(JUnit4.class) +public class FastCDCChunkerTest { + private static final DigestUtil DIGEST_UTIL = + new DigestUtil(SyscallCache.NO_CACHE, DigestHashFunction.SHA256); + + @Test + public void chunkToDigests_emptyInput_returnsEmptyList() throws IOException { + FastCDCChunker chunker = new FastCDCChunker(DIGEST_UTIL); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(new byte[0])); + + assertThat(digests).isEmpty(); + } + + @Test + public void chunkToDigests_smallInput_returnsSingleChunk() throws IOException { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker chunker = new FastCDCChunker(config, DIGEST_UTIL); + byte[] data = new byte[100]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests).hasSize(1); + assertThat(digests.get(0).getSizeBytes()).isEqualTo(100); + } + + @Test + public void chunkToDigests_dataAtMinSize_returnsSingleChunk() throws IOException { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker chunker = new FastCDCChunker(config, DIGEST_UTIL); + byte[] data = new byte[config.minChunkSize()]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests).hasSize(1); + assertThat(digests.get(0).getSizeBytes()).isEqualTo(config.minChunkSize()); + } + + @Test + public void chunkToDigests_largeInput_producesMultipleChunks() throws IOException { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker chunker = new FastCDCChunker(config, DIGEST_UTIL); + byte[] data = new byte[config.maxChunkSize() * 3]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests.size()).isGreaterThan(1); + long totalSize = digests.stream().mapToLong(Digest::getSizeBytes).sum(); + assertThat(totalSize).isEqualTo(data.length); + } + + @Test + public void chunkToDigests_sameInputProducesSameChunks() throws IOException { + FastCDCChunker chunker = new FastCDCChunker(DIGEST_UTIL); + byte[] data = new byte[2 * 1024 * 1024]; + new Random(123).nextBytes(data); + + List digests1 = chunker.chunkToDigests(new ByteArrayInputStream(data)); + List digests2 = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests1).isEqualTo(digests2); + } + + @Test + public void chunkToDigests_chunkSizesWithinBounds() throws IOException { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker chunker = new FastCDCChunker(config, DIGEST_UTIL); + byte[] data = new byte[config.maxChunkSize() * 10]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + for (int i = 0; i < digests.size() - 1; i++) { + long size = digests.get(i).getSizeBytes(); + assertThat(size).isAtLeast(config.minChunkSize()); + assertThat(size).isAtMost(config.maxChunkSize()); + } + } + + @Test + public void chunkToDigests_lastChunkCanBeSmallerThanMin() throws IOException { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker chunker = new FastCDCChunker(config, DIGEST_UTIL); + int dataSize = config.maxChunkSize() + config.minChunkSize() / 2; + byte[] data = new byte[dataSize]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests.size()).isAtLeast(1); + long totalSize = digests.stream().mapToLong(Digest::getSizeBytes).sum(); + assertThat(totalSize).isEqualTo(dataSize); + } + + @Test + public void chunkToDigests_digestsAreCorrect() throws IOException { + ChunkingConfig config = new ChunkingConfig(1024, 2, 0); + FastCDCChunker chunker = new FastCDCChunker(config, DIGEST_UTIL); + byte[] data = new byte[500]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests).hasSize(1); + Digest expected = DIGEST_UTIL.compute(data); + assertThat(digests.get(0)).isEqualTo(expected); + } + + @Test + public void constructor_invalidMinSize_throws() { + assertThrows( + IllegalArgumentException.class, + () -> new FastCDCChunker(0, 1024, 4096, 2, 0, DIGEST_UTIL)); + } + + @Test + public void constructor_avgSizeLessThanMinSize_throws() { + assertThrows( + IllegalArgumentException.class, + () -> new FastCDCChunker(1024, 512, 4096, 2, 0, DIGEST_UTIL)); + } + + @Test + public void constructor_maxSizeLessThanAvgSize_throws() { + assertThrows( + IllegalArgumentException.class, + () -> new FastCDCChunker(256, 1024, 512, 2, 0, DIGEST_UTIL)); + } + + @Test + public void constructor_avgSizeNotPowerOfTwo_throws() { + assertThrows( + IllegalArgumentException.class, + () -> new FastCDCChunker(256, 1000, 4096, 2, 0, DIGEST_UTIL)); + } + + @Test + public void constructor_invalidNormalization_throws() { + assertThrows( + IllegalArgumentException.class, + () -> new FastCDCChunker(256, 1024, 4096, 4, 0, DIGEST_UTIL)); + } + + @Test + public void chunkToDigests_withDefaultConfig() throws IOException { + FastCDCChunker chunker = new FastCDCChunker(DIGEST_UTIL); + byte[] data = new byte[4 * 1024 * 1024]; + new Random(42).nextBytes(data); + + List digests = chunker.chunkToDigests(new ByteArrayInputStream(data)); + + assertThat(digests.size()).isGreaterThan(1); + long totalSize = digests.stream().mapToLong(Digest::getSizeBytes).sum(); + assertThat(totalSize).isEqualTo(data.length); + } + + @Test + public void chunkToDigests_testVectorsSeed0() throws Exception { + verifyTestVectors(0, new int[][] { + {0, 19186}, + {19186, 19279}, + {38465, 17354}, + {55819, 16387}, + {72206, 19940}, + {92146, 17320}, + }, new String[] { + "0f9efa589121d5d9e9e2c4ace91337d77cae866537143f6f15a0ffd525a77c2d", + "c7c86a165573c16448cda35c9169742e85645af42be22889f8b96b8ee0ec7cb0", + "bc88521e28a8b4479cdea5f75aa721a24f3a0a7d0be903aa6d505c574e51e89d", + "4b8dac2652e4685c629d2bb1ae9d4448e676b86f2e67ca0b2fff3d9580184b79", + "c0a7062da6f2386c28e086ee0cedd5732252741269838773cff1ddb05b2df6ed", + "7fa5b12134dc75cd2ac8dc60d3a8f3c8d22f0ee9d4cf74a4aa937e2a0d2d79a5", + }); + } + + @Test + public void chunkToDigests_testVectorsSeed666() throws Exception { + verifyTestVectors(666, new int[][] { + {0, 17635}, + {17635, 17334}, + {34969, 19136}, + {54105, 17467}, + {71572, 23593}, + {95165, 14301}, + }, new String[] { + "cb3a9d80a3569772d4ed331ca37ab0c862c759897b890fc1aac90a4f2ea3a407", + "d758c6b7b0b7eef1e996f8ccd17de6c645360b03a26c35541e7581348ac08944", + "24846aefd89e510594bae3e9d7d5ea5012067601512610fed126a3c57ba993f5", + "efa785e1fefb49f190e665f72fd246c1442079874508c312196da1fb3040d00b", + "a2f557bdd8d40d8faada963ad5f91ec54b10ccee7c5ae72754a65137592dc607", + "e131100b4a7147ccad19dc63c4a2fac1f5d8b644e1373eeb6803825024234efc", + }); + } + + // Test vectors from the Remote Execution API specification: + // https://github.com/bazelbuild/remote-apis/blob/v2.12.0/build/bazel/remote/execution/v2/fastcdc2020_test_vectors.txt + // Test image: "Akashita" by Toriyama Sekien (1712-1788), public domain. + // Source: https://commons.wikimedia.org/wiki/File:SekienAkashita.jpg + private void verifyTestVectors(long seed, int[][] expectedChunks, String[] expectedHashes) + throws Exception { + Runfiles runfiles = Runfiles.create(); + String rlocationPath = System.getenv("SEKIEN_AKASHITA_PATH"); + Path testVectorPath = Path.of(runfiles.rlocation(rlocationPath)); + byte[] fileData = Files.readAllBytes(testVectorPath); + + FastCDCChunker chunker = new FastCDCChunker(4096, 16384, 65535, 2, seed, DIGEST_UTIL); + List digests; + try (InputStream input = new ByteArrayInputStream(fileData)) { + digests = chunker.chunkToDigests(input); + } + + assertThat(digests).hasSize(expectedChunks.length); + + List actualChunks = new ArrayList<>(); + int offset = 0; + for (Digest digest : digests) { + actualChunks.add(new int[] {offset, (int) digest.getSizeBytes()}); + offset += digest.getSizeBytes(); + } + + for (int i = 0; i < expectedChunks.length; i++) { + assertThat(actualChunks.get(i)[0]).isEqualTo(expectedChunks[i][0]); + assertThat(actualChunks.get(i)[1]).isEqualTo(expectedChunks[i][1]); + + byte[] chunkData = new byte[expectedChunks[i][1]]; + System.arraycopy(fileData, expectedChunks[i][0], chunkData, 0, chunkData.length); + String chunkHash = Hashing.sha256().hashBytes(chunkData).toString(); + assertThat(chunkHash).isEqualTo(expectedHashes[i]); + } + } +} diff --git a/src/test/java/com/google/devtools/build/lib/remote/chunking/SekienAkashita.jpg b/src/test/java/com/google/devtools/build/lib/remote/chunking/SekienAkashita.jpg new file mode 100644 index 0000000000000000000000000000000000000000..71b09702c447a34208cb3df86a0a5bb70ab4e0ad GIT binary patch literal 109466 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 splicedBlobs = new ConcurrentHashMap<>(); public CasServer(OnDiskBlobStoreCache cache) { this.cache = cache; @@ -145,4 +157,79 @@ public void getTree(GetTreeRequest request, StreamObserver resp responseObserver.onNext(responseBuilder.build()); responseObserver.onCompleted(); } + + /** + * Returns the chunk digests for a blob that was previously stored via spliceBlob. + * Clients use this to download large blobs in smaller pieces. + */ + @Override + public void splitBlob( + SplitBlobRequest request, StreamObserver responseObserver) { + Digest blobDigest = request.getBlobDigest(); + + List chunkDigests = splicedBlobs.get(blobDigest); + if (chunkDigests == null) { + responseObserver.onError(StatusUtils.notFoundError(blobDigest)); + return; + } + responseObserver.onNext( + SplitBlobResponse.newBuilder() + .addAllChunkDigests(chunkDigests) + .setChunkingFunction(ChunkingFunction.Value.FAST_CDC_2020) + .build()); + responseObserver.onCompleted(); + } + + /** + * Stores a mapping from a blob digest to the list of chunk digests that compose it. + * + *

All chunks must already exist in the CAS. The concatenated chunks are verified + * to match the expected blob digest before storing the mapping. + */ + @Override + public void spliceBlob( + SpliceBlobRequest request, StreamObserver responseObserver) { + RequestMetadata meta = TracingMetadataUtils.fromCurrentContext(); + RemoteActionExecutionContext context = RemoteActionExecutionContext.create(meta); + + Digest blobDigest = request.getBlobDigest(); + List chunkDigests = request.getChunkDigestsList(); + + try { + // Verify all chunks exist in the cache. + for (Digest chunkDigest : chunkDigests) { + if (!cache.refresh(chunkDigest)) { + responseObserver.onError(StatusUtils.notFoundError(chunkDigest)); + return; + } + } + + DigestOutputStream digestOut = + cache.getDigestUtil().newDigestOutputStream(OutputStream.nullOutputStream()); + for (Digest chunkDigest : chunkDigests) { + byte[] chunkData = getFromFuture(cache.downloadBlob(context, chunkDigest)); + digestOut.write(chunkData); + } + Digest computedDigest = digestOut.digest(); + if (!computedDigest.equals(blobDigest)) { + String err = "Splice digest " + blobDigest + " did not match computed digest: " + computedDigest; + responseObserver.onError(StatusUtils.invalidArgumentError("blob_digest", err)); + return; + } + + // Record the blob-to-chunks mapping for splitBlob lookups. + splicedBlobs.put(blobDigest, new ArrayList<>(chunkDigests)); + + responseObserver.onNext( + SpliceBlobResponse.newBuilder().setBlobDigest(blobDigest).build()); + responseObserver.onCompleted(); + } catch (CacheNotFoundException e) { + responseObserver.onError(StatusUtils.notFoundError(e.getMissingDigest())); + } catch (InterruptedException e) { + responseObserver.onError(StatusUtils.interruptedError(blobDigest)); + } catch (Exception e) { + logger.atWarning().withCause(e).log("SpliceBlob request failed"); + responseObserver.onError(StatusUtils.internalError(e)); + } + } }