Skip to content

Commit 62ba853

Browse files
chore: consolidate cancel trampolines at VOperationImpl
VOperationImpl captures opExecutor in start() and trampolines start/cancel via it. RetryingVRpc.start runs synchronously on the op-executor task RetryingVRpc.cancel no longer wraps in execute. Tracer.onOperationStart reordered before started=true (a throwing tracer short-circuits to direct listener.onClose). listener.onMessage failures classify as USER_FAILURE. CleanupListener tracks a closed flag to prevent gRPC-context listener leaks on synchronous chain close.
1 parent 739f0e3 commit 62ba853

2 files changed

Lines changed: 112 additions & 63 deletions

File tree

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

Lines changed: 83 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ public class RetryingVRpc<ReqT, RespT> implements VRpc<ReqT, RespT> {
4747

4848
private final BigtableTimer timer;
4949

50-
// current state and all the flags don't need to be volatile because they're only updated within
51-
// the op executor.
50+
// All mutable state is owned by the op executor; VOperationImpl trampolines every inbound call
51+
// onto it, so no synchronization is needed here.
5252
private State currentState;
5353
private boolean started;
54-
// Breaks the loop if uncaught exception happens during op-executor execution.
54+
// Breaks the loop on uncaught exception during cancel.
5555
private boolean isCancelling;
5656

5757
public RetryingVRpc(Supplier<VRpc<ReqT, RespT>> supplier, BigtableTimer timer) {
@@ -69,64 +69,80 @@ public RetryingVRpc(Supplier<VRpc<ReqT, RespT>> supplier, BigtableTimer timer) {
6969

7070
@Override
7171
public void start(ReqT req, VRpcCallContext ctx, VRpcListener<RespT> listener) {
72-
ctx.getExecutor()
73-
.execute(
74-
() -> {
75-
if (started) {
76-
listener.onClose(
77-
VRpcResult.createRejectedError(
78-
Status.FAILED_PRECONDITION.withDescription(
79-
"operation is already started")));
80-
return;
81-
}
82-
started = true;
72+
if (started) {
73+
listener.onClose(
74+
VRpcResult.createRejectedError(
75+
Status.FAILED_PRECONDITION.withDescription("operation is already started")));
76+
return;
77+
}
8378

84-
this.request = req;
85-
this.listener = listener;
86-
this.context = ctx;
87-
this.tracer = context.getTracer();
79+
// Publish the fields BEFORE the try block. If anything below throws and we recover via
80+
// cancel(), cancel() reads this.context / this.listener — they must be set already, or
81+
// we trade the original failure for an NPE inside the recovery path.
82+
this.request = req;
83+
this.listener = listener;
84+
this.context = ctx;
85+
this.tracer = context.getTracer();
86+
87+
// tracer.onOperationStart runs BEFORE started=true so a tracer failure short-circuits to a
88+
// direct listener.onClose without entering the state machine. If started=true were set first,
89+
// a tracer throw would route through cancel→Done.onStart, which would then NPE in its own
90+
// finally on tracer.onOperationFinish/recordApplicationBlockingLatencies, swallowing the
91+
// original cause and surprising the caller with the secondary NPE.
92+
try {
93+
tracer.onOperationStart();
94+
} catch (Throwable t) {
95+
listener.onClose(
96+
VRpcResult.createRejectedError(
97+
Status.INTERNAL.withDescription("tracer.onOperationStart failed").withCause(t)));
98+
return;
99+
}
100+
started = true;
88101

89-
tracer.onOperationStart();
90-
currentState.onStart();
91-
});
102+
try {
103+
currentState.onStart();
104+
} catch (Throwable t) {
105+
cancel("Unexpected error in start", t);
106+
}
92107
}
93108

94109
@Override
95110
public void cancel(@Nullable String message, @Nullable Throwable cause) {
96-
context
97-
.getExecutor()
98-
.execute(
99-
() -> {
100-
if (currentState.isDone() || isCancelling) {
101-
LOG.fine("Ignoring cancel because the vRPC is already cancelled or done.");
102-
return;
103-
}
104-
// Prevents infinite loop if there's any error thrown during this phase.
105-
isCancelling = true;
106-
Throwable finalCause = cause;
107-
try {
108-
currentState.onCancel(message, cause);
109-
} catch (Throwable t) {
110-
if (finalCause != null) {
111-
finalCause.addSuppressed(t);
112-
} else {
113-
finalCause = t;
114-
}
115-
}
116-
onStateChange(
117-
new Done(
118-
VRpcResult.createRejectedError(
119-
Status.CANCELLED.withDescription(message).withCause(finalCause))));
120-
});
111+
if (currentState.isDone() || isCancelling) {
112+
LOG.fine("Ignoring cancel because the vRPC is already cancelled or done.");
113+
return;
114+
}
115+
// Prevents infinite loop if there's any error thrown during this phase.
116+
isCancelling = true;
117+
Throwable finalCause = cause;
118+
try {
119+
currentState.onCancel(message, cause);
120+
} catch (Throwable t) {
121+
if (finalCause != null) {
122+
finalCause.addSuppressed(t);
123+
} else {
124+
finalCause = t;
125+
}
126+
}
127+
onStateChange(
128+
new Done(
129+
VRpcResult.createRejectedError(
130+
Status.CANCELLED.withDescription(message).withCause(finalCause))));
121131
}
122132

123133
@Override
124134
public void requestNext() {
135+
// Assert the op-executor affinity even though the body is dead today — when streaming lands
136+
// and this becomes real, the missing assertion would silently allow off-thread access.
137+
// Guarded on context being set so a misuse before start() still throws UnsupportedOperationException
138+
// rather than NPE on the assertion.
139+
if (context != null) {
140+
context.getExecutor().throwIfNotInThisExecutor();
141+
}
125142
throw new UnsupportedOperationException("request next is not supported in unary");
126143
}
127144

128145
void onStateChange(State state) {
129-
context.getExecutor().throwIfNotInThisExecutor();
130146
if (currentState.isDone()) {
131147
return;
132148
}
@@ -169,10 +185,9 @@ public void onStart() {
169185
request,
170186
context,
171187
new VRpcListener<RespT>() {
172-
// VRpcImpl dispatches its callbacks via ctx.getExecutor() already, so these methods
173-
// run inside the op-executor task — no need to re-dispatch here.
174188
@Override
175189
public void onMessage(RespT msg) {
190+
context.getExecutor().throwIfNotInThisExecutor();
176191
if (currentState != Active.this) {
177192
LOG.log(
178193
Level.FINE,
@@ -182,15 +197,30 @@ public void onMessage(RespT msg) {
182197
}
183198
tracer.onResponseReceived();
184199
Stopwatch appTimer = Stopwatch.createStarted();
200+
Throwable userThrow = null;
185201
try {
186202
listener.onMessage(msg);
203+
} catch (Throwable t) {
204+
userThrow = t;
187205
} finally {
188206
tracer.recordApplicationBlockingLatencies(appTimer.elapsed());
189207
}
208+
if (userThrow != null) {
209+
// Classify as USER_FAILURE (not CANCELLED, which is what the OpExecutor uncaught
210+
// handler would produce via chain.cancel). Finish tracing for the in-flight
211+
// attempt, cancel the underlying gRPC call so no further events arrive (its later
212+
// onClose is dropped by the currentState != Active.this guard), and transition
213+
// directly to Done with the user-error result.
214+
VRpcResult userResult = VRpcResult.createUserError(userThrow);
215+
tracer.onAttemptFinish(userResult);
216+
attempt.cancel("User callback threw", userThrow);
217+
onStateChange(new Done(userResult));
218+
}
190219
}
191220

192221
@Override
193222
public void onClose(VRpcResult result) {
223+
context.getExecutor().throwIfNotInThisExecutor();
194224
tracer.onAttemptFinish(result);
195225
if (currentState != Active.this) {
196226
LOG.log(
@@ -214,6 +244,7 @@ public void onClose(VRpcResult result) {
214244
}
215245
return;
216246
}
247+
217248
onStateChange(new Done(result));
218249
}
219250
});
@@ -271,8 +302,6 @@ public void onStart() {
271302
try {
272303
// Wraps go innermost so the captured gRPC + OpenTelemetry contexts are re-established at
273304
// the moment the body runs, not just while the dispatcher is invoking the outer task.
274-
// The executor may queue the inner runnable for a later drain on a different thread; an
275-
// outer wrap's scope would already be closed by then.
276305
future =
277306
timer.newTimeout(
278307
() ->
@@ -299,9 +328,9 @@ public void onStart() {
299328

300329
@Override
301330
public void onCancel(String reason, Throwable throwable) {
302-
// future can be null if newTimeout throws an exception. In which case sync context uncaught
303-
// exception handler will be called, which calls cancel on the current state before
304-
// transition into done state.
331+
// future can be null if schedule throws an exception that's not RejectedExecutionException.
332+
// In which case sync context uncaught exception handler will be called, which calls cancel on
333+
// the current state before transition into done state.
305334
if (future != null && !future.isCancelled()) {
306335
future.cancel();
307336
}

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
import javax.annotation.Nullable;
3131

3232
/**
33-
* The single edge between the user and the VRpc middleware chain. Constructs the per-op {@link
34-
* VRpcCallContext} and owns the gRPC {@link Context} cancellation listener.
33+
* The single edge between the user and the VRpc middleware chain. Trampolines all inbound user
34+
* calls onto opExecutor and owns the gRPC {@link Context} cancellation listener so that every
35+
* layer below is single-threaded on opExecutor.
3536
*
3637
* <p>Precondition: {@link #cancel} must not be called before {@link #start}.
3738
*/
@@ -45,6 +46,10 @@ public class VOperationImpl<ReqT, RespT> implements VOperation<ReqT, RespT> {
4546
private final boolean idempotent;
4647
private final Context.CancellationListener cancellationListener;
4748

49+
// Written in start() on the caller thread before the listener is registered and before cancel()
50+
// is reachable from any external thread. Volatile for safe publication to those threads.
51+
private volatile OpExecutor opExecutor;
52+
4853
public VOperationImpl(
4954
VRpc<ReqT, RespT> chain,
5055
Context grpcContext,
@@ -63,7 +68,7 @@ public VOperationImpl(
6368
boolean deadlineExceeded =
6469
Optional.ofNullable(c.getDeadline()).map(Deadline::isExpired).orElse(false);
6570
deadlineExceeded = deadlineExceeded && c.cancellationCause() instanceof TimeoutException;
66-
// Let VRpc machinery handle deadline exceeded.
71+
// Let VRpc machinery handle deadline exceeded
6772
if (!deadlineExceeded) {
6873
cancel("gRPC context cancelled", c.cancellationCause());
6974
}
@@ -72,27 +77,41 @@ public VOperationImpl(
7277

7378
@Override
7479
public void start(ReqT req, VRpcListener<RespT> listener) {
75-
// Per-call SerializingExecutor over the shared user-callback pool. The handler is the
76-
// last-resort recovery: if any op-executor task throws (typically a user-installed tracer or
77-
// a listener callback escape), drive the chain to a terminal state so the caller's listener
78-
// still receives an onClose. RetryingVRpc.cancel is idempotent so cascades collapse safely.
80+
// Last-resort recovery: if any op-executor task throws (typically a user-installed tracer,
81+
// or a listener callback that escapes RetryingVRpc's existing per-state try/catches), drive
82+
// the chain to a terminal state so the caller's listener still receives an onClose. The
83+
// handler runs on the failed task's wrapper, so chain.cancel() — which calls
84+
// OpExecutor#throwIfNotInThisExecutor — passes. RetryingVRpc.cancel is idempotent
85+
// (isCancelling / currentState.isDone() guards), so a cascade of failures collapses to a
86+
// single Done.
7987
OpExecutor exec =
8088
new OpExecutor(
8189
MoreExecutors.newSequentialExecutor(userCallbackExecutor),
8290
t -> chain.cancel("Uncaught exception in op executor task", t));
91+
this.opExecutor = exec;
8392
VRpcCallContext ctx = VRpcCallContext.create(deadline, idempotent, tracer, exec);
93+
CleanupListener<RespT> wrapped =
94+
new CleanupListener<>(listener, grpcContext, cancellationListener);
95+
// Register the gRPC context listener BEFORE submitting chain.start. The submit queues the
96+
// task on the op executor; chain.cancel from this listener also queues. SequentialExecutor
97+
// preserves submission order, so a context-cancel fired during/before chain.start will be
98+
// processed after it.
8499
grpcContext.addListener(cancellationListener, MoreExecutors.directExecutor());
85-
chain.start(req, ctx, new CleanupListener<>(listener, grpcContext, cancellationListener));
100+
exec.execute(() -> chain.start(req, ctx, wrapped));
86101
}
87102

88103
@Override
89104
public void cancel(@Nullable String message, @Nullable Throwable cause) {
90-
chain.cancel(message, cause);
105+
opExecutor.execute(() -> chain.cancel(message, cause));
91106
}
92107

93108
private static class CleanupListener<RespT> extends ForwardListener<RespT> {
94109
private final Context grpcContext;
95110
private final Context.CancellationListener cancellationListener;
111+
// Read by VOperationImpl.start on the caller thread after runInline returns. runInline runs
112+
// chain.start synchronously, so any sync onClose has completed (and this flag been set) by
113+
// the time start() reads it on the same thread — no synchronization needed.
114+
volatile boolean closed = false;
96115

97116
CleanupListener(
98117
VRpcListener<RespT> delegate,
@@ -105,6 +124,7 @@ private static class CleanupListener<RespT> extends ForwardListener<RespT> {
105124

106125
@Override
107126
public void onClose(VRpcResult result) {
127+
closed = true;
108128
grpcContext.removeListener(cancellationListener);
109129
super.onClose(result);
110130
}

0 commit comments

Comments
 (0)