Skip to content

fix(gax): use fake clock in ScheduledRetryingExecutorTest for robust and fast execution#13553

Draft
lqiu96 wants to merge 2 commits into
mainfrom
fix-issue-13535-java
Draft

fix(gax): use fake clock in ScheduledRetryingExecutorTest for robust and fast execution#13553
lqiu96 wants to merge 2 commits into
mainfrom
fix-issue-13535-java

Conversation

@lqiu96

@lqiu96 lqiu96 commented Jun 24, 2026

Copy link
Copy Markdown
Member

Introduced FakeApiClock and RecordingScheduler to ScheduledRetryingExecutorTest to run most tests (including the flaky testSuccessWithFailuresGetAttempt) instantly without real-time delays.

Isolated tests that rely on asynchronous polling or cancellation to use local real executors and clocks, with increased timeout (10s) to avoid flakiness on slow VMs.

Made RecordingScheduler stubbings lenient to avoid UnnecessaryStubbingException in tests that do not query all recorded metrics.

Fixes: #13535

…and fast execution

Introduced FakeApiClock and RecordingScheduler to ScheduledRetryingExecutorTest
to run most tests (including the flaky testSuccessWithFailuresGetAttempt)
instantly without real-time delays.

Isolated tests that rely on asynchronous polling or cancellation to use
local real executors and clocks, with increased timeout (10s) to avoid
flakiness on slow VMs.

Made RecordingScheduler stubbings lenient to avoid UnnecessaryStubbingException
in tests that do not query all recorded metrics.

TAG=agy
CONV=71af906a-b7dc-4dda-a2fa-ebe24b5d8b91
BUG=13535

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the test suite in ScheduledRetryingExecutorTest to use a fake clock and introduces local scheduled executors for isolated test runs. It also applies Mockito.lenient() to mock configurations in RecordingScheduler to prevent strict stubbing errors. The review feedback suggests moving the creation and shutdown of the local executors inside the test loops for testSuccessWithFailuresPeekAttempt and testCancelOuterFutureAfterStart to ensure complete isolation between iterations and avoid potential test flakiness.

Comment on lines 106 to 157
ScheduledExecutorService localExecutor = Executors.newSingleThreadScheduledExecutor();
try {
for (int executionsCount = 0; executionsCount < EXECUTIONS_COUNT; executionsCount++) {

FailingCallable callable = new FailingCallable(15, "request", "SUCCESS", tracer);

RetryingExecutorWithContext<String> executor =
getRetryingExecutor(
getAlgorithm(retrySettings, 0, null, NanoClock.getDefaultClock()), localExecutor);
RetryingFuture<String> future =
executor.createFuture(
callable,
FakeCallContext.createDefault().withTracer(tracer).withRetrySettings(retrySettings));
callable.setExternalFuture(future);

assertNull(future.peekAttemptResult());
assertSame(future.peekAttemptResult(), future.peekAttemptResult());
assertFalse(future.getAttemptResult().isDone());
assertFalse(future.getAttemptResult().isCancelled());

future.setAttemptFuture(executor.submit(future));

final AtomicInteger failedAttempts = new AtomicInteger(0);
final AtomicReference<ApiFuture<String>> lastSeenAttempt = new AtomicReference<>();
await()
.pollInterval(Duration.ofMillis(2))
.atMost(Duration.ofSeconds(5))
.until(
() -> {
ApiFuture<String> attemptResult = future.peekAttemptResult();
if (attemptResult != null && attemptResult != lastSeenAttempt.get()) {
lastSeenAttempt.set(attemptResult);
assertTrue(attemptResult.isDone());
assertFalse(attemptResult.isCancelled());
try {
attemptResult.get();
} catch (ExecutionException e) {
if (e.getCause() instanceof CustomException) {
failedAttempts.incrementAndGet();
}
}
}
}
return future.isDone();
});
return future.isDone();
});

assertFutureSuccess(future);
assertEquals(15, future.getAttemptSettings().getAttemptCount());
assertTrue(failedAttempts.get() > 0);
assertFutureSuccess(future);
assertEquals(15, future.getAttemptSettings().getAttemptCount());
assertTrue(failedAttempts.get() > 0);
}
} finally {
localExecutor.shutdownNow();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating the localExecutor outside the loop means it is shared across all iterations of the test. If any tasks from a previous iteration are still executing or scheduled, they could interfere with subsequent iterations, leading to test flakiness or race conditions. Creating and shutting down the executor inside the loop ensures complete isolation between iterations.

    for (int executionsCount = 0; executionsCount < EXECUTIONS_COUNT; executionsCount++) {
      ScheduledExecutorService localExecutor = Executors.newSingleThreadScheduledExecutor();
      try {
        FailingCallable callable = new FailingCallable(15, "request", "SUCCESS", tracer);

        RetryingExecutorWithContext<String> executor =
            getRetryingExecutor(
                getAlgorithm(retrySettings, 0, null, NanoClock.getDefaultClock()), localExecutor);
        RetryingFuture<String> future =
            executor.createFuture(
                callable,
                FakeCallContext.createDefault().withTracer(tracer).withRetrySettings(retrySettings));
        callable.setExternalFuture(future);

        assertNull(future.peekAttemptResult());
        assertSame(future.peekAttemptResult(), future.peekAttemptResult());
        assertFalse(future.getAttemptResult().isDone());
        assertFalse(future.getAttemptResult().isCancelled());

        future.setAttemptFuture(executor.submit(future));

        final AtomicInteger failedAttempts = new AtomicInteger(0);
        final AtomicReference<ApiFuture<String>> lastSeenAttempt = new AtomicReference<>();
        await()
            .pollInterval(Duration.ofMillis(2))
            .atMost(Duration.ofSeconds(5))
            .until(
                () -> {
                  ApiFuture<String> attemptResult = future.peekAttemptResult();
                  if (attemptResult != null && attemptResult != lastSeenAttempt.get()) {
                    lastSeenAttempt.set(attemptResult);
                    assertTrue(attemptResult.isDone());
                    assertFalse(attemptResult.isCancelled());
                    try {
                      attemptResult.get();
                    } catch (ExecutionException e) {
                      if (e.getCause() instanceof CustomException) {
                        failedAttempts.incrementAndGet();
                      }
                    }
                  }
                  return future.isDone();
                });

        assertFutureSuccess(future);
        assertEquals(15, future.getAttemptSettings().getAttemptCount());
        assertTrue(failedAttempts.get() > 0);
      } finally {
        localExecutor.shutdownNow();
      }
    }

Comment on lines 287 to 323
ScheduledExecutorService localExecutor = Executors.newSingleThreadScheduledExecutor();
try {
for (int executionsCount = 0; executionsCount < EXECUTIONS_COUNT; executionsCount++) {
FailingCallable callable = new FailingCallable(4, "request", "SUCCESS", tracer);
RetryingExecutorWithContext<String> executor =
getRetryingExecutor(
getAlgorithm(retrySettings, 0, null, NanoClock.getDefaultClock()), localExecutor);
RetryingFuture<String> future =
executor.createFuture(
callable,
FakeCallContext.createDefault().withTracer(tracer).withRetrySettings(retrySettings));
callable.setExternalFuture(future);
future.setAttemptFuture(executor.submit(future));

await()
.atMost(Duration.ofSeconds(5))
.until(
() ->
future.getAttemptSettings() != null
&& future.getAttemptSettings().getAttemptCount() > 0);

boolean res = future.cancel(false);
assertTrue(res);
assertFutureCancel(future);

// Verify that the cancelled future is traced. Every attempt increases the number
// of cancellation attempts from the tracer.
Mockito.verify(tracer, Mockito.times(executionsCount + 1)).attemptCancelled();

// Assert that future has at least been attempted once
// i.e. The future from executor.submit() has been run by the ScheduledExecutor
assertTrue(future.getAttemptSettings().getAttemptCount() > 0);
assertTrue(future.getAttemptSettings().getAttemptCount() < 4);
}
} finally {
localExecutor.shutdownNow();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the other test, creating the localExecutor outside the loop can lead to cross-iteration interference. Since this test verifies the number of cancellation attempts on the shared tracer, any delayed task from a previous iteration that runs late and invokes the tracer could cause the verification to fail. Creating and shutting down the executor inside the loop ensures complete isolation.

    for (int executionsCount = 0; executionsCount < EXECUTIONS_COUNT; executionsCount++) {
      ScheduledExecutorService localExecutor = Executors.newSingleThreadScheduledExecutor();
      try {
        FailingCallable callable = new FailingCallable(4, "request", "SUCCESS", tracer);
        RetryingExecutorWithContext<String> executor =
            getRetryingExecutor(
                getAlgorithm(retrySettings, 0, null, NanoClock.getDefaultClock()), localExecutor);
        RetryingFuture<String> future =
            executor.createFuture(
                callable,
                FakeCallContext.createDefault().withTracer(tracer).withRetrySettings(retrySettings));
        callable.setExternalFuture(future);
        future.setAttemptFuture(executor.submit(future));

        await()
            .atMost(Duration.ofSeconds(5))
            .until(
                () ->
                    future.getAttemptSettings() != null
                        && future.getAttemptSettings().getAttemptCount() > 0);

        boolean res = future.cancel(false);
        assertTrue(res);
        assertFutureCancel(future);

        // Verify that the cancelled future is traced. Every attempt increases the number
        // of cancellation attempts from the tracer.
        Mockito.verify(tracer, Mockito.times(executionsCount + 1)).attemptCancelled();

        // Assert that future has at least been attempted once
        // i.e. The future from executor.submit() has been run by the ScheduledExecutor
        assertTrue(future.getAttemptSettings().getAttemptCount() > 0);
        assertTrue(future.getAttemptSettings().getAttemptCount() < 4);
      } finally {
        localExecutor.shutdownNow();
      }
    }

…xecutorTest

Addressed PR comments by moving the creation and shutdown of local
executors inside the test loops for testSuccessWithFailuresPeekAttempt
and testCancelOuterFutureAfterStart to ensure complete isolation
between iterations and avoid potential flakiness.

TAG=agy
CONV=71af906a-b7dc-4dda-a2fa-ebe24b5d8b91
BUG=13535
@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky test ScheduledRetryingExecutorTest.testSuccessWithFailuresGetAttempt

1 participant