| // 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 static org.junit.Assert.assertThrows; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.devtools.build.lib.actions.BuildFailedException; |
| 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.options.RemoteStartupOptions; |
| 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.common.options.OptionsBase; |
| 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 the remote failure circuit breaker's opt-in half-open recovery, run under |
| * {@code --remote_download_minimal} so that tripping the breaker happens while prefetching a |
| * remote-only input. |
| * |
| * <p>Kept separate from {@link BuildWithoutTheBytesIntegrationTest} because these tests need a |
| * dedicated remote worker that injects transient read failures, whereas every other |
| * Build-without-the-Bytes test runs against a pristine worker. |
| */ |
| @RunWith(JUnit4.class) |
| public class RemoteCircuitBreakerRecoveryIntegrationTest extends BuildIntegrationTestCase { |
| |
| // A worker that returns UNAVAILABLE for the first armed ByteStream.Read, so a build can trip the |
| // remote failure circuit breaker on a transient, self-healing failure. |
| @ClassRule @Rule |
| public static final WorkerInstance worker = |
| IntegrationTestUtils.createFailFirstReadWorker(/* n= */ 1); |
| |
| // The worker logs this to stderr each time it injects a read failure (see FailFirstNInterceptor |
| // in RemoteWorker). The tests match on it to prove a transient failure was actually delivered; |
| // keep it in sync with the worker's message. |
| private static final String INJECTED_READ_FAILURE_LOG = |
| "INJECTED_UNAVAILABLE google.bytestream.ByteStream/Read"; |
| |
| @Override |
| protected ImmutableList<Class<? extends OptionsBase>> getStartupOptionClasses() { |
| return ImmutableList.<Class<? extends OptionsBase>>builder() |
| .addAll(super.getStartupOptionClasses()) |
| .add(RemoteStartupOptions.class) |
| .build(); |
| } |
| |
| @Override |
| protected void setupOptions() throws Exception { |
| super.setupOptions(); |
| addOptions( |
| "--remote_executor=grpc://localhost:" + worker.getPort(), |
| "--remote_download_minimal", |
| // Both tests recover a lost input via action rewinding: a failed prefetch (with |
| // --remote_retries=0) is treated as a lost input, and rewinding re-runs the action. |
| "--rewind_lost_inputs", |
| // Disable build-level invocation retries (default 5) so recovery is exercised via action |
| // rewinding within one build, not by retrying the whole build with a fresh breaker. |
| "--experimental_remote_cache_eviction_retries=0"); |
| } |
| |
| @Override |
| protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception { |
| return super.getRuntimeBuilder() |
| .addBlazeModule(new RemoteModule()) |
| .addBlazeModule(new BuildSummaryStatsModule()) |
| .addBlazeModule(new BlockWaitingModule()); |
| } |
| |
| @Override |
| protected ImmutableList<BlazeModule> getSpawnModules() { |
| return ImmutableList.<BlazeModule>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 assertValidOutputFile(String binRelativePath, String content) throws Exception { |
| Path output = getOutputPath(binRelativePath); |
| assertThat(readContent(output, UTF_8)).isEqualTo(content); |
| assertThat(output.isReadable()).isTrue(); |
| assertThat(output.isWritable()).isFalse(); |
| assertThat(output.isExecutable()).isTrue(); |
| } |
| |
| private void writeFooBar() throws Exception { |
| write( |
| "a/BUILD", |
| """ |
| genrule( |
| name = "foo", |
| srcs = ["foo.in"], |
| outs = ["foo.out"], |
| cmd = "cat $(SRCS) > $@", |
| ) |
| |
| genrule( |
| name = "bar", |
| srcs = [ |
| "foo.out", |
| "bar.in", |
| ], |
| outs = ["bar.out"], |
| cmd = "cat $(SRCS) > $@", |
| tags = ["no-remote-exec"], |
| ) |
| """); |
| write("a/foo.in", "foo"); |
| write("a/bar.in", "bar"); |
| } |
| |
| /** |
| * Populates the remote cache and leaves foo.out remote-only, so a later build of //a:bar must |
| * prefetch it. The failing worker is wired up in {@link #setupOptions}, which {@link |
| * #createRuntimeWrapper} re-runs, so foo.out stays remote-only across the simulated server |
| * restart. |
| */ |
| private void setupRemoteOnlyFooOut() throws Exception { |
| writeFooBar(); |
| |
| buildTarget("//a:bar"); |
| getOutputPath("a/foo.out").delete(); |
| getOutputPath("a/bar.out").delete(); |
| getOutputBase().getRelative("action_cache").deleteTreesBelow(); |
| createRuntimeWrapper(); // Simulate a server restart. |
| |
| buildTarget("//a:bar"); |
| assertThat(getOutputPath("a/foo.out").exists()).isFalse(); |
| } |
| |
| /** |
| * Asserts the worker delivered at least one injected read failure since {@code |
| * previousStderrLength} (captured just before the build) — i.e. the breaker actually saw a |
| * transient remote failure, so a green build is meaningful rather than vacuous. |
| */ |
| private static void assertReadFailureInjectedSince(int previousStderrLength) { |
| assertThat(worker.getStderr().substring(previousStderrLength)) |
| .contains(INJECTED_READ_FAILURE_LOG); |
| } |
| |
| @Test |
| public void recovers_whenPrefetchingInput_succeedsWithActionRewinding() throws Exception { |
| setupRemoteOnlyFooOut(); |
| |
| // Act: enable the failure breaker with a short recovery delay, arm a single transient read |
| // failure, and force bar to re-prefetch the remote-only foo.out. The prefetch read trips the |
| // breaker; with --remote_retries=0 that becomes a lost input and drives action rewinding, and |
| // the breaker re-closes on a trial probe during the rewind so the build recovers. |
| addOptions( |
| "--experimental_circuit_breaker_strategy=failure", |
| "--experimental_remote_min_fail_count_to_compute_failure_rate=1", |
| "--experimental_remote_min_call_count_to_compute_failure_rate=1", |
| "--experimental_remote_failure_rate_threshold=1", |
| "--experimental_remote_circuit_breaker_recovery_delay=1ms", |
| "--remote_retries=0"); |
| write("a/bar.in", "updated bar"); |
| int stderrLenBefore = worker.getStderr().length(); |
| worker.armReadFailures(); |
| |
| buildTarget("//a:bar"); |
| |
| // Assert: a transient failure was actually delivered (the breaker tripped) and, thanks to |
| // recovery, the build still succeeded with the correct output. |
| assertReadFailureInjectedSince(stderrLenBefore); |
| assertValidOutputFile("a/bar.out", "foo\nupdated bar\n"); |
| } |
| |
| @Test |
| public void trips_whenPrefetchingInput_recoveryDisabled_fails() throws Exception { |
| setupRemoteOnlyFooOut(); |
| |
| // Act: same as the recovery test, but recovery is disabled (the delay defaults to 0), so the |
| // tripped breaker stays open for the rest of the build and the rewound re-execution is |
| // rejected. |
| addOptions( |
| "--experimental_circuit_breaker_strategy=failure", |
| "--experimental_remote_min_fail_count_to_compute_failure_rate=1", |
| "--experimental_remote_min_call_count_to_compute_failure_rate=1", |
| "--experimental_remote_failure_rate_threshold=1", |
| "--remote_retries=0"); |
| write("a/bar.in", "updated bar"); |
| int stderrLenBefore = worker.getStderr().length(); |
| worker.armReadFailures(); |
| |
| var error = assertThrows(BuildFailedException.class, () -> buildTarget("//a:bar")); |
| |
| // Assert: the breaker tripped and, without recovery, the build failed with a remote error. |
| assertReadFailureInjectedSince(stderrLenBefore); |
| assertThat(error.getDetailedExitCode().getExitCode().getNumericExitCode()).isEqualTo(34); |
| } |
| } |