Skip to content

Commit d760a8c

Browse files
committed
fix(grpc-gcp): align the dynamic channel pool with Go DCP to remove one-hot-channel skew
1 parent 385e1f2 commit d760a8c

12 files changed

Lines changed: 3484 additions & 757 deletions

‎grpc-gcp-java/pom.xml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
6464
<site.installationModule>grpc-gcp</site.installationModule>
6565
<api-common.version>2.68.0-SNAPSHOT</api-common.version><!-- {x-version-update:api-common:current} -->
66+
<awaitility.version>4.3.0</awaitility.version>
6667
<auto-value.version>1.11.0</auto-value.version>
6768
<error-prone-annotations.version>2.48.0</error-prone-annotations.version>
6869
<google-http-client.version>2.2.0</google-http-client.version>
@@ -172,6 +173,12 @@
172173
<version>${junit.version}</version>
173174
<scope>test</scope>
174175
</dependency>
176+
<dependency>
177+
<groupId>org.awaitility</groupId>
178+
<artifactId>awaitility</artifactId>
179+
<version>${awaitility.version}</version>
180+
<scope>test</scope>
181+
</dependency>
175182
<dependency>
176183
<groupId>com.google.truth</groupId>
177184
<artifactId>truth</artifactId>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.grpc;
18+
19+
import com.google.common.util.concurrent.ListenableFuture;
20+
import io.grpc.ManagedChannel;
21+
22+
/** Primes a newly built delegate channel before a dynamic pool publishes it for request picking. */
23+
@FunctionalInterface
24+
public interface GcpChannelPrimer {
25+
26+
/**
27+
* Issues a cheap end-to-end RPC on {@code channel} so its connection is warm before real traffic.
28+
* For example, a Cloud Spanner implementation can execute {@code SELECT 1}. Return a failed
29+
* future to reject and close the channel.
30+
*/
31+
ListenableFuture<Void> prime(ManagedChannel channel);
32+
}

‎grpc-gcp-java/src/main/java/com/google/cloud/grpc/GcpClientCall.java‎

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import java.util.Collections;
3131
import java.util.List;
3232
import java.util.Queue;
33-
import java.util.concurrent.atomic.AtomicBoolean;
33+
import java.util.concurrent.atomic.AtomicInteger;
3434
import javax.annotation.Nullable;
3535
import javax.annotation.concurrent.GuardedBy;
3636

@@ -52,14 +52,21 @@ public class GcpClientCall<ReqT, RespT> extends ClientCall<ReqT, RespT> {
5252
private ClientCall<ReqT, RespT> delegateCall = null;
5353
private List<String> keys = null;
5454
private boolean received = false;
55-
private final AtomicBoolean decremented = new AtomicBoolean(false);
55+
// 0 = not counted, 1 = counted, 2 = finished.
56+
private final AtomicInteger countState = new AtomicInteger();
5657

5758
@GuardedBy("this")
5859
private final Queue<Runnable> calls = new ArrayDeque<>();
5960

6061
@GuardedBy("this")
6162
private boolean started;
6263

64+
@GuardedBy("this")
65+
private boolean cancelQueued;
66+
67+
@GuardedBy("this")
68+
private boolean cancelled;
69+
6370
private long startNanos = 0;
6471

6572
protected GcpClientCall(
@@ -90,7 +97,22 @@ public void setMessageCompression(boolean enabled) {
9097

9198
@Override
9299
public void cancel(@Nullable String message, @Nullable Throwable cause) {
93-
checkSendMessage(() -> checkedCancel(message, cause));
100+
synchronized (this) {
101+
if (cancelQueued || cancelled) {
102+
return;
103+
}
104+
cancelQueued = true;
105+
Runnable cancelCall =
106+
() -> {
107+
cancelled = true;
108+
checkedCancel(message, cause);
109+
};
110+
if (started) {
111+
cancelCall.run();
112+
} else {
113+
calls.add(cancelCall);
114+
}
115+
}
94116
}
95117

96118
@Override
@@ -104,6 +126,7 @@ public void halfClose() {
104126
*/
105127
@Override
106128
public void sendMessage(ReqT message) {
129+
boolean send;
107130
synchronized (this) {
108131
if (!started) {
109132
startNanos = System.nanoTime();
@@ -123,17 +146,29 @@ public void sendMessage(ReqT message) {
123146
delegateChannelRef = delegateChannel.getChannelRef(key);
124147
}
125148
delegateChannelRef.activeStreamsCountIncr();
126-
127-
// Create the client call and do the previous operations.
128-
delegateCall = delegateChannelRef.getChannel().newCall(methodDescriptor, callOptions);
129-
for (Runnable call : calls) {
130-
call.run();
149+
countState.set(1);
150+
151+
try {
152+
// Create the client call and do the previous operations.
153+
CallOptions callOptionsWithChannelId =
154+
callOptions.withOption(GcpManagedChannel.CHANNEL_ID_KEY, delegateChannelRef.getId());
155+
delegateCall =
156+
delegateChannelRef.getChannel().newCall(methodDescriptor, callOptionsWithChannelId);
157+
for (Runnable call : calls) {
158+
call.run();
159+
}
160+
} catch (RuntimeException | Error failure) {
161+
finishCount(Status.fromThrowable(failure), true);
162+
throw failure;
131163
}
132164
calls.clear();
133165
started = true;
134166
}
167+
send = !cancelled;
168+
}
169+
if (send) {
170+
delegateCall.sendMessage(message);
135171
}
136-
delegateCall.sendMessage(message);
137172
}
138173

139174
/** Calls that send exactly one message should not check this method. */
@@ -162,14 +197,21 @@ public String toString() {
162197
}
163198

164199
private void checkedCancel(@Nullable String message, @Nullable Throwable cause) {
165-
if (!decremented.getAndSet(true)) {
166-
delegateChannelRef.activeStreamsCountDecr(startNanos, Status.CANCELLED, true);
167-
}
200+
finishCount(Status.CANCELLED, true);
168201
delegateCall.cancel(message, cause);
169202
}
170203

204+
private void finishCount(Status status, boolean fromClientSide) {
205+
if (countState.compareAndSet(1, 2)) {
206+
delegateChannelRef.activeStreamsCountDecr(startNanos, status, fromClientSide);
207+
}
208+
}
209+
171210
private void checkSendMessage(Runnable call) {
172211
synchronized (this) {
212+
if (cancelQueued || cancelled) {
213+
return;
214+
}
173215
if (started) {
174216
call.run();
175217
} else {
@@ -185,9 +227,7 @@ private Listener<RespT> getListener(final Listener<RespT> responseListener) {
185227
// Decrement the stream number by one when the call is closed.
186228
@Override
187229
public void onClose(Status status, Metadata trailers) {
188-
if (!decremented.getAndSet(true)) {
189-
delegateChannelRef.activeStreamsCountDecr(startNanos, status, false);
190-
}
230+
finishCount(status, false);
191231
// If the operation completed successfully, bind/unbind the affinity key.
192232
if (keys != null && status.getCode() == Status.Code.OK) {
193233
if (affinity.getCommand() == AffinityConfig.Command.UNBIND) {
@@ -219,7 +259,8 @@ public void onMessage(RespT message) {
219259
* A simple wrapper of ClientCall.
220260
*
221261
* <p>It defines the callback function to manage the number of active streams of a ChannelRef
222-
* everytime a call is started/closed.
262+
* every time a call is created/closed. Stream capacity is reserved in the constructor, before
263+
* {@link #start(Listener, Metadata)}, and remains reserved until close or cancel.
223264
*/
224265
public static class SimpleGcpClientCall<ReqT, RespT> extends ForwardingClientCall<ReqT, RespT> {
225266

@@ -230,7 +271,13 @@ public static class SimpleGcpClientCall<ReqT, RespT> extends ForwardingClientCal
230271
private final boolean unbindOnComplete;
231272
private long startNanos = 0;
232273

233-
private final AtomicBoolean decremented = new AtomicBoolean(false);
274+
private final Object countLock = new Object();
275+
276+
@GuardedBy("countLock")
277+
private boolean counted;
278+
279+
@GuardedBy("countLock")
280+
private boolean finished;
234281

235282
protected SimpleGcpClientCall(
236283
GcpManagedChannel delegateChannel,
@@ -244,8 +291,18 @@ protected SimpleGcpClientCall(
244291
// Set the actual channel ID in callOptions so downstream interceptors can access it.
245292
CallOptions callOptionsWithChannelId =
246293
callOptions.withOption(GcpManagedChannel.CHANNEL_ID_KEY, channelRef.getId());
247-
this.delegateCall =
248-
channelRef.getChannel().newCall(methodDescriptor, callOptionsWithChannelId);
294+
startNanos = System.nanoTime();
295+
synchronized (countLock) {
296+
channelRef.activeStreamsCountIncr();
297+
counted = true;
298+
}
299+
try {
300+
this.delegateCall =
301+
channelRef.getChannel().newCall(methodDescriptor, callOptionsWithChannelId);
302+
} catch (RuntimeException | Error failure) {
303+
finishCount(Status.fromThrowable(failure), true);
304+
throw failure;
305+
}
249306
}
250307

251308
@Override
@@ -255,16 +312,12 @@ protected ClientCall<ReqT, RespT> delegate() {
255312

256313
@Override
257314
public void start(Listener<RespT> responseListener, Metadata headers) {
258-
startNanos = System.nanoTime();
259-
260315
Listener<RespT> listener =
261316
new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(
262317
responseListener) {
263318
@Override
264319
public void onClose(Status status, Metadata trailers) {
265-
if (!decremented.getAndSet(true)) {
266-
channelRef.activeStreamsCountDecr(startNanos, status, false);
267-
}
320+
finishCount(status, false);
268321
// Unbind the affinity key when the caller explicitly requests it
269322
// (e.g., on terminal RPCs like Commit or Rollback) to prevent
270323
// unbounded growth of the affinity map.
@@ -281,20 +334,34 @@ public void onMessage(RespT message) {
281334
}
282335
};
283336

284-
channelRef.activeStreamsCountIncr();
285-
delegateCall.start(listener, headers);
337+
try {
338+
delegateCall.start(listener, headers);
339+
} catch (RuntimeException | Error failure) {
340+
finishCount(Status.fromThrowable(failure), true);
341+
throw failure;
342+
}
286343
}
287344

288345
@Override
289346
public void cancel(String message, Throwable cause) {
290-
if (!decremented.getAndSet(true)) {
291-
channelRef.activeStreamsCountDecr(startNanos, Status.CANCELLED, true);
292-
}
347+
finishCount(Status.CANCELLED, true);
293348
// Always unbind on cancel — the transaction is being abandoned.
294349
if (affinityKey != null) {
295350
delegateChannel.unbind(Collections.singletonList(affinityKey));
296351
}
297352
delegateCall.cancel(message, cause);
298353
}
354+
355+
private void finishCount(Status status, boolean fromClientSide) {
356+
synchronized (countLock) {
357+
if (finished) {
358+
return;
359+
}
360+
finished = true;
361+
if (counted) {
362+
channelRef.activeStreamsCountDecr(startNanos, status, fromClientSide);
363+
}
364+
}
365+
}
299366
}
300367
}

0 commit comments

Comments
 (0)