// Copyright 2016 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 com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.devtools.build.lib.actions.ActionInput; import com.google.devtools.build.lib.actions.ActionInputFileCache; import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe; import com.google.devtools.build.lib.remote.ContentDigests.ActionKey; import com.google.devtools.build.lib.remote.RemoteProtocol.ActionResult; import com.google.devtools.build.lib.remote.RemoteProtocol.BlobChunk; import com.google.devtools.build.lib.remote.RemoteProtocol.CasDownloadBlobRequest; import com.google.devtools.build.lib.remote.RemoteProtocol.CasDownloadReply; import com.google.devtools.build.lib.remote.RemoteProtocol.CasLookupRequest; import com.google.devtools.build.lib.remote.RemoteProtocol.CasStatus; import com.google.devtools.build.lib.remote.RemoteProtocol.CasUploadBlobReply; import com.google.devtools.build.lib.remote.RemoteProtocol.CasUploadBlobRequest; import com.google.devtools.build.lib.remote.RemoteProtocol.CasUploadTreeMetadataReply; import com.google.devtools.build.lib.remote.RemoteProtocol.CasUploadTreeMetadataRequest; import com.google.devtools.build.lib.remote.RemoteProtocol.ContentDigest; import com.google.devtools.build.lib.remote.RemoteProtocol.ExecutionCacheReply; import com.google.devtools.build.lib.remote.RemoteProtocol.ExecutionCacheRequest; import com.google.devtools.build.lib.remote.RemoteProtocol.ExecutionCacheSetReply; import com.google.devtools.build.lib.remote.RemoteProtocol.ExecutionCacheSetRequest; import com.google.devtools.build.lib.remote.RemoteProtocol.ExecutionCacheStatus; import com.google.devtools.build.lib.remote.RemoteProtocol.FileMetadata; import com.google.devtools.build.lib.remote.RemoteProtocol.FileNode; import com.google.devtools.build.lib.remote.RemoteProtocol.Output; import com.google.devtools.build.lib.remote.RemoteProtocol.Output.ContentCase; import com.google.devtools.build.lib.remote.TreeNodeRepository.TreeNode; import com.google.devtools.build.lib.util.Pair; import com.google.devtools.build.lib.util.Preconditions; import com.google.devtools.build.lib.vfs.FileSystemUtils; import com.google.devtools.build.lib.vfs.Path; import com.google.protobuf.ByteString; import io.grpc.Channel; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; /** A RemoteActionCache implementation that uses gRPC calls to a remote cache server. */ @ThreadSafe public class GrpcActionCache implements RemoteActionCache { private static final int MAX_MEMORY_KBYTES = 512 * 1024; /** Channel over which to send gRPC CAS queries. */ private final GrpcCasInterface casIface; private final GrpcExecutionCacheInterface iface; private final RemoteOptions options; public GrpcActionCache( RemoteOptions options, GrpcCasInterface casIface, GrpcExecutionCacheInterface iface) { this.options = options; this.casIface = casIface; this.iface = iface; } @VisibleForTesting public GrpcActionCache( Channel channel, RemoteOptions options, ChannelOptions channelOptions) { this.options = options; this.casIface = GrpcInterfaces.casInterface(options.grpcTimeoutSeconds, channel, channelOptions); this.iface = GrpcInterfaces.executionCacheInterface(options.grpcTimeoutSeconds, channel, channelOptions); } public GrpcActionCache(RemoteOptions options, ChannelOptions channelOptions) { this(RemoteUtils.createChannel(options.remoteCache, channelOptions), options, channelOptions); } public static boolean isRemoteCacheOptions(RemoteOptions options) { return options.remoteCache != null; } private ImmutableSet<ContentDigest> getMissingDigests(Iterable<ContentDigest> digests) { CasLookupRequest.Builder request = CasLookupRequest.newBuilder().addAllDigest(digests); if (request.getDigestCount() == 0) { return ImmutableSet.of(); } CasStatus status = casIface.lookup(request.build()).getStatus(); if (!status.getSucceeded() && status.getError() != CasStatus.ErrorCode.MISSING_DIGEST) { // TODO(olaola): here and below, add basic retry logic on transient errors! throw new RuntimeException(status.getErrorDetail()); } return ImmutableSet.copyOf(status.getMissingDigestList()); } /** * Upload enough of the tree metadata and data into remote cache so that the entire tree can be * reassembled remotely using the root digest. */ @Override public void uploadTree(TreeNodeRepository repository, Path execRoot, TreeNode root) throws IOException, InterruptedException { repository.computeMerkleDigests(root); // TODO(olaola): avoid querying all the digests, only ask for novel subtrees. ImmutableSet<ContentDigest> missingDigests = getMissingDigests(repository.getAllDigests(root)); // Only upload data that was missing from the cache. ArrayList<ActionInput> actionInputs = new ArrayList<>(); ArrayList<FileNode> treeNodes = new ArrayList<>(); repository.getDataFromDigests(missingDigests, actionInputs, treeNodes); if (!treeNodes.isEmpty()) { CasUploadTreeMetadataRequest.Builder metaRequest = CasUploadTreeMetadataRequest.newBuilder().addAllTreeNode(treeNodes); CasUploadTreeMetadataReply reply = casIface.uploadTreeMetadata(metaRequest.build()); if (!reply.getStatus().getSucceeded()) { throw new RuntimeException(reply.getStatus().getErrorDetail()); } } if (!actionInputs.isEmpty()) { uploadChunks( actionInputs.size(), new Chunker.Builder() .chunkSize(options.grpcMaxChunkSizeBytes) .addAllInputs(actionInputs, repository.getInputFileCache(), execRoot) .onlyUseDigests(missingDigests) .build()); } } /** * Download the entire tree data rooted by the given digest and write it into the given location. */ @Override public void downloadTree(ContentDigest rootDigest, Path rootLocation) throws IOException, CacheNotFoundException { throw new UnsupportedOperationException(); } private void handleDownloadStatus(CasStatus status) throws CacheNotFoundException { if (!status.getSucceeded()) { if (status.getError() == CasStatus.ErrorCode.MISSING_DIGEST) { throw new CacheNotFoundException(status.getMissingDigest(0)); } // TODO(olaola): deal with other statuses better. throw new RuntimeException(status.getErrorDetail()); } } /** * Download all results of a remotely executed action locally. TODO(olaola): will need to amend to * include the {@link com.google.devtools.build.lib.remote.TreeNodeRepository} for updating. */ @Override public void downloadAllResults(ActionResult result, Path execRoot) throws IOException, CacheNotFoundException { if (result.getOutputList().isEmpty()) { return; } // Send all the file requests in a single synchronous batch. // TODO(olaola): profile to maybe replace with separate concurrent requests. CasDownloadBlobRequest.Builder request = CasDownloadBlobRequest.newBuilder(); Map<ContentDigest, Pair<Path, FileMetadata>> metadataMap = new HashMap<>(); for (Output output : result.getOutputList()) { Path path = execRoot.getRelative(output.getPath()); if (output.getContentCase() == ContentCase.FILE_METADATA) { FileMetadata fileMetadata = output.getFileMetadata(); ContentDigest digest = fileMetadata.getDigest(); if (digest.getSizeBytes() > 0) { request.addDigest(digest); metadataMap.put(digest, Pair.of(path, fileMetadata)); } else { // Handle empty file locally. FileSystemUtils.createDirectoryAndParents(path.getParentDirectory()); FileSystemUtils.writeContent(path, new byte[0]); } } else { downloadTree(output.getDigest(), path); } } Iterator<CasDownloadReply> replies = casIface.downloadBlob(request.build()); Set<ContentDigest> results = new HashSet<>(); while (replies.hasNext()) { results.add(createFileFromStream(metadataMap, replies)); } for (ContentDigest digest : metadataMap.keySet()) { if (!results.contains(digest)) { throw new CacheNotFoundException(digest); } } } private ContentDigest createFileFromStream( Map<ContentDigest, Pair<Path, FileMetadata>> metadataMap, Iterator<CasDownloadReply> replies) throws IOException, CacheNotFoundException { Preconditions.checkArgument(replies.hasNext()); CasDownloadReply reply = replies.next(); if (reply.hasStatus()) { handleDownloadStatus(reply.getStatus()); } BlobChunk chunk = reply.getData(); ContentDigest digest = chunk.getDigest(); Preconditions.checkArgument(metadataMap.containsKey(digest)); Pair<Path, FileMetadata> metadata = metadataMap.get(digest); Path path = metadata.first; FileSystemUtils.createDirectoryAndParents(path.getParentDirectory()); try (OutputStream stream = path.getOutputStream()) { ByteString data = chunk.getData(); data.writeTo(stream); long bytesLeft = digest.getSizeBytes() - data.size(); while (bytesLeft > 0) { Preconditions.checkArgument(replies.hasNext()); reply = replies.next(); if (reply.hasStatus()) { handleDownloadStatus(reply.getStatus()); } chunk = reply.getData(); data = chunk.getData(); Preconditions.checkArgument(!chunk.hasDigest()); Preconditions.checkArgument(chunk.getOffset() == digest.getSizeBytes() - bytesLeft); data.writeTo(stream); bytesLeft -= data.size(); } path.setExecutable(metadata.second.getExecutable()); } return digest; } /** Upload all results of a locally executed action to the cache. */ @Override public void uploadAllResults(Path execRoot, Collection<Path> files, ActionResult.Builder result) throws IOException, InterruptedException { ArrayList<ContentDigest> digests = new ArrayList<>(); Chunker.Builder b = new Chunker.Builder().chunkSize(options.grpcMaxChunkSizeBytes); for (Path file : files) { digests.add(ContentDigests.computeDigest(file)); b.addInput(file); } ImmutableSet<ContentDigest> missing = getMissingDigests(digests); if (!missing.isEmpty()) { uploadChunks(missing.size(), b.onlyUseDigests(missing).build()); } int index = 0; for (Path file : files) { if (file.isDirectory()) { // TODO(olaola): to implement this for a directory, will need to create or pass a // TreeNodeRepository to call uploadTree. throw new UnsupportedOperationException("Storing a directory is not yet supported."); } // Add to protobuf. result .addOutputBuilder() .setPath(file.relativeTo(execRoot).getPathString()) .getFileMetadataBuilder() .setDigest(digests.get(index++)) .setExecutable(file.isExecutable()); } } /** * Put the file contents cache if it is not already in it. No-op if the file is already stored in * cache. The given path must be a full absolute path. * * @return The key for fetching the file contents blob from cache. */ @Override public ContentDigest uploadFileContents(Path file) throws IOException, InterruptedException { ContentDigest digest = ContentDigests.computeDigest(file); ImmutableSet<ContentDigest> missing = getMissingDigests(ImmutableList.of(digest)); if (!missing.isEmpty()) { uploadChunks(1, Chunker.from(file, options.grpcMaxChunkSizeBytes)); } return digest; } /** * Put the file contents cache if it is not already in it. No-op if the file is already stored in * cache. The given path must be a full absolute path. * * @return The key for fetching the file contents blob from cache. */ @Override public ContentDigest uploadFileContents( ActionInput input, Path execRoot, ActionInputFileCache inputCache) throws IOException, InterruptedException { ContentDigest digest = ContentDigests.getDigestFromInputCache(input, inputCache); ImmutableSet<ContentDigest> missing = getMissingDigests(ImmutableList.of(digest)); if (!missing.isEmpty()) { uploadChunks(1, Chunker.from(input, options.grpcMaxChunkSizeBytes, inputCache, execRoot)); } return digest; } static class UploadBlobReplyStreamObserver implements StreamObserver<CasUploadBlobReply> { private final CountDownLatch finishLatch; private final AtomicReference<RuntimeException> exception; public UploadBlobReplyStreamObserver( CountDownLatch finishLatch, AtomicReference<RuntimeException> exception) { this.finishLatch = finishLatch; this.exception = exception; } @Override public void onNext(CasUploadBlobReply reply) { if (!reply.getStatus().getSucceeded()) { // TODO(olaola): add basic retry logic on transient errors! this.exception.compareAndSet( null, new RuntimeException(reply.getStatus().getErrorDetail())); } } @Override public void onError(Throwable t) { this.exception.compareAndSet(null, new StatusRuntimeException(Status.fromThrowable(t))); finishLatch.countDown(); } @Override public void onCompleted() { finishLatch.countDown(); } } private void uploadChunks(int numItems, Chunker blobs) throws InterruptedException, IOException { CountDownLatch finishLatch = new CountDownLatch(numItems); // Maximal number of batches. AtomicReference<RuntimeException> exception = new AtomicReference<>(null); UploadBlobReplyStreamObserver responseObserver = null; StreamObserver<CasUploadBlobRequest> requestObserver = null; int currentBatchBytes = 0; int batchedInputs = 0; int batches = 0; try { while (blobs.hasNext()) { BlobChunk chunk = blobs.next(); if (chunk.hasDigest()) { // Determine whether to start next batch. final long batchSize = chunk.getDigest().getSizeBytes() + currentBatchBytes; if (batchedInputs % options.grpcMaxBatchInputs == 0 || batchSize > options.grpcMaxBatchSizeBytes) { // The batches execute simultaneously. if (requestObserver != null) { batchedInputs = 0; currentBatchBytes = 0; requestObserver.onCompleted(); } batches++; responseObserver = new UploadBlobReplyStreamObserver(finishLatch, exception); requestObserver = casIface.uploadBlobAsync(responseObserver); } batchedInputs++; } currentBatchBytes += chunk.getData().size(); requestObserver.onNext(CasUploadBlobRequest.newBuilder().setData(chunk).build()); if (finishLatch.getCount() == 0) { // RPC completed or errored before we finished sending. throw new RuntimeException( "gRPC terminated prematurely: " + (exception.get() != null ? exception.get() : "unknown cause")); } } } catch (RuntimeException e) { // Cancel RPC if (requestObserver != null) { requestObserver.onError(e); } throw e; } if (requestObserver != null) { requestObserver.onCompleted(); // Finish last batch. } while (batches++ < numItems) { finishLatch.countDown(); // Non-sent batches. } finishLatch.await(options.grpcTimeoutSeconds, TimeUnit.SECONDS); if (exception.get() != null) { throw exception.get(); // Re-throw the first encountered exception. } } @Override public ImmutableList<ContentDigest> uploadBlobs(Iterable<byte[]> blobs) throws InterruptedException { ArrayList<ContentDigest> digests = new ArrayList<>(); Chunker.Builder b = new Chunker.Builder().chunkSize(options.grpcMaxChunkSizeBytes); for (byte[] blob : blobs) { digests.add(ContentDigests.computeDigest(blob)); b.addInput(blob); } ImmutableSet<ContentDigest> missing = getMissingDigests(digests); try { if (!missing.isEmpty()) { uploadChunks(missing.size(), b.onlyUseDigests(missing).build()); } return ImmutableList.copyOf(digests); } catch (IOException e) { // This will never happen. throw new RuntimeException(e); } } @Override public ContentDigest uploadBlob(byte[] blob) throws InterruptedException { ContentDigest digest = ContentDigests.computeDigest(blob); ImmutableSet<ContentDigest> missing = getMissingDigests(ImmutableList.of(digest)); try { if (!missing.isEmpty()) { uploadChunks(1, Chunker.from(blob, options.grpcMaxChunkSizeBytes)); } return digest; } catch (IOException e) { // This will never happen. throw new RuntimeException(); } } @Override public byte[] downloadBlob(ContentDigest digest) throws CacheNotFoundException { return downloadBlobs(ImmutableList.of(digest)).get(0); } @Override public ImmutableList<byte[]> downloadBlobs(Iterable<ContentDigest> digests) throws CacheNotFoundException { // Send all the file requests in a single synchronous batch. // TODO(olaola): profile to maybe replace with separate concurrent requests. CasDownloadBlobRequest.Builder request = CasDownloadBlobRequest.newBuilder(); for (ContentDigest digest : digests) { if (digest.getSizeBytes() > 0) { request.addDigest(digest); // We handle empty blobs locally. } } Iterator<CasDownloadReply> replies = null; Map<ContentDigest, byte[]> results = new HashMap<>(); int digestCount = request.getDigestCount(); if (digestCount > 0) { replies = casIface.downloadBlob(request.build()); while (digestCount-- > 0) { Preconditions.checkArgument(replies.hasNext()); CasDownloadReply reply = replies.next(); if (reply.hasStatus()) { handleDownloadStatus(reply.getStatus()); } BlobChunk chunk = reply.getData(); ContentDigest digest = chunk.getDigest(); // This is not enough, but better than nothing. Preconditions.checkArgument(digest.getSizeBytes() / 1000.0 < MAX_MEMORY_KBYTES); byte[] result = new byte[(int) digest.getSizeBytes()]; ByteString data = chunk.getData(); data.copyTo(result, 0); int offset = data.size(); while (offset < result.length) { Preconditions.checkArgument(replies.hasNext()); reply = replies.next(); if (reply.hasStatus()) { handleDownloadStatus(reply.getStatus()); } chunk = reply.getData(); Preconditions.checkArgument(!chunk.hasDigest()); Preconditions.checkArgument(chunk.getOffset() == offset); data = chunk.getData(); data.copyTo(result, offset); offset += data.size(); } results.put(digest, result); } } ArrayList<byte[]> result = new ArrayList<>(); for (ContentDigest digest : digests) { if (digest.getSizeBytes() == 0) { result.add(new byte[0]); continue; } if (!results.containsKey(digest)) { throw new CacheNotFoundException(digest); } result.add(results.get(digest)); } return ImmutableList.copyOf(result); } // Execution Cache API /** Returns a cached result for a given Action digest, or null if not found in cache. */ @Override public ActionResult getCachedActionResult(ActionKey actionKey) { ExecutionCacheRequest request = ExecutionCacheRequest.newBuilder().setActionDigest(actionKey.getDigest()).build(); ExecutionCacheReply reply = iface.getCachedResult(request); ExecutionCacheStatus status = reply.getStatus(); if (!status.getSucceeded() && status.getError() != ExecutionCacheStatus.ErrorCode.MISSING_RESULT) { throw new RuntimeException(status.getErrorDetail()); } return reply.hasResult() ? reply.getResult() : null; } /** Sets the given result as result of the given Action. */ @Override public void setCachedActionResult(ActionKey actionKey, ActionResult result) throws InterruptedException { ExecutionCacheSetRequest request = ExecutionCacheSetRequest.newBuilder() .setActionDigest(actionKey.getDigest()) .setResult(result) .build(); ExecutionCacheSetReply reply = iface.setCachedResult(request); ExecutionCacheStatus status = reply.getStatus(); if (!status.getSucceeded() && status.getError() != ExecutionCacheStatus.ErrorCode.UNSUPPORTED) { throw new RuntimeException(status.getErrorDetail()); } } }