@@ -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