| // Copyright 2019 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.base.Preconditions.checkNotNull; | |
| import static com.google.common.collect.ImmutableList.toImmutableList; | |
| import static com.google.common.util.concurrent.Futures.immediateFailedFuture; | |
| import static com.google.common.util.concurrent.Futures.immediateFuture; | |
| import static com.google.common.util.concurrent.Futures.immediateVoidFuture; | |
| import static com.google.common.util.concurrent.MoreExecutors.directExecutor; | |
| import static com.google.devtools.build.lib.remote.util.RxFutures.toCompletable; | |
| import static com.google.devtools.build.lib.remote.util.RxFutures.toSingle; | |
| import static com.google.devtools.build.lib.remote.util.RxUtils.mergeBulkTransfer; | |
| import static com.google.devtools.build.lib.remote.util.RxUtils.toTransferResult; | |
| import static java.lang.String.format; | |
| import build.bazel.remote.execution.v2.Digest; | |
| import com.google.common.annotations.VisibleForTesting; | |
| import com.google.common.base.Throwables; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.common.collect.ImmutableSet; | |
| import com.google.common.collect.Iterables; | |
| import com.google.common.flogger.GoogleLogger; | |
| import com.google.common.util.concurrent.Futures; | |
| import com.google.common.util.concurrent.ListenableFuture; | |
| import com.google.devtools.build.lib.actions.cache.VirtualActionInput; | |
| import com.google.devtools.build.lib.profiler.Profiler; | |
| import com.google.devtools.build.lib.profiler.SilentCloseable; | |
| 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.common.RemoteCacheClient; | |
| import com.google.devtools.build.lib.remote.common.RemoteCacheClient.Blob; | |
| import com.google.devtools.build.lib.remote.common.RemotePathResolver; | |
| import com.google.devtools.build.lib.remote.disk.DiskCacheClient; | |
| import com.google.devtools.build.lib.remote.merkletree.MerkleTree; | |
| import com.google.devtools.build.lib.remote.merkletree.MerkleTreeUploader; | |
| import com.google.devtools.build.lib.remote.options.RemoteOptions.ChunkingFunctionValue; | |
| import com.google.devtools.build.lib.remote.util.AsyncTaskCache; | |
| import com.google.devtools.build.lib.remote.util.DigestUtil; | |
| import com.google.devtools.build.lib.remote.util.RxUtils.TransferResult; | |
| import com.google.devtools.build.lib.vfs.Path; | |
| import com.google.protobuf.Message; | |
| import io.reactivex.rxjava3.annotations.NonNull; | |
| import io.reactivex.rxjava3.core.Completable; | |
| import io.reactivex.rxjava3.core.CompletableObserver; | |
| import io.reactivex.rxjava3.core.Flowable; | |
| import io.reactivex.rxjava3.core.Maybe; | |
| import io.reactivex.rxjava3.core.Observable; | |
| import io.reactivex.rxjava3.core.Single; | |
| import io.reactivex.rxjava3.core.SingleEmitter; | |
| import io.reactivex.rxjava3.disposables.Disposable; | |
| import io.reactivex.rxjava3.subjects.AsyncSubject; | |
| import java.io.ByteArrayInputStream; | |
| import java.io.IOException; | |
| import java.io.InputStream; | |
| import java.io.PipedInputStream; | |
| import java.io.PipedOutputStream; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.concurrent.ExecutorService; | |
| import java.util.concurrent.Executors; | |
| import java.util.concurrent.atomic.AtomicReference; | |
| import javax.annotation.Nullable; | |
| /** A {@link CombinedCache} with additional functionality needed for remote execution. */ | |
| public class RemoteExecutionCache extends CombinedCache implements MerkleTreeUploader { | |
| private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); | |
| /** | |
| * An interface used to check whether a given {@link Path} is available without contacting the | |
| * remote cache, i.e., it is present on the local disk, perhaps after being downloaded from the | |
| * disk cache. | |
| */ | |
| public interface RemotePathChecker { | |
| ListenableFuture<Boolean> isAvailableLocally(RemoteActionExecutionContext context, Path path); | |
| } | |
| /** | |
| * Deduplicates concurrent {@code findMissingDigests} queries for the same digest across | |
| * overlapping {@link #ensureInputsPresent} invocations. Results reported as "present" stay cached | |
| * until explicitly invalidated, but a "missing" result is invalidated as soon as the triggered | |
| * upload attempt terminates. | |
| */ | |
| private final AsyncTaskCache<Digest, Boolean> findMissingCache = AsyncTaskCache.create(); | |
| private RemotePathChecker remotePathChecker = | |
| new RemotePathChecker() { | |
| @Override | |
| public ListenableFuture<Boolean> isAvailableLocally( | |
| RemoteActionExecutionContext context, Path path) { | |
| var fs = path.getFileSystem(); | |
| if (!(fs instanceof RemoteActionFileSystem remoteActionFileSystem)) { | |
| return immediateFuture(true); | |
| } | |
| // If the file is available in the disk cache, we can attempt to download it from there. | |
| ListenableFuture<Void> downloadFromDiskCache = immediateVoidFuture(); | |
| if (context.getReadCachePolicy().allowDiskCache()) { | |
| downloadFromDiskCache = | |
| Futures.catchingAsync( | |
| remoteActionFileSystem.downloadIfRemote(path.asFragment()), | |
| IOException.class, | |
| e -> { | |
| logger.atWarning().withCause(e).log( | |
| "Failed to download %s", path.getPathString()); | |
| return immediateVoidFuture(); | |
| }, | |
| directExecutor()); | |
| } | |
| return Futures.transform( | |
| downloadFromDiskCache, | |
| unused -> remoteActionFileSystem.getHostFileSystem().exists(path.asFragment()), | |
| directExecutor()); | |
| } | |
| }; | |
| public RemoteExecutionCache( | |
| RemoteCacheClient remoteCacheClient, | |
| @Nullable DiskCacheClient diskCacheClient, | |
| @Nullable String symlinkTemplate, | |
| DigestUtil digestUtil, | |
| @Nullable ChunkingFunctionValue chunkingFunction) { | |
| super( | |
| checkNotNull(remoteCacheClient), | |
| diskCacheClient, | |
| symlinkTemplate, | |
| digestUtil, | |
| chunkingFunction); | |
| } | |
| @VisibleForTesting | |
| void setRemotePathChecker(RemotePathChecker remotePathChecker) { | |
| this.remotePathChecker = remotePathChecker; | |
| } | |
| /** | |
| * Ensures that the tree structure of the inputs, the input files themselves, and the command are | |
| * available in the remote cache, such that the tree can be reassembled and executed on another | |
| * machine given the root digest. | |
| * | |
| * <p>The cache may check whether files or parts of the tree structure are already present, and do | |
| * not need to be uploaded again. | |
| * | |
| * <p>Note that this method is only required for remote execution, not for caching itself. | |
| * However, remote execution uses a cache to store input files, and that may be a separate | |
| * end-point from the executor itself, so the functionality lives here. | |
| */ | |
| public void ensureInputsPresent( | |
| RemoteActionExecutionContext context, | |
| MerkleTree.Uploadable merkleTree, | |
| Map<Digest, Message> additionalInputs, | |
| boolean force, | |
| @Nullable RemotePathResolver remotePathResolver) | |
| throws IOException, InterruptedException { | |
| Flowable<TransferResult> uploads = | |
| createUploadTasks(context, merkleTree, additionalInputs, force, remotePathResolver) | |
| .flatMapPublisher( | |
| result -> | |
| Flowable.using( | |
| () -> result, | |
| uploadTasks -> | |
| findMissingBlobs(context, uploadTasks) | |
| .flatMapPublisher(this::waitForUploadTasks), | |
| uploadTasks -> { | |
| for (UploadTask uploadTask : uploadTasks) { | |
| Disposable d = uploadTask.disposable.getAndSet(null); | |
| if (d != null) { | |
| d.dispose(); | |
| } | |
| } | |
| })); | |
| try { | |
| mergeBulkTransfer(uploads).blockingAwait(); | |
| } catch (RuntimeException e) { | |
| Throwable cause = e.getCause(); | |
| if (cause != null) { | |
| Throwables.throwIfInstanceOf(cause, InterruptedException.class); | |
| Throwables.throwIfInstanceOf(cause, IOException.class); | |
| } | |
| throw e; | |
| } | |
| } | |
| @Override | |
| public void ensureInputsPresent( | |
| RemoteActionExecutionContext context, | |
| MerkleTree.Uploadable merkleTree, | |
| boolean force, | |
| RemotePathResolver remotePathResolver) | |
| throws IOException, InterruptedException { | |
| ensureInputsPresent(context, merkleTree, ImmutableMap.of(), force, remotePathResolver); | |
| } | |
| @Override | |
| public ListenableFuture<Void> uploadFile( | |
| RemoteActionExecutionContext context, | |
| RemotePathResolver remotePathResolver, | |
| Digest digest, | |
| Path path, | |
| boolean force) { | |
| return Futures.transformAsync( | |
| remotePathChecker.isAvailableLocally(context, path), | |
| isAvailableLocally -> { | |
| if (!isAvailableLocally) { | |
| // If we get here, the remote input was determined to exist in the remote or disk | |
| // cache at some point before action execution, but reported to be missing when | |
| // querying the remote for missing action inputs; possibly because it was evicted in | |
| // the interim. | |
| if (remotePathResolver != null) { | |
| throw new CacheNotFoundException( | |
| digest, remotePathResolver.localPathToExecPath(path.asFragment())); | |
| } else { | |
| // This path should only be taken for RemoteRepositoryRemoteExecutor, which has no | |
| // way to handle lost inputs. | |
| throw new CacheNotFoundException(digest, path.getPathString()); | |
| } | |
| } | |
| return remoteCacheClient.uploadFile(context, digest, path, force); | |
| }, | |
| directExecutor()); | |
| } | |
| @Override | |
| public ListenableFuture<Void> uploadVirtualActionInput( | |
| RemoteActionExecutionContext context, | |
| Digest digest, | |
| VirtualActionInput virtualActionInput, | |
| boolean force) { | |
| return remoteCacheClient.uploadBlob( | |
| context, digest, new VirtualActionInputBlob(virtualActionInput), force); | |
| } | |
| private record VirtualActionInputBlob(VirtualActionInput virtualActionInput) implements Blob { | |
| @SuppressWarnings("AllowVirtualThreads") | |
| private static final ExecutorService VIRTUAL_ACTION_INPUT_PIPE_EXECUTOR = | |
| Executors.newThreadPerTaskExecutor( | |
| Thread.ofVirtual().name("virtual-action-input-pipe-", 0).factory()); | |
| @Override | |
| public InputStream get() { | |
| // Avoid materializing and retaining VirtualActionInput.getBytes() during the upload. This | |
| // can result in high memory usage with many parallel actions with large virtual inputs. Limit | |
| // this memory usage to the fixed buffer size by using a piped stream. | |
| var pipedIn = new PipedInputStream(Chunker.getDefaultChunkSize()); | |
| PipedOutputStream pipedOut; | |
| try { | |
| pipedOut = new PipedOutputStream(pipedIn); | |
| } catch (IOException e) { | |
| throw new IllegalStateException( | |
| "PipedOutputStream constructor is not expected to throw", e); | |
| } | |
| // Note that while Piped{Input,Output}Stream are not directly I/O-bound, bytes read from | |
| // pipedIn are sent out via gRPC before more bytes are read. As a result, pipedOut is expected | |
| // to block frequently enough to make virtual threads suitable here. | |
| var unused = | |
| VIRTUAL_ACTION_INPUT_PIPE_EXECUTOR.submit( | |
| () -> { | |
| try (pipedOut) { | |
| virtualActionInput.writeTo(pipedOut); | |
| } catch (IOException e) { | |
| // Since VirtualActionInput#writeTo only throws when pipedOut does, this means | |
| // that the reader has closed pipedIn early, perhaps due to interruption. Since | |
| // the reader is gone, there is no way to propagate this exception back. | |
| } | |
| }); | |
| return pipedIn; | |
| } | |
| } | |
| @Override | |
| public ListenableFuture<Void> uploadBlob( | |
| RemoteActionExecutionContext context, Digest digest, byte[] data, boolean force) { | |
| return remoteCacheClient.uploadBlob( | |
| context, digest, () -> new ByteArrayInputStream(data), force); | |
| } | |
| private ListenableFuture<Void> uploadBlob( | |
| RemoteActionExecutionContext context, | |
| Digest digest, | |
| MerkleTree.Uploadable merkleTree, | |
| Map<Digest, Message> additionalInputs, | |
| @Nullable RemotePathResolver remotePathResolver, | |
| boolean force) { | |
| var upload = merkleTree.upload(this, context, remotePathResolver, digest, force); | |
| if (upload.isPresent()) { | |
| return upload.get(); | |
| } | |
| Message message = additionalInputs.get(digest); | |
| if (message != null) { | |
| return remoteCacheClient.uploadBlob(context, digest, message.toByteString(), force); | |
| } | |
| return immediateFailedFuture( | |
| new IOException( | |
| format( | |
| "findMissingDigests returned a missing digest that has not been requested: %s", | |
| digest))); | |
| } | |
| static class UploadTask { | |
| Digest digest; | |
| AtomicReference<Disposable> disposable; | |
| SingleEmitter<Boolean> continuation; | |
| Completable completion; | |
| } | |
| private Single<List<UploadTask>> createUploadTasks( | |
| RemoteActionExecutionContext context, | |
| MerkleTree.Uploadable merkleTree, | |
| Map<Digest, Message> additionalInputs, | |
| boolean force, | |
| @Nullable RemotePathResolver remotePathResolver) { | |
| var allDigests = Iterables.concat(merkleTree.allDigests(), additionalInputs.keySet()); | |
| if (Iterables.isEmpty(allDigests)) { | |
| return Single.just(ImmutableList.of()); | |
| } | |
| return Single.using( | |
| () -> Profiler.instance().profile("collect digests"), | |
| ignored -> | |
| Flowable.fromIterable(allDigests) | |
| .flatMapMaybe( | |
| digest -> | |
| maybeCreateUploadTask( | |
| context, | |
| merkleTree, | |
| additionalInputs, | |
| digest, | |
| force, | |
| remotePathResolver)) | |
| .collect(toImmutableList()), | |
| SilentCloseable::close); | |
| } | |
| private Maybe<UploadTask> maybeCreateUploadTask( | |
| RemoteActionExecutionContext context, | |
| MerkleTree.Uploadable merkleTree, | |
| Map<Digest, Message> additionalInputs, | |
| Digest digest, | |
| boolean force, | |
| @Nullable RemotePathResolver remotePathResolver) { | |
| return Maybe.create( | |
| emitter -> { | |
| AsyncSubject<Void> completion = AsyncSubject.create(); | |
| UploadTask uploadTask = new UploadTask(); | |
| uploadTask.digest = digest; | |
| uploadTask.disposable = new AtomicReference<>(); | |
| uploadTask.completion = Completable.fromObservable(completion); | |
| Completable upload = | |
| findMissingCache | |
| .execute( | |
| digest, | |
| Single.<Boolean>create( | |
| continuation -> { | |
| uploadTask.continuation = continuation; | |
| emitter.onSuccess(uploadTask); | |
| }), | |
| /* onAlreadyRunning= */ () -> emitter.onSuccess(uploadTask), | |
| /* onAlreadyFinished= */ () -> emitter.onSuccess(uploadTask), | |
| force) | |
| .flatMapCompletable( | |
| shouldUpload -> { | |
| if (!shouldUpload) { | |
| return Completable.complete(); | |
| } | |
| return toCompletable( | |
| () -> | |
| uploadBlob( | |
| context, | |
| uploadTask.digest, | |
| merkleTree, | |
| additionalInputs, | |
| remotePathResolver, | |
| force), | |
| directExecutor()) | |
| // On success, the digest is now present remotely: replace the cached | |
| // "missing" answer with "present" so late callers (or the next | |
| // ensureInputsPresent invocation) skip both findMissingDigests and | |
| // the upload path. On failure, invalidate so a subsequent caller | |
| // (e.g., after action rewinding) re-queries the remote. | |
| .doOnComplete(() -> findMissingCache.put(digest, false)) | |
| .doOnError(t -> findMissingCache.invalidate(digest)); | |
| }); | |
| upload.subscribe( | |
| new CompletableObserver() { | |
| @Override | |
| public void onSubscribe(@NonNull Disposable d) { | |
| uploadTask.disposable.set(d); | |
| } | |
| @Override | |
| public void onComplete() { | |
| completion.onComplete(); | |
| } | |
| @Override | |
| public void onError(@NonNull Throwable e) { | |
| completion.onError(e); | |
| } | |
| }); | |
| }); | |
| } | |
| private Single<List<UploadTask>> findMissingBlobs( | |
| RemoteActionExecutionContext context, List<UploadTask> uploadTasks) { | |
| return Single.using( | |
| () -> Profiler.instance().profile("findMissingDigests"), | |
| ignored -> | |
| Single.fromObservable( | |
| Observable.fromSingle( | |
| toSingle( | |
| () -> { | |
| ImmutableList<Digest> digestsToQuery = | |
| uploadTasks.stream() | |
| .filter(uploadTask -> uploadTask.continuation != null) | |
| .map(uploadTask -> uploadTask.digest) | |
| .collect(toImmutableList()); | |
| if (digestsToQuery.isEmpty()) { | |
| return immediateFuture(ImmutableSet.of()); | |
| } | |
| return remoteCacheClient.findMissingDigests( | |
| context, digestsToQuery); | |
| }, | |
| directExecutor()) | |
| .map( | |
| missingDigests -> { | |
| for (UploadTask uploadTask : uploadTasks) { | |
| if (uploadTask.continuation != null) { | |
| uploadTask.continuation.onSuccess( | |
| missingDigests.contains(uploadTask.digest)); | |
| } | |
| } | |
| return uploadTasks; | |
| })) | |
| // Use AsyncSubject so that if downstream is disposed, the | |
| // findMissingDigests call is not cancelled (because it may be needed by | |
| // other threads). | |
| .subscribeWith(AsyncSubject.create())), | |
| SilentCloseable::close); | |
| } | |
| private Flowable<TransferResult> waitForUploadTasks(List<UploadTask> uploadTasks) { | |
| return Flowable.using( | |
| () -> Profiler.instance().profile("upload"), | |
| ignored -> | |
| Flowable.fromIterable(uploadTasks) | |
| .flatMapSingle(uploadTask -> toTransferResult(uploadTask.completion)), | |
| SilentCloseable::close); | |
| } | |
| } |