Skip to content

Commit 6eec32c

Browse files
refactor: add State.onExit and localize per-attempt tracer pairing to Active
State machine now has an onExit hook called from onStateChange before the swap. States that hold cleanup-worthy resources override it. Active owns its own tracer-pairing flag + finishAttempt helper (no longer a RetryingVRpc-level bool). Listener paths and onCancel call finishAttempt with the right result; onExit is a safety net that guarantees the pairing is balanced even if a future exit path forgets. Fixes the attempt.start synchronous-throw leak. Also resolves two latent tracer hazards: - tracer.onAttemptFinish now fires when an in-flight attempt is cancelled (previously the late server onClose was dropped by the stale-state guard, so the cancelled attempt's tracer span leaked). - The listener-path tracer.onAttemptFinish is gated on the stale-state check first, matching onMessage above — a discarded onClose can no longer double-fire the tracer. Scheduled cleanup (timer + stop-hook unregister) consolidates from three parallel sites (onCancel, timer-fire body, stop-hook body) into one onExit. onCancel drops to the default no-op. Done.onStart no longer balances per-attempt tracer state — that lives on Active now.
1 parent de62fa1 commit 6eec32c

1 file changed

Lines changed: 48 additions & 28 deletions

File tree

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

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

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,18 @@ void onStateChange(State state) {
134134
if (currentState.isDone()) {
135135
return;
136136
}
137+
// Give the outgoing state a chance to release per-state resources (timers, registrations,
138+
// tracer pairings). Default is a no-op; states that hold cleanup-worthy resources override.
139+
currentState.onExit();
137140
this.currentState = state;
138141
currentState.onStart();
139142
}
140143

141144
abstract static class State {
142145
public abstract void onStart();
143146

147+
public void onExit() {}
148+
144149
public void onCancel(String reason, Throwable throwable) {}
145150

146151
public boolean isDone() {
@@ -164,6 +169,16 @@ public void onStart() {
164169
class Active extends State {
165170

166171
private VRpc<ReqT, RespT> attempt;
172+
// Tracer pairing flag scoped to this attempt. finishAttempt is idempotent via this flag so
173+
// listener path, cancel path, and onExit safety net never double-fire.
174+
private boolean attemptFinished = false;
175+
176+
private void finishAttempt(VRpcResult result) {
177+
if (!attemptFinished) {
178+
attemptFinished = true;
179+
tracer.onAttemptFinish(result);
180+
}
181+
}
167182

168183
@Override
169184
public void onStart() {
@@ -195,12 +210,12 @@ public void onMessage(RespT msg) {
195210
}
196211
if (userThrow != null) {
197212
// Classify as USER_FAILURE (not CANCELLED, which is what the OpExecutor uncaught
198-
// handler would produce via chain.cancel). Finish tracing for the in-flight
199-
// attempt, cancel the underlying gRPC call so no further events arrive (its later
200-
// onClose is dropped by the currentState != Active.this guard), and transition
201-
// directly to Done with the user-error result.
213+
// handler would produce via chain.cancel). Finish the attempt's tracer span
214+
// with the user-error result, cancel the underlying gRPC call so no further
215+
// events arrive (its later onClose is dropped by the currentState !=
216+
// Active.this guard), and transition directly to Done.
202217
VRpcResult userResult = VRpcResult.createUserError(userThrow);
203-
tracer.onAttemptFinish(userResult);
218+
finishAttempt(userResult);
204219
attempt.cancel("User callback threw", userThrow);
205220
onStateChange(new Done(userResult));
206221
}
@@ -209,7 +224,6 @@ public void onMessage(RespT msg) {
209224
@Override
210225
public void onClose(VRpcResult result) {
211226
context.getExecutor().throwIfNotInThisExecutor();
212-
tracer.onAttemptFinish(result);
213227
if (currentState != Active.this) {
214228
LOG.log(
215229
Level.FINE,
@@ -218,6 +232,7 @@ public void onClose(VRpcResult result) {
218232
result);
219233
return;
220234
}
235+
finishAttempt(result);
221236
if (shouldRetry(result)) {
222237
context = context.createForNextAttempt();
223238
Duration retryDelay =
@@ -240,14 +255,29 @@ public void onClose(VRpcResult result) {
240255

241256
@Override
242257
public void onCancel(String reason, Throwable throwable) {
243-
// attempt could be null if attemptFactory.get() throws an exception. In which case sync
244-
// context uncaught exception handler will be called, which calls cancel on the current
245-
// state before transition into done state.
258+
// Pair the onAttemptStart fired in onStart with an onAttemptFinish at the moment we
259+
// abandon the attempt — the later server onClose for the cancelled attempt is dropped by
260+
// the stale-state guard, so this is the only chance to balance the tracer.
261+
finishAttempt(
262+
VRpcResult.createRejectedError(
263+
Status.CANCELLED.withDescription(reason).withCause(throwable)));
264+
// attempt could be null if attemptFactory.get() threw before assignment.
246265
if (attempt != null) {
247266
attempt.cancel(reason, throwable);
248267
}
249268
}
250269

270+
@Override
271+
public void onExit() {
272+
// Defense-in-depth: every existing exit path (listener.onClose, onMessage user-throw,
273+
// onCancel) calls finishAttempt with a meaningful result before transitioning. This catches
274+
// any new exit path that forgets, recording a generic 'abandoned' instead of leaking the
275+
// tracer span.
276+
finishAttempt(
277+
VRpcResult.createRejectedError(
278+
Status.CANCELLED.withDescription("attempt abandoned during transition")));
279+
}
280+
251281
boolean shouldRetry(VRpcResult result) {
252282
// If the error has RetryInfo, it means it comes from the server and should
253283
// be retried.
@@ -281,9 +311,7 @@ class Scheduled extends State {
281311
private final Duration retryDelay;
282312
private BigtableTimer.Timeout future;
283313
// 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.
314+
// Scheduled to a CANCELLED Done instead of silently discarding the pending timeout.
287315
private BigtableTimer.Registration stopHook;
288316

289317
Scheduled(Duration retryDelay) {
@@ -304,15 +332,14 @@ public void onStart() {
304332
.execute(
305333
() ->
306334
grpcContext
307-
.wrap(() -> otelContext.wrap(this::onTimerFired).run())
335+
.wrap(() -> otelContext.wrap(() -> onStateChange(new Idle())).run())
308336
.run()),
309337
Durations.toMillis(retryDelay),
310338
TimeUnit.MILLISECONDS);
311339
} catch (IllegalStateException e) {
312340
// Timer was stopped between Active.onClose deciding to retry and this task running on the
313341
// 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();
342+
// op-executor uncaught handler does not have to. onExit will release the stopHook.
316343
onStateChange(
317344
new Done(
318345
VRpcResult.createRejectedError(
@@ -323,11 +350,6 @@ public void onStart() {
323350
}
324351
}
325352

326-
private void onTimerFired() {
327-
unregisterStopHook();
328-
onStateChange(new Idle());
329-
}
330-
331353
// Invoked from BigtableTimer.stop on the close thread. Trampoline back to the op executor so
332354
// currentState reads and onStateChange are still single-threaded with the rest of the chain.
333355
private void onTimerStopping() {
@@ -343,18 +365,14 @@ private void onTimerStopping() {
343365
});
344366
}
345367

346-
private void unregisterStopHook() {
368+
@Override
369+
public void onExit() {
370+
// Consolidated cleanup: runs on every exit path (normal fire → Idle, cancel → Done,
371+
// shutdown hook → Done). Both fields may be null if schedule threw an ISE before assignment.
347372
if (stopHook != null) {
348373
stopHook.unregister();
349374
stopHook = null;
350375
}
351-
}
352-
353-
@Override
354-
public void onCancel(String reason, Throwable throwable) {
355-
unregisterStopHook();
356-
// future can be null if schedule throws and we end up here via the op-executor uncaught
357-
// path.
358376
if (future != null && !future.isCancelled()) {
359377
future.cancel();
360378
}
@@ -375,6 +393,8 @@ public void onStart() {
375393
LOG.fine("operation is not started yet.");
376394
return;
377395
}
396+
// Per-attempt tracer pairing is owned by Active.onExit; Done just runs the user listener
397+
// and the per-operation tracer finish.
378398
Stopwatch appTimer = Stopwatch.createStarted();
379399
try {
380400
listener.onClose(result);

0 commit comments

Comments
 (0)