Skip to content

Commit 42394fa

Browse files
fix: deliver terminal onClose to Scheduled retries pending at Client.close
Scheduled RetryingVRpcs hold no session reference, so a long-delay retry (server-driven RetryInfo.retryDelay) outlives Phase 2 drain and is silently discarded when sessionTimer.stop() runs in Phase 3. The user's listener never fires. Add an onStop hook primitive to BigtableTimer. Scheduled.onStart registers a hook on entry and unregisters on every exit path (normal fire, cancel, hook fire). NettyWheelTimer.stop() runs every hook synchronously before discarding pending wheel timeouts; hooks trampoline back through the op executor to drive Scheduled to a CANCELLED Done. Reorder Client.close Phase 3 so sessionTimer.stop() runs before userCallbackExecutor.close(), giving the hook-fired onClose tasks a live op-executor backing to land on. Also replace Scheduled.onStart's dead RejectedExecutionException catch with IllegalStateException, matching BigtableTimer.stop()'s documented post-condition.
1 parent 0dbd9c8 commit 42394fa

6 files changed

Lines changed: 126 additions & 17 deletions

File tree

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/api/Client.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,17 +273,22 @@ public void close() {
273273
}
274274
}
275275

276-
// Phase 3: tear down infrastructure. By this point all listener.onClose tasks for in-flight
277-
// RPCs are queued on their op executors (which run on userCallbackExecutor), and no new
278-
// session responses are coming since every session is CLOSED. The 5s await inside
279-
// userCallbackExecutor.close() is therefore just a guard for tasks in flight — it should
280-
// return immediately in the typical case.
276+
// Phase 3: tear down infrastructure.
277+
//
278+
// sessionTimer.stop() runs FIRST so its onStop hooks can drive any pending Scheduled retries
279+
// to a terminal Done — that delivery hops through op executor → userCallbackExecutor, both
280+
// of which must still be alive at this moment.
281+
//
282+
// userCallbackExecutor.close() next, with a 5s drain to catch the listener.onClose tasks
283+
// queued by both the session drain (Phase 2) and the just-fired retry shutdowns.
284+
//
285+
// backgroundExecutor must close last because it's the timer's dispatcher and the op
286+
// executor's chain ultimately runs ScheduledExecutorService tasks here.
287+
sessionTimer.stop();
281288
userCallbackExecutor.close();
282289
metrics.close();
283290
channelPool.close();
284291
configManager.close();
285-
// Stop the timer before tearing down backgroundExecutor (the timer's dispatcher).
286-
sessionTimer.stop();
287292
backgroundExecutor.close();
288293
}
289294

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/middleware/RetryingVRpc.java

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import io.grpc.Context;
2626
import io.grpc.Status;
2727
import java.util.Optional;
28-
import java.util.concurrent.RejectedExecutionException;
2928
import java.util.concurrent.TimeUnit;
3029
import java.util.function.Supplier;
3130
import java.util.logging.Level;
@@ -281,6 +280,11 @@ boolean shouldRetry(VRpcResult result) {
281280
class Scheduled extends State {
282281
private final Duration retryDelay;
283282
private BigtableTimer.Timeout future;
283+
// Registered with the timer on entry so a Client.close that stops the timer drives this
284+
// Scheduled to a CANCELLED Done instead of silently discarding the pending timeout. Cleared
285+
// on every exit path (normal fire, cancel, hook fire) to avoid accumulating dead entries on
286+
// a long-lived Client.
287+
private BigtableTimer.Registration stopHook;
284288

285289
Scheduled(Duration retryDelay) {
286290
this.retryDelay = retryDelay;
@@ -289,6 +293,7 @@ class Scheduled extends State {
289293
@Override
290294
public void onStart() {
291295
try {
296+
stopHook = timer.onStop(this::onTimerStopping);
292297
// Wraps go innermost so the captured gRPC + OpenTelemetry contexts are re-established at
293298
// the moment the body runs, not just while the dispatcher is invoking the outer task.
294299
future =
@@ -299,13 +304,15 @@ public void onStart() {
299304
.execute(
300305
() ->
301306
grpcContext
302-
.wrap(
303-
() ->
304-
otelContext.wrap(() -> onStateChange(new Idle())).run())
307+
.wrap(() -> otelContext.wrap(this::onTimerFired).run())
305308
.run()),
306309
Durations.toMillis(retryDelay),
307310
TimeUnit.MILLISECONDS);
308-
} catch (RejectedExecutionException e) {
311+
} catch (IllegalStateException e) {
312+
// Timer was stopped between Active.onClose deciding to retry and this task running on the
313+
// op executor. Race window is narrow (post-drain shutdown), but cover it cleanly so the
314+
// op-executor uncaught handler does not have to.
315+
unregisterStopHook();
309316
onStateChange(
310317
new Done(
311318
VRpcResult.createRejectedError(
@@ -316,11 +323,38 @@ public void onStart() {
316323
}
317324
}
318325

326+
private void onTimerFired() {
327+
unregisterStopHook();
328+
onStateChange(new Idle());
329+
}
330+
331+
// Invoked from BigtableTimer.stop on the close thread. Trampoline back to the op executor so
332+
// currentState reads and onStateChange are still single-threaded with the rest of the chain.
333+
private void onTimerStopping() {
334+
context.getExecutor().execute(() -> {
335+
if (currentState != Scheduled.this) {
336+
return; // already transitioned out via normal fire or cancel
337+
}
338+
onStateChange(
339+
new Done(
340+
VRpcResult.createRejectedError(
341+
Status.CANCELLED.withDescription(
342+
"Client closing while retry pending"))));
343+
});
344+
}
345+
346+
private void unregisterStopHook() {
347+
if (stopHook != null) {
348+
stopHook.unregister();
349+
stopHook = null;
350+
}
351+
}
352+
319353
@Override
320354
public void onCancel(String reason, Throwable throwable) {
321-
// future can be null if schedule throws an exception that's not RejectedExecutionException.
322-
// In which case sync context uncaught exception handler will be called, which calls cancel on
323-
// the current state before transition into done state.
355+
unregisterStopHook();
356+
// future can be null if schedule throws and we end up here via the op-executor uncaught
357+
// path.
324358
if (future != null && !future.isCancelled()) {
325359
future.cancel();
326360
}

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/BigtableTimer.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,34 @@ public interface BigtableTimer {
4747

4848
/**
4949
* Releases the tick thread and discards any pending timeouts. Idempotent. After {@code stop()},
50-
* subsequent calls to {@link #newTimeout} throw {@link IllegalStateException}.
50+
* subsequent calls to {@link #newTimeout} or {@link #onStop} throw {@link
51+
* IllegalStateException}.
52+
*
53+
* <p>Before releasing the tick thread, invokes every hook registered via {@link #onStop} on the
54+
* caller thread. Hooks fire in unspecified order; a hook that throws is logged and other hooks
55+
* still fire.
5156
*/
5257
void stop();
5358

59+
/**
60+
* Registers a hook to run during {@link #stop()}. Use this to drive caller-owned state (e.g. a
61+
* scheduled retry waiting on the timer) to a terminal state before the timer is torn down,
62+
* instead of letting a pending timeout silently disappear.
63+
*
64+
* <p>The returned {@link Registration} unregisters the hook; call it when the hook is no longer
65+
* needed (e.g. the scheduled work fired normally or was cancelled) so the hook set does not
66+
* accumulate stale entries.
67+
*/
68+
Registration onStop(Runnable hook);
69+
5470
interface Timeout {
5571
/** Cancels the scheduled task. Returns true if the task had not yet fired. */
5672
boolean cancel();
5773

5874
boolean isCancelled();
5975
}
76+
77+
interface Registration {
78+
void unregister();
79+
}
6080
}

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/NettyWheelTimer.java

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@
1717

1818
import com.google.common.util.concurrent.ThreadFactoryBuilder;
1919
import io.grpc.netty.shaded.io.netty.util.HashedWheelTimer;
20+
import java.util.HashSet;
21+
import java.util.Set;
22+
import java.util.concurrent.ConcurrentHashMap;
2023
import java.util.concurrent.Executor;
2124
import java.util.concurrent.TimeUnit;
25+
import java.util.logging.Level;
26+
import java.util.logging.Logger;
2227

2328
/**
2429
* {@link BigtableTimer} backed by Netty's {@code HashedWheelTimer}, accessed via the shaded copy
@@ -29,6 +34,8 @@
2934
* with an in-tree implementation that does not reach into gRPC's shaded internals.
3035
*/
3136
public final class NettyWheelTimer implements BigtableTimer {
37+
private static final Logger LOG = Logger.getLogger(NettyWheelTimer.class.getName());
38+
3239
// 10 ms tick × 512 buckets ≈ 5 s per rotation. Heartbeat (100 ms), deadlines (sub-second to
3340
// seconds), and watchdog (5 min) all sit comfortably inside this resolution.
3441
private static final long TICK_DURATION_MS = 10;
@@ -37,6 +44,11 @@ public final class NettyWheelTimer implements BigtableTimer {
3744
private final HashedWheelTimer delegate;
3845
private final Executor dispatcher;
3946

47+
// ConcurrentHashMap-backed Set so onStop/Registration.unregister can run from any thread without
48+
// blocking newTimeout. Stop drains it once, then refuses further registrations.
49+
private final Set<Runnable> stopHooks = ConcurrentHashMap.newKeySet();
50+
private volatile boolean stopped = false;
51+
4052
public NettyWheelTimer(String name, Executor dispatcher) {
4153
this.dispatcher = dispatcher;
4254
this.delegate =
@@ -49,11 +61,39 @@ public NettyWheelTimer(String name, Executor dispatcher) {
4961

5062
@Override
5163
public Timeout newTimeout(Runnable task, long delay, TimeUnit unit) {
52-
return new TimeoutHandle(delegate.newTimeout(ignored -> dispatcher.execute(task), delay, unit));
64+
if (stopped) {
65+
throw new IllegalStateException("timer stopped");
66+
}
67+
return new TimeoutHandle(
68+
delegate.newTimeout(ignored -> dispatcher.execute(task), delay, unit));
69+
}
70+
71+
@Override
72+
public Registration onStop(Runnable hook) {
73+
if (stopped) {
74+
throw new IllegalStateException("timer stopped");
75+
}
76+
stopHooks.add(hook);
77+
return () -> stopHooks.remove(hook);
5378
}
5479

5580
@Override
5681
public void stop() {
82+
if (stopped) {
83+
return;
84+
}
85+
stopped = true;
86+
// Snapshot then clear so hooks can no longer be unregistered while we iterate (and so a hook
87+
// that re-enters onStop sees stopped=true and fails fast).
88+
Set<Runnable> hooks = new HashSet<>(stopHooks);
89+
stopHooks.clear();
90+
for (Runnable hook : hooks) {
91+
try {
92+
hook.run();
93+
} catch (Throwable t) {
94+
LOG.log(Level.WARNING, "stop hook threw; continuing", t);
95+
}
96+
}
5797
delegate.stop();
5898
}
5999

java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImplTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,11 @@ public boolean isCancelled() {
843843
};
844844
}
845845

846+
@Override
847+
public Registration onStop(Runnable hook) {
848+
return delegate.onStop(hook);
849+
}
850+
846851
@Override
847852
public void stop() {
848853
delegate.stop();

java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/session/WatchdogTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ public boolean isCancelled() {
8484
};
8585
}
8686

87+
@Override
88+
public Registration onStop(Runnable hook) {
89+
return () -> {};
90+
}
91+
8792
@Override
8893
public void stop() {}
8994
}

0 commit comments

Comments
 (0)