Skip to content

Commit 725086d

Browse files
authored
fix(bigtable): prevent ClientConfigurationManagerTest from wedging on… (#13907)
… failed initial fetch Also harden ClientConfigurationManager.onClose to always terminate the fetch future: if the stream closes cleanly without ever delivering a config, complete it exceptionally instead of leaving the caller blocked on start().get(). For the current unary RPC gRPC already surfaces a missing response as a non-OK status, so this is defensive, but it keeps the Listener->CompletableFuture bridge correct regardless of that invariant.
1 parent 523cb78 commit 725086d

2 files changed

Lines changed: 63 additions & 0 deletions

File tree

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/util/ClientConfigurationManager.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,17 @@ public void onMessage(ClientConfiguration cfg) {
337337
public void onClose(Status status, Metadata trailers) {
338338
if (!status.isOk()) {
339339
future.completeExceptionally(status.asRuntimeException());
340+
} else if (!future.isDone()) {
341+
// Defensive: guarantee this Listener always terminates the future. A caller blocks on
342+
// this future via start().get() with no timeout, so a close that neither delivered a
343+
// message nor reported an error would wedge it forever. For today's unary RPC gRPC
344+
// already converts a missing response into a non-OK status, so this branch is not
345+
// expected to be hit, but it keeps the bridge correct regardless of that invariant.
346+
future.completeExceptionally(
347+
Status.INTERNAL
348+
.withDescription(
349+
"GetClientConfiguration stream closed without returning a configuration")
350+
.asRuntimeException());
340351
}
341352
}
342353
},

java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/util/ClientConfigurationManagerTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static org.mockito.ArgumentMatchers.any;
2121
import static org.mockito.ArgumentMatchers.anyLong;
2222
import static org.mockito.ArgumentMatchers.eq;
23+
import static org.mockito.Mockito.lenient;
2324
import static org.mockito.Mockito.never;
2425
import static org.mockito.Mockito.times;
2526
import static org.mockito.Mockito.verify;
@@ -59,22 +60,32 @@
5960
import java.util.concurrent.CountDownLatch;
6061
import java.util.concurrent.ExecutionException;
6162
import java.util.concurrent.ScheduledExecutorService;
63+
import java.util.concurrent.ScheduledFuture;
6264
import java.util.concurrent.TimeUnit;
65+
import java.util.concurrent.atomic.AtomicInteger;
6366
import java.util.concurrent.atomic.AtomicReference;
6467
import org.junit.jupiter.api.AfterEach;
6568
import org.junit.jupiter.api.BeforeEach;
6669
import org.junit.jupiter.api.Disabled;
6770
import org.junit.jupiter.api.Test;
71+
import org.junit.jupiter.api.Timeout;
6872
import org.junit.jupiter.api.extension.ExtendWith;
6973
import org.mockito.ArgumentCaptor;
7074
import org.mockito.Mock;
7175
import org.mockito.Mockito;
7276
import org.mockito.junit.jupiter.MockitoExtension;
7377

7478
@ExtendWith(MockitoExtension.class)
79+
// Backstop against a wedged blocking call (e.g. manager.start().get()) hanging the CI runner
80+
// indefinitely. If any test exceeds this, fail fast with a diagnosable timeout instead.
81+
@Timeout(value = 60, unit = TimeUnit.SECONDS)
7582
class ClientConfigurationManagerTest {
7683
private static final FeatureFlags FEATURE_FLAGS = FeatureFlags.getDefaultInstance();
7784

85+
// Retries of a failed fetch are scheduled with sub-second delays, while the next poll is
86+
// scheduled minutes out. Anything below this threshold is treated as a retry and run inline.
87+
private static final long POLL_VS_RETRY_THRESHOLD_MS = 1_000;
88+
7889
private static final ClientInfo CLIENT_INFO =
7990
ClientInfo.builder()
8091
.setClientName("fake-client")
@@ -105,6 +116,25 @@ public ManagedChannelBuilder<?> newChannelBuilder() {
105116
}
106117
};
107118

119+
// The manager schedules two kinds of tasks on this executor: short-delay retries of a failed
120+
// fetch, and the long-delay next poll. Run retries inline so a transient failure of the initial
121+
// fetch (e.g. a flaky loopback connection) recovers instead of wedging a caller blocked on
122+
// start().get() forever — a plain mock executor would otherwise silently drop the retry.
123+
// Long-delay polls are left for tests to trigger manually via the captured Runnable.
124+
lenient()
125+
.when(mockExecutor.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)))
126+
.thenAnswer(
127+
invocation -> {
128+
long delayMs =
129+
invocation
130+
.getArgument(2, TimeUnit.class)
131+
.toMillis(invocation.getArgument(1, Long.class));
132+
if (delayMs < POLL_VS_RETRY_THRESHOLD_MS) {
133+
invocation.getArgument(0, Runnable.class).run();
134+
}
135+
return Mockito.mock(ScheduledFuture.class);
136+
});
137+
108138
manager =
109139
new ClientConfigurationManager(
110140
FEATURE_FLAGS, CLIENT_INFO, channelProvider, noopDebugTracer, mockExecutor);
@@ -133,6 +163,19 @@ void initialFetchTest() throws ExecutionException, InterruptedException {
133163
service.config.get().getPollingConfiguration().getPollingInterval()));
134164
}
135165

166+
@Test
167+
void initialFetchRetriesAndRecovers() throws Exception {
168+
// The first request is answered without a config message; for a unary RPC gRPC surfaces this to
169+
// the client as a non-OK status, which drives the retry path. start().get() must recover via a
170+
// retry instead of wedging forever. Under a plain mock executor the scheduled retry would be
171+
// silently dropped and this would hang (caught by the class @Timeout).
172+
service.emptyResponses.set(1);
173+
174+
ClientConfiguration initialConfig = manager.start().get();
175+
176+
assertThat(initialConfig).isEqualTo(service.config.get());
177+
}
178+
136179
@Test
137180
void notifyListenerTest() throws Exception {
138181
// Fetch initial config
@@ -425,6 +468,10 @@ public void onChange(SessionClientConfiguration newValue) {
425468
static class FakeConfigService extends BigtableGrpc.BigtableImplBase {
426469
private final AtomicReference<ClientConfiguration> config = new AtomicReference<>();
427470

471+
// Number of leading requests to answer with a clean (OK) close that delivers no message,
472+
// simulating the server closing the stream without ever returning a configuration.
473+
private final AtomicInteger emptyResponses = new AtomicInteger(0);
474+
428475
public FakeConfigService() throws IOException {
429476
ClientConfiguration.Builder builder = ClientConfigurationManager.loadDefault().toBuilder();
430477
builder.getSessionConfigurationBuilder().setSessionLoad(0.25f);
@@ -435,6 +482,11 @@ public FakeConfigService() throws IOException {
435482
public void getClientConfiguration(
436483
GetClientConfigurationRequest request,
437484
StreamObserver<ClientConfiguration> responseObserver) {
485+
if (emptyResponses.getAndDecrement() > 0) {
486+
// Close cleanly without sending a message.
487+
responseObserver.onCompleted();
488+
return;
489+
}
438490
responseObserver.onNext(config.get());
439491
responseObserver.onCompleted();
440492
}

0 commit comments

Comments
 (0)