Skip to content

Commit dd75645

Browse files
authored
feat(grpc-gcp): prime scaled channels before publish (#14232)
With dynamic scaling enabled, a freshly built channel was published to the pool the moment its delegate was constructed. Its first real RPCs then paid for connection establishment, TLS, and any service-side warm-up, so every scale-up event injected a latency spike into live traffic right when the pool was already under load. **Change** - **Optional channel primer.** New `GcpChannelPrimer` hook (`setChannelPrimer`) issues a cheap end-to-end RPC on each newly built scale-up channel before it is published; for Cloud Spanner this can be a `SELECT 1`. Channels in a scale-up batch are primed concurrently and each one is published as soon as its own prime succeeds, so one slow channel never holds back the rest. A `null` primer (the default) keeps the existing publish-on-build path. Only dynamic scale-up channels are primed; the initial pool is not. - **Bounded attempts.** Each attempt is bounded by `channelPrimeTimeout` (default 10s) and retried up to `channelPrimeMaxAttempts` (default 3) with exponential backoff (100ms doubling, capped at 5s). The timeout covers the whole attempt, including the synchronous `prime()` call. A primer still blocked inside `prime()` at the timeout fails the channel without a retry, since nothing can stop that call; primer invocations run on a dedicated executor so a blocked primer cannot starve the shared scheduler that drives scale-up, draining, and timeouts. Exhausted or timed-out channels are closed and counted in the new `scale_up_prime_failures` metric. - **Scale-up accounting.** Channels still priming count toward both the pool size cap and the desired size, so a second scale-up during a slow prime batch cannot rebuild the same shortfall or construct delegates past `maxSize`. A prime abandoned because its call never returned keeps its slot until the call does, so a stuck primer can pin at most `maxSize` slots rather than one thread per scale-up event. - **Lifecycle.** Unpublished priming channels are never visible to pickers; shutdown cancels every pending prime and closes its delegate. Per-RPC pick and stream-accounting paths are unchanged, with no new hot-path allocations. - **Configuration.** `setChannelPrimeTimeout` rejects negative or non-nanosecond-representable durations (zero uses the default); `setChannelPrimeMaxAttempts` rejects negatives (zero uses the default).
1 parent 1c643de commit dd75645

10 files changed

Lines changed: 2072 additions & 13 deletions

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

Lines changed: 481 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
/**
23+
* Primes delegate channels built by dynamic scale-up before they are published to the pool. The
24+
* initial pool and non-dynamic growth are not primed.
25+
*/
26+
@FunctionalInterface
27+
public interface GcpChannelPrimer {
28+
29+
/**
30+
* Issues a cheap, read-only end-to-end RPC on {@code channel} so its transport, TLS session, and
31+
* server-side connection are established before the channel serves live traffic. Scale-up batches
32+
* invoke this method concurrently across channels and publish each channel as soon as its own
33+
* future succeeds. For example, a Cloud Spanner implementation can execute {@code SELECT 1}.
34+
* Return a failed future to reject the attempt; the pool retries up to the configured attempt
35+
* count and closes the channel when they are exhausted.
36+
*
37+
* <p>Implementations must observe the following:
38+
*
39+
* <ul>
40+
* <li><b>Return promptly and do the work inside the future.</b> Do not block in this method (no
41+
* blocking stub, {@code ResultSet.next()}, {@code Future.get()}, or waiting on other
42+
* threads). The configured prime timeout bounds each attempt as a whole: a future still
43+
* pending at the timeout is cancelled and the attempt retried, while a call that is still
44+
* blocked inside this method at the timeout fails the channel with no retry, because
45+
* nothing can stop it. Such a call keeps occupying its primer thread and a scale-up slot
46+
* until it returns.
47+
* <li><b>Set an explicit, short deadline on the RPC</b>, for example {@code
48+
* stub.withDeadlineAfter(5, SECONDS)}. Do not rely on the default deadline: many Google
49+
* Cloud APIs default to very long ones, such as one hour for Cloud Spanner's {@code
50+
* ExecuteStreamingSql}, so an unresponsive backend would leave the call lingering on the
51+
* server long after the pool has given up on it.
52+
* <li><b>Propagate cancellation to the gRPC call.</b> The pool calls {@code
53+
* future.cancel(true)} on the returned future when the attempt times out or the pool shuts
54+
* down. Cancelling the future must cancel the underlying {@link io.grpc.ClientCall} or
55+
* {@link io.grpc.Context.CancellableContext} so the stream and its server-side resources
56+
* are released promptly.
57+
* <li><b>Expect the channel to be shut down under a running call.</b> The pool may call {@code
58+
* shutdownNow()} on {@code channel} while this method is still running: when the call is
59+
* still blocked at the timeout, or when the pool shuts down. A channel-shutdown status from
60+
* an RPC on {@code channel} is then expected and is not a backend outage.
61+
* <li><b>Tolerate concurrent calls for one channel.</b> A retry may overlap work from a
62+
* cancelled future if that work does not promptly honor cancellation, so the implementation
63+
* must be safe for multiple invocations on the same channel at once.
64+
* <li><b>Keep the RPC trivial and side-effect free.</b> It should be strictly read-only and
65+
* need minimal server work, such as {@code SELECT 1} or a lightweight ping.
66+
* </ul>
67+
*
68+
* @param channel the newly built, not yet published delegate channel to prime
69+
* @return a future that succeeds when the priming RPC succeeds, or fails to reject the attempt
70+
*/
71+
ListenableFuture<Void> prime(ManagedChannel channel);
72+
}

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

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
import java.util.concurrent.atomic.AtomicLong;
7777
import java.util.function.Consumer;
7878
import java.util.function.IntUnaryOperator;
79+
import java.util.function.LongUnaryOperator;
7980
import java.util.function.Supplier;
8081
import java.util.logging.Level;
8182
import java.util.logging.Logger;
@@ -84,6 +85,7 @@
8485

8586
/** A channel management factory that implements grpc.Channel APIs. */
8687
public class GcpManagedChannel extends ManagedChannel {
88+
8789
private static final Logger logger = Logger.getLogger(GcpManagedChannel.class.getName());
8890
static final AtomicInteger channelPoolIndex = new AtomicInteger();
8991

@@ -181,6 +183,7 @@ private static int stateFromChannelId(int channelId) {
181183
private int errorPenaltyStep = 5;
182184
private Duration errorPenaltyDuration = Duration.ofSeconds(5);
183185
private long errorPenaltyDurationNanos = Duration.ofSeconds(5).toNanos();
186+
@Nullable private GcpChannelPrimeController channelPrimeController;
184187
private boolean isDynamicScalingEnabled = false;
185188
private int maxConcurrentStreamsLowWatermark = DEFAULT_MAX_STREAM;
186189
private GcpManagedChannelOptions.ChannelPickStrategy channelPickStrategy =
@@ -302,6 +305,7 @@ private static int stateFromChannelId(int channelId) {
302305
private AtomicLong maxUnresponsiveDrops = new AtomicLong();
303306
private AtomicLong scaleUpCount = new AtomicLong();
304307
private AtomicLong scaleDownCount = new AtomicLong();
308+
private final AtomicLong scaleUpPrimeFailures = new AtomicLong();
305309

306310
// Clock supplier for nanoTime, injectable for testing.
307311
private NanoClock nanoClock = System::nanoTime;
@@ -354,6 +358,41 @@ int readyChannelCountForTest() {
354358
return readyChannels.get();
355359
}
356360

361+
@VisibleForTesting
362+
long scaleUpPrimeFailuresForTest() {
363+
return scaleUpPrimeFailures.get();
364+
}
365+
366+
@VisibleForTesting
367+
boolean scaleUpWorkerRunningForTest() {
368+
return scaleUpWorkerRunning.get();
369+
}
370+
371+
@VisibleForTesting
372+
synchronized int inFlightPrimeCountForTest() {
373+
return channelPrimeController == null ? 0 : channelPrimeController.inFlightCount();
374+
}
375+
376+
@VisibleForTesting
377+
synchronized int abandonedPrimeCountForTest() {
378+
return channelPrimeController == null ? 0 : channelPrimeController.abandonedCount();
379+
}
380+
381+
@VisibleForTesting
382+
static long primeBackoffMillisForTest(int attempt) {
383+
return GcpChannelPrimeController.backoffMillis(attempt);
384+
}
385+
386+
@VisibleForTesting
387+
static long primeBackoffMillisForTest(int attempt, LongUnaryOperator random) {
388+
return GcpChannelPrimeController.backoffMillis(attempt, random);
389+
}
390+
391+
@VisibleForTesting
392+
static long primeBaseBackoffMillisForTest(int attempt) {
393+
return GcpChannelPrimeController.baseBackoffMillis(attempt);
394+
}
395+
357396
@VisibleForTesting
358397
synchronized int drainTaskCountForTest() {
359398
return drainTasks.size();
@@ -742,13 +781,64 @@ private void initOptions() {
742781
errorPenaltyStep = poolOptions.getErrorPenaltyStep();
743782
errorPenaltyDuration = poolOptions.getErrorPenaltyDuration();
744783
errorPenaltyDurationNanos = errorPenaltyDuration.toNanos();
784+
channelPrimeController = createChannelPrimeController(poolOptions);
745785
isDynamicScalingEnabled =
746786
minRpcPerChannel > 0 && maxRpcPerChannel > 0 && !scaleDownInterval.isZero();
747787
channelPickStrategy = poolOptions.getChannelPickStrategy();
748788
}
749789
initMetrics();
750790
}
751791

792+
@Nullable
793+
private GcpChannelPrimeController createChannelPrimeController(
794+
GcpManagedChannelOptions.GcpChannelPoolOptions poolOptions) {
795+
GcpChannelPrimer primer = poolOptions.getChannelPrimer();
796+
if (primer == null) {
797+
return null;
798+
}
799+
GcpChannelPrimeController.Pool pool =
800+
new GcpChannelPrimeController.Pool() {
801+
@Override
802+
public boolean isShuttingDown() {
803+
return shuttingDown;
804+
}
805+
806+
@Override
807+
public boolean isFull() {
808+
return channelRefs.size() >= maxSize;
809+
}
810+
811+
@Override
812+
public void addPrimedChannel(ManagedChannel channel) {
813+
addBuiltChannel(channel);
814+
scaleUpCount.incrementAndGet();
815+
}
816+
817+
@Override
818+
public void reportPrimeFailure(Throwable failure) {
819+
scaleUpPrimeFailures.incrementAndGet();
820+
logger.log(Level.WARNING, log("Scaled-up channel priming failed"), failure);
821+
}
822+
823+
@Override
824+
public void reportPrimeAbandoned() {
825+
logger.warning(
826+
log(
827+
"Channel priming abandoned without retry: prime() was still blocked at the"
828+
+ " timeout and nothing can stop it. The channel is closed and the blocked"
829+
+ " call keeps occupying a primer thread and a scale-up slot until it"
830+
+ " returns."));
831+
}
832+
};
833+
return new GcpChannelPrimeController(
834+
this,
835+
pool,
836+
primer,
837+
poolOptions.getChannelPrimeTimeout().toNanos(),
838+
poolOptions.getChannelPrimeMaxAttempts(),
839+
SHARED_BACKGROUND_SERVICE);
840+
}
841+
752842
private synchronized void initCleanupTask(Duration cleanupInterval) {
753843
if (cleanupInterval.isZero()) {
754844
return;
@@ -1060,6 +1150,13 @@ private void initMetrics() {
10601150
this,
10611151
GcpManagedChannel::reportScaleUp,
10621152
GcpManagedChannel::reportScaleDown);
1153+
1154+
createDerivedLongCumulativeTimeSeries(
1155+
GcpMetricsConstants.METRIC_SCALE_UP_PRIME_FAILURES,
1156+
"The number of scaled-up channels rejected because priming failed or timed out.",
1157+
GcpMetricsConstants.COUNT,
1158+
this,
1159+
GcpManagedChannel::reportScaleUpPrimeFailures);
10631160
}
10641161

10651162
private void setupOtelCommonAttributes(GcpMetricsOptions metricsOptions) {
@@ -1315,6 +1412,14 @@ private void initOtelMetrics(Meter meter) {
13151412
m.record(reportScaleUp(), withDirection(GcpMetricsConstants.DIRECTION_UP));
13161413
m.record(reportScaleDown(), withDirection(GcpMetricsConstants.DIRECTION_DOWN));
13171414
});
1415+
1416+
meter
1417+
.gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_SCALE_UP_PRIME_FAILURES)
1418+
.ofLongs()
1419+
.setDescription(
1420+
"The number of scaled-up channels rejected because priming failed or timed out.")
1421+
.setUnit(GcpMetricsConstants.COUNT)
1422+
.buildWithCallback(m -> m.record(reportScaleUpPrimeFailures(), otelCommonAttributes));
13181423
}
13191424

13201425
private void logGauge(String key, long value) {
@@ -1343,6 +1448,7 @@ void logMetrics() {
13431448
reportMaxAllowedChannels();
13441449
reportScaleUp();
13451450
reportScaleDown();
1451+
reportScaleUpPrimeFailures();
13461452
reportNumChannelDisconnect();
13471453
reportNumChannelConnect();
13481454
reportMinReadinessTime();
@@ -1707,6 +1813,12 @@ private long reportScaleDown() {
17071813
return value;
17081814
}
17091815

1816+
private long reportScaleUpPrimeFailures() {
1817+
long value = scaleUpPrimeFailures.get();
1818+
logCumulative(GcpMetricsConstants.METRIC_SCALE_UP_PRIME_FAILURES, value);
1819+
return value;
1820+
}
1821+
17101822
private void incReadyChannels(boolean connected) {
17111823
if (connected) {
17121824
numChannelConnect.incrementAndGet();
@@ -2234,7 +2346,11 @@ private void dynamicUpscale() {
22342346
}
22352347
final int channelsToBuild;
22362348
synchronized (this) {
2237-
if (!isDynamicScalingEnabled || shuttingDown || channelRefs.size() >= maxSize) {
2349+
// Channels still priming are capacity already on its way: they count toward the size cap
2350+
// and the desired size, so overlapping scale-ups cannot build the same shortfall twice.
2351+
// Abandoned primes keep their slot until their blocked prime() call returns.
2352+
int priming = channelPrimeController == null ? 0 : channelPrimeController.primingCount();
2353+
if (!isDynamicScalingEnabled || shuttingDown || channelRefs.size() + priming >= maxSize) {
22382354
return;
22392355
}
22402356
if (lastScaleUpNanos != Long.MIN_VALUE
@@ -2248,11 +2364,11 @@ private void dynamicUpscale() {
22482364
return;
22492365
}
22502366
int desired = ceilDiv(pickerLoad(activeChannels, now), targetRpcPerChannel());
2251-
int add = desired - active;
2367+
int add = desired - active - priming;
22522368
// Small pools may add two channels per event before percentage growth dominates.
22532369
int percentCap = Math.max(2, ceilDiv((long) active * maxScaleUpPercent, 100));
22542370
add = Math.min(add, percentCap);
2255-
add = Math.min(add, maxSize - active);
2371+
add = Math.min(add, maxSize - channelRefs.size() - priming);
22562372
if (add <= 0) {
22572373
return;
22582374
}
@@ -2277,6 +2393,11 @@ private void dynamicUpscale() {
22772393
throw failure;
22782394
}
22792395

2396+
if (channelPrimeController != null) {
2397+
builtChannels.forEach(channelPrimeController::startPrime);
2398+
return;
2399+
}
2400+
22802401
int added = 0;
22812402
List<ManagedChannel> surplus = new ArrayList<>();
22822403
synchronized (this) {
@@ -2555,7 +2676,7 @@ private String keyFromOptsCtx(CallOptions callOptions) {
25552676
return key;
25562677
}
25572678

2558-
private synchronized void cancelBackgroundTasks() {
2679+
private synchronized List<GcpChannelPrimeController.PendingPrime> cancelBackgroundTasks() {
25592680
shuttingDown = true;
25602681
scaleUpSignalPending.set(false);
25612682
if (cleanupTask != null) {
@@ -2572,12 +2693,18 @@ private synchronized void cancelBackgroundTasks() {
25722693
}
25732694
drainTasks.values().forEach(task -> task.cancel(false));
25742695
drainTasks.clear();
2696+
return channelPrimeController == null
2697+
? Collections.emptyList()
2698+
: channelPrimeController.detachAll();
25752699
}
25762700

25772701
@Override
25782702
public ManagedChannel shutdownNow() {
25792703
logger.finer(log("Shutdown now started."));
2580-
cancelBackgroundTasks();
2704+
List<GcpChannelPrimeController.PendingPrime> primesToCancel = cancelBackgroundTasks();
2705+
if (channelPrimeController != null) {
2706+
channelPrimeController.cancel(primesToCancel, true);
2707+
}
25812708
List<ChannelRef> activeSnapshot = new ArrayList<>(channelRefs);
25822709
List<ChannelRef> removedSnapshot = new ArrayList<>(removedChannelRefs);
25832710
for (ChannelRef channelRef : activeSnapshot) {
@@ -2599,7 +2726,10 @@ public ManagedChannel shutdownNow() {
25992726
@Override
26002727
public ManagedChannel shutdown() {
26012728
logger.finer(log("Shutdown started."));
2602-
cancelBackgroundTasks();
2729+
List<GcpChannelPrimeController.PendingPrime> primesToCancel = cancelBackgroundTasks();
2730+
if (channelPrimeController != null) {
2731+
channelPrimeController.cancel(primesToCancel, false);
2732+
}
26032733
List<ChannelRef> activeSnapshot = new ArrayList<>(channelRefs);
26042734
List<ChannelRef> removedSnapshot = new ArrayList<>(removedChannelRefs);
26052735
for (ChannelRef channelRef : activeSnapshot) {

0 commit comments

Comments
 (0)