diff --git a/java-spanner/grpc-gcp/pom.xml b/java-spanner/grpc-gcp/pom.xml
new file mode 100644
index 000000000000..df13e8461f01
--- /dev/null
+++ b/java-spanner/grpc-gcp/pom.xml
@@ -0,0 +1,168 @@
+
+
+ 4.0.0
+ com.google.cloud
+ grpc-gcp
+ 1.9.2
+ jar
+ gRPC extension library for Google Cloud Platform
+ GRPC-GCP-Extension Java
+ https://github.com/GoogleCloudPlatform/grpc-gcp-java/tree/master/grpc-gcp
+
+
+ com.google.cloud
+ google-cloud-spanner-parent
+ 6.114.0
+
+
+
+ grpc-gcp
+ 0.31.1
+
+
+
+
+ grpc.io
+ gRPC Contributors
+ grpc-io@googlegroups.com
+ https://grpc.io/
+ gRPC Authors
+ https://www.google.com
+
+
+
+
+
+ Apache 2.0
+ https://opensource.org/licenses/Apache-2.0
+
+
+
+
+ scm:git:https://github.com/GoogleCloudPlatform/grpc-gcp-java.git
+ https://github.com/GoogleCloudPlatform/grpc-gcp-java
+
+
+
+
+ com.google.protobuf
+ protobuf-java
+
+
+ com.google.protobuf
+ protobuf-java-util
+
+
+ io.grpc
+ grpc-api
+
+
+ io.grpc
+ grpc-core
+
+
+ io.grpc
+ grpc-netty-shaded
+
+
+ io.grpc
+ grpc-protobuf
+
+
+ io.grpc
+ grpc-stub
+
+
+ io.opencensus
+ opencensus-api
+ ${opencensus.version}
+
+
+ io.opentelemetry
+ opentelemetry-api
+
+
+ com.google.api
+ api-common
+
+
+ com.google.guava
+ guava
+
+
+ com.google.auto.value
+ auto-value-annotations
+
+
+ com.google.code.findbugs
+ jsr305
+
+
+ com.google.errorprone
+ error_prone_annotations
+
+
+
+ junit
+ junit
+ test
+
+
+ com.google.truth
+ truth
+ test
+
+
+ org.mockito
+ mockito-core
+ 4.11.0
+ test
+
+
+ com.google.http-client
+ google-http-client
+ test
+
+
+ io.opentelemetry
+ opentelemetry-sdk
+ test
+
+
+ io.opentelemetry
+ opentelemetry-sdk-metrics
+ test
+
+
+ io.opentelemetry
+ opentelemetry-sdk-testing
+ test
+
+
+ com.google.api.grpc
+ proto-google-cloud-spanner-v1
+ test
+
+
+
+
+
+
+
+ org.codehaus.mojo
+ flatten-maven-plugin
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+ com.google.auto.value:auto-value-annotations,javax.annotation:javax.annotation-api,com.google.errorprone:error_prone_annotations
+
+
+
+
+
+
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpClientCall.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpClientCall.java
new file mode 100644
index 000000000000..517e716da8ac
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpClientCall.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import com.google.cloud.grpc.proto.AffinityConfig;
+import com.google.common.base.MoreObjects;
+import io.grpc.Attributes;
+import io.grpc.CallOptions;
+import io.grpc.ClientCall;
+import io.grpc.ForwardingClientCall;
+import io.grpc.ForwardingClientCallListener;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.grpc.Status;
+import java.util.ArrayDeque;
+import java.util.Collections;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * A wrapper of ClientCall that can fetch the affinitykey from the request/response message.
+ *
+ *
It stores the information such as method, calloptions, the ChannelRef which created it, etc to
+ * facilitate creating new calls. It gets the affinitykey from the request/response message, and
+ * defines the callback functions to manage the number of active streams and bind/unbind the
+ * affinity key with the channel.
+ */
+public class GcpClientCall extends ClientCall {
+ private final MethodDescriptor methodDescriptor;
+ private final CallOptions callOptions;
+ private final GcpManagedChannel delegateChannel;
+ private final AffinityConfig affinity;
+
+ private GcpManagedChannel.ChannelRef delegateChannelRef = null;
+ private ClientCall delegateCall = null;
+ private List keys = null;
+ private boolean received = false;
+ private final AtomicBoolean decremented = new AtomicBoolean(false);
+
+ @GuardedBy("this")
+ private final Queue calls = new ArrayDeque<>();
+
+ @GuardedBy("this")
+ private boolean started;
+
+ private long startNanos = 0;
+
+ protected GcpClientCall(
+ GcpManagedChannel delegateChannel,
+ MethodDescriptor methodDescriptor,
+ CallOptions callOptions,
+ AffinityConfig affinity) {
+ this.methodDescriptor = methodDescriptor;
+ this.callOptions = callOptions;
+ this.delegateChannel = delegateChannel;
+ this.affinity = affinity;
+ }
+
+ @Override
+ public void start(Listener responseListener, Metadata headers) {
+ checkSendMessage(() -> delegateCall.start(getListener(responseListener), headers));
+ }
+
+ @Override
+ public void request(int numMessages) {
+ checkSendMessage(() -> delegateCall.request(numMessages));
+ }
+
+ @Override
+ public void setMessageCompression(boolean enabled) {
+ checkSendMessage(() -> delegateCall.setMessageCompression(enabled));
+ }
+
+ @Override
+ public void cancel(@Nullable String message, @Nullable Throwable cause) {
+ checkSendMessage(() -> checkedCancel(message, cause));
+ }
+
+ @Override
+ public void halfClose() {
+ checkSendMessage(() -> delegateCall.halfClose());
+ }
+
+ /**
+ * Delay executing operations until call.sendMessage() is called, switch the channel, start the
+ * call, do previous operations, and finally do sendMessage().
+ */
+ @Override
+ public void sendMessage(ReqT message) {
+ synchronized (this) {
+ if (!started) {
+ startNanos = System.nanoTime();
+ // Check if the current channelRef is bound with the key and change it if necessary.
+ // If no channel is bound with the key, use the least busy one.
+ keys = delegateChannel.checkKeys(message, true, methodDescriptor);
+ String key = null;
+ if (keys != null
+ && keys.size() == 1
+ && delegateChannel.getChannelRef(keys.get(0)) != null) {
+ key = keys.get(0);
+ }
+
+ if (affinity != null && affinity.getCommand().equals(AffinityConfig.Command.BIND)) {
+ delegateChannelRef = delegateChannel.getChannelRefForBind();
+ } else {
+ delegateChannelRef = delegateChannel.getChannelRef(key);
+ }
+ delegateChannelRef.activeStreamsCountIncr();
+
+ // Create the client call and do the previous operations.
+ delegateCall = delegateChannelRef.getChannel().newCall(methodDescriptor, callOptions);
+ for (Runnable call : calls) {
+ call.run();
+ }
+ calls.clear();
+ started = true;
+ }
+ }
+ delegateCall.sendMessage(message);
+ }
+
+ /** Calls that send exactly one message should not check this method. */
+ @Override
+ public boolean isReady() {
+ synchronized (this) {
+ return started && delegateCall.isReady();
+ }
+ }
+
+ /** May only be called after Listener#onHeaders or Listener#onClose. */
+ @Override
+ public Attributes getAttributes() {
+ synchronized (this) {
+ if (started) {
+ return delegateCall.getAttributes();
+ } else {
+ throw new IllegalStateException("Calling getAttributes() before sendMessage().");
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("delegate", delegateCall).toString();
+ }
+
+ private void checkedCancel(@Nullable String message, @Nullable Throwable cause) {
+ if (!decremented.getAndSet(true)) {
+ delegateChannelRef.activeStreamsCountDecr(startNanos, Status.CANCELLED, true);
+ }
+ delegateCall.cancel(message, cause);
+ }
+
+ private void checkSendMessage(Runnable call) {
+ synchronized (this) {
+ if (started) {
+ call.run();
+ } else {
+ calls.add(call);
+ }
+ }
+ }
+
+ private Listener getListener(final Listener responseListener) {
+
+ return new ForwardingClientCallListener.SimpleForwardingClientCallListener(
+ responseListener) {
+ // Decrement the stream number by one when the call is closed.
+ @Override
+ public void onClose(Status status, Metadata trailers) {
+ if (!decremented.getAndSet(true)) {
+ delegateChannelRef.activeStreamsCountDecr(startNanos, status, false);
+ }
+ // If the operation completed successfully, bind/unbind the affinity key.
+ if (keys != null && status.getCode() == Status.Code.OK) {
+ if (affinity.getCommand() == AffinityConfig.Command.UNBIND) {
+ delegateChannel.unbind(keys);
+ } else if (affinity.getCommand() == AffinityConfig.Command.BIND) {
+ delegateChannel.bind(delegateChannelRef, keys);
+ }
+ }
+ responseListener.onClose(status, trailers);
+ }
+
+ // If the command is "BIND", fetch the affinitykey from the response message and bind it
+ // with the channelRef.
+ @Override
+ public void onMessage(RespT message) {
+ delegateChannelRef.messageReceived();
+ if (!received) {
+ received = true;
+ if (keys == null) {
+ keys = delegateChannel.checkKeys(message, false, methodDescriptor);
+ }
+ }
+ responseListener.onMessage(message);
+ }
+ };
+ }
+
+ /**
+ * A simple wrapper of ClientCall.
+ *
+ * It defines the callback function to manage the number of active streams of a ChannelRef
+ * everytime a call is started/closed.
+ */
+ public static class SimpleGcpClientCall extends ForwardingClientCall {
+
+ private final GcpManagedChannel delegateChannel;
+ private final GcpManagedChannel.ChannelRef channelRef;
+ private final ClientCall delegateCall;
+ @Nullable private final String affinityKey;
+ private final boolean unbindOnComplete;
+ private long startNanos = 0;
+
+ private final AtomicBoolean decremented = new AtomicBoolean(false);
+
+ protected SimpleGcpClientCall(
+ GcpManagedChannel delegateChannel,
+ GcpManagedChannel.ChannelRef channelRef,
+ MethodDescriptor methodDescriptor,
+ CallOptions callOptions) {
+ this.delegateChannel = delegateChannel;
+ this.channelRef = channelRef;
+ this.affinityKey = callOptions.getOption(GcpManagedChannel.AFFINITY_KEY);
+ this.unbindOnComplete = callOptions.getOption(GcpManagedChannel.UNBIND_AFFINITY_KEY);
+ // Set the actual channel ID in callOptions so downstream interceptors can access it.
+ CallOptions callOptionsWithChannelId =
+ callOptions.withOption(GcpManagedChannel.CHANNEL_ID_KEY, channelRef.getId());
+ this.delegateCall =
+ channelRef.getChannel().newCall(methodDescriptor, callOptionsWithChannelId);
+ }
+
+ @Override
+ protected ClientCall delegate() {
+ return delegateCall;
+ }
+
+ @Override
+ public void start(Listener responseListener, Metadata headers) {
+ startNanos = System.nanoTime();
+
+ Listener listener =
+ new ForwardingClientCallListener.SimpleForwardingClientCallListener(
+ responseListener) {
+ @Override
+ public void onClose(Status status, Metadata trailers) {
+ if (!decremented.getAndSet(true)) {
+ channelRef.activeStreamsCountDecr(startNanos, status, false);
+ }
+ // Unbind the affinity key when the caller explicitly requests it
+ // (e.g., on terminal RPCs like Commit or Rollback) to prevent
+ // unbounded growth of the affinity map.
+ if (unbindOnComplete && affinityKey != null) {
+ delegateChannel.unbind(Collections.singletonList(affinityKey));
+ }
+ super.onClose(status, trailers);
+ }
+
+ @Override
+ public void onMessage(RespT message) {
+ channelRef.messageReceived();
+ super.onMessage(message);
+ }
+ };
+
+ channelRef.activeStreamsCountIncr();
+ delegateCall.start(listener, headers);
+ }
+
+ @Override
+ public void cancel(String message, Throwable cause) {
+ if (!decremented.getAndSet(true)) {
+ channelRef.activeStreamsCountDecr(startNanos, Status.CANCELLED, true);
+ }
+ // Always unbind on cancel — the transaction is being abandoned.
+ if (affinityKey != null) {
+ delegateChannel.unbind(Collections.singletonList(affinityKey));
+ }
+ delegateCall.cancel(message, cause);
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannel.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannel.java
new file mode 100644
index 000000000000..5d68ee6519a1
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannel.java
@@ -0,0 +1,2449 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.cloud.grpc.GcpManagedChannelOptions.GcpChannelPoolOptions;
+import com.google.cloud.grpc.GcpManagedChannelOptions.GcpMetricsOptions;
+import com.google.cloud.grpc.GcpManagedChannelOptions.GcpResiliencyOptions;
+import com.google.cloud.grpc.proto.AffinityConfig;
+import com.google.cloud.grpc.proto.ApiConfig;
+import com.google.cloud.grpc.proto.MethodConfig;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.errorprone.annotations.concurrent.GuardedBy;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.MessageOrBuilder;
+import com.google.protobuf.TextFormat;
+import io.grpc.CallOptions;
+import io.grpc.ClientCall;
+import io.grpc.ConnectivityState;
+import io.grpc.Context;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.MethodDescriptor;
+import io.grpc.Status;
+import io.grpc.Status.Code;
+import io.opencensus.common.ToLongFunction;
+import io.opencensus.metrics.DerivedLongCumulative;
+import io.opencensus.metrics.DerivedLongGauge;
+import io.opencensus.metrics.LabelKey;
+import io.opencensus.metrics.LabelValue;
+import io.opencensus.metrics.MetricOptions;
+import io.opencensus.metrics.MetricRegistry;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.api.metrics.Meter;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.LongSummaryStatistics;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/** A channel management factory that implements grpc.Channel APIs. */
+public class GcpManagedChannel extends ManagedChannel {
+ private static final Logger logger = Logger.getLogger(GcpManagedChannel.class.getName());
+ static final AtomicInteger channelPoolIndex = new AtomicInteger();
+
+ // Counter for tracking channel ids.
+ final AtomicInteger nextChannelId = new AtomicInteger();
+ static final int DEFAULT_MAX_CHANNEL = 10;
+ static final int DEFAULT_MAX_STREAM = 100;
+ public static final Context.Key DISABLE_AFFINITY_CTX_KEY =
+ Context.keyWithDefault("DisableAffinity", false);
+ public static final CallOptions.Key DISABLE_AFFINITY_KEY =
+ CallOptions.Key.createWithDefault("DisableAffinity", false);
+ public static final Context.Key AFFINITY_CTX_KEY = Context.key("AffinityKey");
+ public static final CallOptions.Key AFFINITY_KEY = CallOptions.Key.create("AffinityKey");
+
+ /** When set to true, the affinity key will be unbound after the call completes. */
+ public static final CallOptions.Key UNBIND_AFFINITY_KEY =
+ CallOptions.Key.createWithDefault("UnbindAffinityKey", false);
+
+ /**
+ * CallOptions key that will be set by grpc-gcp with the actual channel ID used for the call. This
+ * can be read by downstream interceptors to get the real channel ID after channel selection.
+ */
+ public static final CallOptions.Key CHANNEL_ID_KEY =
+ CallOptions.Key.create("GcpChannelId");
+
+ @GuardedBy("this")
+ private Integer bindingIndex = -1;
+
+ private final ManagedChannelBuilder> delegateChannelBuilder;
+ private final GcpManagedChannelOptions options;
+ private final boolean fallbackEnabled;
+ private final boolean unresponsiveDetectionEnabled;
+ private final int unresponsiveMs;
+ private final int unresponsiveDropCount;
+ private int maxSize = DEFAULT_MAX_CHANNEL;
+ private int minSize = 0;
+ private int initSize = 0;
+ private int minRpcPerChannel = 0;
+ private int maxRpcPerChannel = 0;
+ private Duration scaleDownInterval = Duration.ZERO;
+ private boolean isDynamicScalingEnabled = false;
+ private int maxConcurrentStreamsLowWatermark = DEFAULT_MAX_STREAM;
+ private GcpManagedChannelOptions.ChannelPickStrategy channelPickStrategy =
+ GcpManagedChannelOptions.ChannelPickStrategy.POWER_OF_TWO;
+ private Duration affinityKeyLifetime = Duration.ZERO;
+
+ @VisibleForTesting final Map methodToAffinity = new HashMap<>();
+
+ @VisibleForTesting
+ final Map affinityKeyToChannelRef = new ConcurrentHashMap<>();
+
+ @VisibleForTesting final Map affinityKeyLastUsed = new ConcurrentHashMap<>();
+
+ // Map from a broken channel id to the remapped affinity keys (key => ready channel id).
+ private final Map> fallbackMap = new ConcurrentHashMap<>();
+
+ // The channel pool.
+ @VisibleForTesting final List channelRefs = new CopyOnWriteArrayList<>();
+ // A set of channels that we removed from the pool and wait for their RPCs to be completed before
+ // we can shut them down.
+ final Set removedChannelRefs = new HashSet<>();
+
+ private final ExecutorService stateNotificationExecutor =
+ Executors.newCachedThreadPool(
+ GcpThreadFactory.newThreadFactory("gcp-mc-state-notifications-%d"));
+
+ // Callbacks to call when state changes.
+ @GuardedBy("this")
+ private List stateChangeCallbacks = new LinkedList<>();
+
+ // Metrics configuration.
+ private MetricRegistry metricRegistry;
+ private Meter otelMeter;
+ private Attributes otelCommonAttributes;
+ private final List labelKeys = new ArrayList<>();
+ private final List labelKeysWithResult =
+ new ArrayList<>(
+ Collections.singletonList(
+ LabelKey.create(GcpMetricsConstants.RESULT_LABEL, GcpMetricsConstants.RESULT_DESC)));
+ private final List labelKeysWithDirection =
+ new ArrayList<>(
+ Collections.singletonList(
+ LabelKey.create(
+ GcpMetricsConstants.DIRECTION_LABEL, GcpMetricsConstants.DIRECTION_LABEL_DESC)));
+ private final List labelValues = new ArrayList<>();
+ private final List labelValuesSuccess =
+ new ArrayList<>(
+ Collections.singletonList(LabelValue.create(GcpMetricsConstants.RESULT_SUCCESS)));
+ private final List labelValuesError =
+ new ArrayList<>(
+ Collections.singletonList(LabelValue.create(GcpMetricsConstants.RESULT_ERROR)));
+ private final List labelValuesUp =
+ new ArrayList<>(
+ Collections.singletonList(LabelValue.create(GcpMetricsConstants.DIRECTION_UP)));
+ private final List labelValuesDown =
+ new ArrayList<>(
+ Collections.singletonList(LabelValue.create(GcpMetricsConstants.DIRECTION_DOWN)));
+ private String metricPrefix;
+ private final String metricPoolIndex =
+ String.format("pool-%d", channelPoolIndex.incrementAndGet());
+ private final Map cumulativeMetricValues = new ConcurrentHashMap<>();
+ private static final ScheduledThreadPoolExecutor SHARED_BACKGROUND_SERVICE =
+ createSharedBackgroundService();
+
+ private ScheduledFuture> cleanupTask;
+ private ScheduledFuture> scaleDownTask;
+ private ScheduledFuture> logMetricsTask;
+
+ // Metrics counters.
+ private final AtomicInteger readyChannels = new AtomicInteger();
+ private AtomicInteger minChannels = new AtomicInteger();
+ private AtomicInteger maxChannels = new AtomicInteger();
+ private AtomicInteger minReadyChannels = new AtomicInteger();
+ private AtomicInteger maxReadyChannels = new AtomicInteger();
+ private final AtomicLong numChannelConnect = new AtomicLong();
+ private final AtomicLong numChannelDisconnect = new AtomicLong();
+ private AtomicLong minReadinessTime = new AtomicLong();
+ private AtomicLong maxReadinessTime = new AtomicLong();
+ private final AtomicLong totalReadinessTime = new AtomicLong();
+ private final AtomicLong readinessTimeOccurrences = new AtomicLong();
+ private final AtomicInteger totalActiveStreams = new AtomicInteger();
+ private AtomicInteger minActiveStreams = new AtomicInteger();
+ private AtomicInteger maxActiveStreams = new AtomicInteger();
+ private AtomicInteger minTotalActiveStreams = new AtomicInteger();
+ private AtomicInteger maxTotalActiveStreams = new AtomicInteger();
+ private AtomicInteger maxTotalActiveStreamsForScaleDown = new AtomicInteger();
+ private long minOkCalls = 0;
+ private long maxOkCalls = 0;
+ private final AtomicLong totalOkCalls = new AtomicLong();
+ private boolean minOkReported = false;
+ private boolean maxOkReported = false;
+ private long minErrCalls = 0;
+ private long maxErrCalls = 0;
+ private final AtomicLong totalErrCalls = new AtomicLong();
+ private boolean minErrReported = false;
+ private boolean maxErrReported = false;
+ private final AtomicInteger minAffinity = new AtomicInteger();
+ private final AtomicInteger maxAffinity = new AtomicInteger();
+ private final AtomicInteger totalAffinityCount = new AtomicInteger();
+ private final AtomicLong fallbacksSucceeded = new AtomicLong();
+ private final AtomicLong fallbacksFailed = new AtomicLong();
+ private final AtomicLong unresponsiveDetectionCount = new AtomicLong();
+ private AtomicLong minUnresponsiveMs = new AtomicLong();
+ private AtomicLong maxUnresponsiveMs = new AtomicLong();
+ private AtomicLong minUnresponsiveDrops = new AtomicLong();
+ private AtomicLong maxUnresponsiveDrops = new AtomicLong();
+ private AtomicLong scaleUpCount = new AtomicLong();
+ private AtomicLong scaleDownCount = new AtomicLong();
+
+ // Clock supplier for nanoTime, injectable for testing.
+ private Supplier nanoClock = System::nanoTime;
+
+ @VisibleForTesting
+ void setNanoClock(Supplier nanoClock) {
+ this.nanoClock = nanoClock;
+ }
+
+ private static ScheduledThreadPoolExecutor createSharedBackgroundService() {
+ ScheduledThreadPoolExecutor executor =
+ new ScheduledThreadPoolExecutor(
+ Math.max(2, Math.min(4, Runtime.getRuntime().availableProcessors() / 2)),
+ GcpThreadFactory.newThreadFactory("gcp-mc-bg-%d"));
+ executor.setRemoveOnCancelPolicy(true);
+ executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+ return executor;
+ }
+
+ /**
+ * Constructor for GcpManagedChannel.
+ *
+ * @param delegateChannelBuilder the underlying delegate ManagedChannelBuilder.
+ * @param apiConfig the ApiConfig object for configuring GcpManagedChannel.
+ * @param options the options for GcpManagedChannel.
+ */
+ public GcpManagedChannel(
+ ManagedChannelBuilder> delegateChannelBuilder,
+ ApiConfig apiConfig,
+ GcpManagedChannelOptions options) {
+ loadApiConfig(apiConfig);
+ this.delegateChannelBuilder = delegateChannelBuilder;
+ this.options = options;
+ logger.finer(
+ log(
+ "Created with api config: %s, and options: %s",
+ apiConfig == null ? "null" : TextFormat.shortDebugString(apiConfig), options));
+ initOptions();
+ GcpResiliencyOptions resiliencyOptions = options.getResiliencyOptions();
+ if (resiliencyOptions != null) {
+ fallbackEnabled = resiliencyOptions.isNotReadyFallbackEnabled();
+ unresponsiveDetectionEnabled = resiliencyOptions.isUnresponsiveDetectionEnabled();
+ unresponsiveMs = resiliencyOptions.getUnresponsiveDetectionMs();
+ unresponsiveDropCount = resiliencyOptions.getUnresponsiveDetectionDroppedCount();
+ } else {
+ fallbackEnabled = false;
+ unresponsiveDetectionEnabled = false;
+ unresponsiveMs = 0;
+ unresponsiveDropCount = 0;
+ }
+ initChannels();
+ GcpChannelPoolOptions channelPoolOptions = options.getChannelPoolOptions();
+ if (channelPoolOptions != null) {
+ affinityKeyLifetime = channelPoolOptions.getAffinityKeyLifetime();
+ initCleanupTask(channelPoolOptions.getCleanupInterval());
+ initScaleDownChecker(channelPoolOptions.getScaleDownInterval());
+ }
+ }
+
+ /**
+ * Constructor for GcpManagedChannel. Deprecated. Use the one without the poolSize and set the
+ * maximum pool size in options. However, note that if setting the pool size from options then
+ * concurrent streams low watermark (even the default one) will be also taken from the options and
+ * not apiConfig.
+ *
+ * @param delegateChannelBuilder the underlying delegate ManagedChannelBuilder.
+ * @param apiConfig the ApiConfig object for configuring GcpManagedChannel.
+ * @param poolSize maximum number of channels the pool can have.
+ * @param options the options for GcpManagedChannel.
+ */
+ @Deprecated
+ public GcpManagedChannel(
+ ManagedChannelBuilder> delegateChannelBuilder,
+ ApiConfig apiConfig,
+ int poolSize,
+ GcpManagedChannelOptions options) {
+ this(delegateChannelBuilder, apiConfig, options);
+ if (poolSize != 0) {
+ logger.finer(log("Pool size adjusted to %d", poolSize));
+ this.maxSize = poolSize;
+ }
+ }
+
+ private void cleanupAffinityKeys() {
+ final long cutoff = System.nanoTime() - affinityKeyLifetime.toNanos();
+ affinityKeyLastUsed.forEach(
+ (String key, Long time) -> {
+ if (time < cutoff) {
+ unbind(Collections.singletonList(key));
+ }
+ });
+ }
+
+ private synchronized void checkScaleDown() {
+ if (!isDynamicScalingEnabled) {
+ return;
+ }
+
+ // Use and reset maxTotalActiveStreamsForScaleDown.
+ int maxTotalActiveStreamsCount =
+ maxTotalActiveStreamsForScaleDown.getAndSet(totalActiveStreams.get());
+ // Number of channels to support maximum seen (since last check) concurrent streams
+ // with lowest desired utilization (minRpcPerChannel).
+ int desiredSize =
+ maxTotalActiveStreamsCount / minRpcPerChannel
+ + ((maxTotalActiveStreamsCount % minRpcPerChannel == 0) ? 0 : 1);
+
+ int scaleDownTo = Math.max(minSize, desiredSize);
+ // Remove those extra channels that are the oldest.
+ removeOldestChannels(channelRefs.size() - scaleDownTo);
+
+ // Shutdown removed channels where all RPCs are completed.
+ List completedChRefs =
+ removedChannelRefs.stream()
+ .filter(chRef -> (chRef.getActiveStreamsCount() == 0))
+ .collect(Collectors.toList());
+ removedChannelRefs.removeAll(completedChRefs);
+ for (ChannelRef channelRef : completedChRefs) {
+ channelRef.getChannel().shutdown();
+ // Remove channel from broken channels map.
+ fallbackMap.remove(channelRef.getId());
+ }
+ }
+
+ private void removeOldestChannels(int num) {
+ if (num <= 0) {
+ return;
+ }
+
+ // Select longest connected channels (or disconnected channels).
+ final List channelsToRemove =
+ channelRefs.stream()
+ .sorted(Comparator.comparing(ChannelRef::getConnectedSinceNanos))
+ .limit(num)
+ .collect(Collectors.toList());
+
+ // Remove from active channels.
+ channelRefs.removeAll(channelsToRemove);
+
+ for (ChannelRef channelRef : channelsToRemove) {
+ channelRef.resetAffinityCount();
+ if (channelRef.getState() == ConnectivityState.READY) {
+ decReadyChannels(false);
+ }
+ }
+
+ // Remove affinity keys mapping for the channels.
+ affinityKeyToChannelRef
+ .keySet()
+ .removeIf(key -> channelsToRemove.contains(affinityKeyToChannelRef.get(key)));
+
+ // Keep them aside to wait for all RPCs to complete.
+ removedChannelRefs.addAll(channelsToRemove);
+
+ // Track minimum number of channels for metrics.
+ minChannels.accumulateAndGet(getNumberOfChannels(), Math::min);
+ scaleDownCount.addAndGet(channelsToRemove.size());
+
+ // Removing a channel may change channel pool state.
+ executeStateChangeCallbacks();
+ }
+
+ private Supplier log(Supplier messageSupplier) {
+ return () -> String.format("%s: %s", metricPoolIndex, messageSupplier.get());
+ }
+
+ private String log(String message) {
+ return String.format("%s: %s", metricPoolIndex, message);
+ }
+
+ private String log(String format, Object... args) {
+ return String.format("%s: %s", metricPoolIndex, String.format(format, args));
+ }
+
+ private synchronized void initChannels() {
+ while (Math.max(minSize, initSize) - getNumberOfChannels() > 0) {
+ createNewChannel();
+ }
+ }
+
+ private void initOptions() {
+ GcpManagedChannelOptions.GcpChannelPoolOptions poolOptions = options.getChannelPoolOptions();
+ if (poolOptions != null) {
+ maxSize = poolOptions.getMaxSize();
+ minSize = poolOptions.getMinSize();
+ maxConcurrentStreamsLowWatermark = poolOptions.getConcurrentStreamsLowWatermark();
+ initSize = poolOptions.getInitSize();
+ minRpcPerChannel = poolOptions.getMinRpcPerChannel();
+ maxRpcPerChannel = poolOptions.getMaxRpcPerChannel();
+ scaleDownInterval = poolOptions.getScaleDownInterval();
+ isDynamicScalingEnabled =
+ minRpcPerChannel > 0 && maxRpcPerChannel > 0 && !scaleDownInterval.isZero();
+ channelPickStrategy = poolOptions.getChannelPickStrategy();
+ }
+ initMetrics();
+ }
+
+ private synchronized void initCleanupTask(Duration cleanupInterval) {
+ if (cleanupInterval.isZero()) {
+ return;
+ }
+ cleanupTask =
+ SHARED_BACKGROUND_SERVICE.scheduleAtFixedRate(
+ this::cleanupAffinityKeys,
+ cleanupInterval.toMillis(),
+ cleanupInterval.toMillis(),
+ MILLISECONDS);
+ }
+
+ private synchronized void initScaleDownChecker(Duration scaleDownInterval) {
+ if (!isDynamicScalingEnabled || scaleDownInterval.isZero()) {
+ return;
+ }
+
+ scaleDownTask =
+ SHARED_BACKGROUND_SERVICE.scheduleAtFixedRate(
+ this::checkScaleDown,
+ scaleDownInterval.toMillis(),
+ scaleDownInterval.toMillis(),
+ MILLISECONDS);
+ }
+
+ private synchronized void initLogMetrics() {
+ logMetricsTask =
+ SHARED_BACKGROUND_SERVICE.scheduleAtFixedRate(this::logMetrics, 60, 60, SECONDS);
+ }
+
+ private void logMetricsOptions() {
+ if (options.getMetricsOptions() != null) {
+ logger.fine(log("Metrics options: %s", options.getMetricsOptions()));
+ }
+ }
+
+ private void logChannelsStats() {
+ logger.fine(
+ log(
+ "Active streams counts: [%s]",
+ Joiner.on(", ")
+ .join(
+ channelRefs.stream().mapToInt(ChannelRef::getActiveStreamsCount).iterator())));
+ logger.fine(
+ log(
+ "Removed channels active streams counts: [%s]",
+ Joiner.on(", ")
+ .join(
+ removedChannelRefs.stream()
+ .mapToInt(ChannelRef::getActiveStreamsCount)
+ .iterator())));
+ logger.fine(
+ log(
+ "Affinity counts: [%s]",
+ Joiner.on(", ")
+ .join(channelRefs.stream().mapToInt(ChannelRef::getAffinityCount).iterator())));
+ }
+
+ private void initMetrics() {
+ final GcpMetricsOptions metricsOptions = options.getMetricsOptions();
+ if (metricsOptions == null) {
+ logger.info(log("Metrics options are empty. Metrics disabled."));
+ initLogMetrics();
+ return;
+ }
+ logMetricsOptions();
+ if (metricsOptions.getOpenTelemetryMeter() != null) {
+ // Prefer OpenTelemetry if provided.
+ logger.info(log("OpenTelemetry meter detected. Using OpenTelemetry metrics."));
+ setupOtelCommonAttributes(metricsOptions);
+ metricPrefix = metricsOptions.getNamePrefix();
+ initOtelMetrics(metricsOptions.getOpenTelemetryMeter());
+ return;
+ }
+ if (metricsOptions.getMetricRegistry() == null) {
+ logger.info(log("Metric registry is null. Metrics disabled."));
+ initLogMetrics();
+ return;
+ }
+ logger.info(log("Metrics enabled (OpenCensus)."));
+
+ metricRegistry = metricsOptions.getMetricRegistry();
+ labelKeys.addAll(metricsOptions.getLabelKeys());
+ labelKeysWithResult.addAll(metricsOptions.getLabelKeys());
+ labelKeysWithDirection.addAll(metricsOptions.getLabelKeys());
+ labelValues.addAll(metricsOptions.getLabelValues());
+ labelValuesSuccess.addAll(metricsOptions.getLabelValues());
+ labelValuesError.addAll(metricsOptions.getLabelValues());
+ labelValuesUp.addAll(metricsOptions.getLabelValues());
+ labelValuesDown.addAll(metricsOptions.getLabelValues());
+
+ final LabelKey poolKey =
+ LabelKey.create(GcpMetricsConstants.POOL_INDEX_LABEL, GcpMetricsConstants.POOL_INDEX_DESC);
+ labelKeys.add(poolKey);
+ labelKeysWithResult.add(poolKey);
+ labelKeysWithDirection.add(poolKey);
+ final LabelValue poolIndex = LabelValue.create(metricPoolIndex);
+ labelValues.add(poolIndex);
+ labelValuesSuccess.add(poolIndex);
+ labelValuesError.add(poolIndex);
+ labelValuesUp.add(poolIndex);
+ labelValuesDown.add(poolIndex);
+
+ metricPrefix = metricsOptions.getNamePrefix();
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_READY_CHANNELS,
+ "The minimum number of channels simultaneously in the READY state.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinReadyChannels);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_READY_CHANNELS,
+ "The maximum number of channels simultaneously in the READY state.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxReadyChannels);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_NUM_CHANNELS,
+ "The number of channels currently in the pool.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportNumChannels);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_CHANNELS,
+ "The minimum number of channels in the pool.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinChannels);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_CHANNELS,
+ "The maximum number of channels in the pool.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxChannels);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_ALLOWED_CHANNELS,
+ "The maximum number of channels allowed in the pool. (The poll max size)",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxAllowedChannels);
+
+ createDerivedLongCumulativeTimeSeries(
+ GcpMetricsConstants.METRIC_NUM_CHANNEL_DISCONNECT,
+ "The number of disconnections (occurrences when a channel deviates from the READY state)",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportNumChannelDisconnect);
+
+ createDerivedLongCumulativeTimeSeries(
+ GcpMetricsConstants.METRIC_NUM_CHANNEL_CONNECT,
+ "The number of times when a channel reached the READY state.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportNumChannelConnect);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_CHANNEL_READINESS_TIME,
+ "The minimum time it took to transition a channel to the READY state.",
+ GcpMetricsConstants.MICROSECOND,
+ this,
+ GcpManagedChannel::reportMinReadinessTime);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_AVG_CHANNEL_READINESS_TIME,
+ "The average time it took to transition a channel to the READY state.",
+ GcpMetricsConstants.MICROSECOND,
+ this,
+ GcpManagedChannel::reportAvgReadinessTime);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_CHANNEL_READINESS_TIME,
+ "The maximum time it took to transition a channel to the READY state.",
+ GcpMetricsConstants.MICROSECOND,
+ this,
+ GcpManagedChannel::reportMaxReadinessTime);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_ACTIVE_STREAMS,
+ "The minimum number of active streams on any channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinActiveStreams);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_ACTIVE_STREAMS,
+ "The maximum number of active streams on any channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxActiveStreams);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_TOTAL_ACTIVE_STREAMS,
+ "The minimum total number of active streams across all channels.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinTotalActiveStreams);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_TOTAL_ACTIVE_STREAMS,
+ "The maximum total number of active streams across all channels.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxTotalActiveStreams);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_AFFINITY,
+ "The minimum number of affinity count on any channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinAffinity);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_AFFINITY,
+ "The maximum number of affinity count on any channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxAffinity);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_NUM_AFFINITY,
+ "The total number of affinity count across all channels.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportNumAffinity);
+
+ createDerivedLongGaugeTimeSeriesWithResult(
+ GcpMetricsConstants.METRIC_MIN_CALLS,
+ "The minimum number of completed calls on any channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinOkCalls,
+ GcpManagedChannel::reportMinErrCalls);
+
+ createDerivedLongGaugeTimeSeriesWithResult(
+ GcpMetricsConstants.METRIC_MAX_CALLS,
+ "The maximum number of completed calls on any channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxOkCalls,
+ GcpManagedChannel::reportMaxErrCalls);
+
+ createDerivedLongCumulativeTimeSeriesWithResult(
+ GcpMetricsConstants.METRIC_NUM_CALLS_COMPLETED,
+ "The number of calls completed across all channels.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportTotalOkCalls,
+ GcpManagedChannel::reportTotalErrCalls);
+
+ createDerivedLongCumulativeTimeSeriesWithResult(
+ GcpMetricsConstants.METRIC_NUM_FALLBACKS,
+ "The number of calls that had fallback to another channel.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportSucceededFallbacks,
+ GcpManagedChannel::reportFailedFallbacks);
+
+ createDerivedLongCumulativeTimeSeries(
+ GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS,
+ "The number of unresponsive connections detected.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportUnresponsiveDetectionCount);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DETECTION_TIME,
+ "The minimum time it took to detect an unresponsive connection.",
+ GcpMetricsConstants.MILLISECOND,
+ this,
+ GcpManagedChannel::reportMinUnresponsiveMs);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DETECTION_TIME,
+ "The maximum time it took to detect an unresponsive connection.",
+ GcpMetricsConstants.MILLISECOND,
+ this,
+ GcpManagedChannel::reportMaxUnresponsiveMs);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DROPPED_CALLS,
+ "The minimum calls dropped before detection of an unresponsive connection.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMinUnresponsiveDrops);
+
+ createDerivedLongGaugeTimeSeries(
+ GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DROPPED_CALLS,
+ "The maximum calls dropped before detection of an unresponsive connection.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportMaxUnresponsiveDrops);
+
+ createDerivedLongCumulativeTimeSeriesWithDirection(
+ GcpMetricsConstants.METRIC_CHANNEL_POOL_SCALING,
+ "The number of channels channel pool scaled up or down.",
+ GcpMetricsConstants.COUNT,
+ this,
+ GcpManagedChannel::reportScaleUp,
+ GcpManagedChannel::reportScaleDown);
+ }
+
+ private void setupOtelCommonAttributes(GcpMetricsOptions metricsOptions) {
+ AttributesBuilder builder = Attributes.builder();
+ if (metricsOptions.getOtelLabelKeys() != null && metricsOptions.getOtelLabelValues() != null) {
+ List keys = metricsOptions.getOtelLabelKeys();
+ List values = metricsOptions.getOtelLabelValues();
+ for (int i = 0; i < Math.min(keys.size(), values.size()); i++) {
+ String k = keys.get(i);
+ String v = values.get(i);
+ if (k != null && !k.isEmpty() && v != null) {
+ builder.put(k, v);
+ }
+ }
+ }
+ // pool_index label is always added
+ builder.put(GcpMetricsConstants.POOL_INDEX_LABEL, metricPoolIndex);
+ otelCommonAttributes = builder.build();
+ }
+
+ private Attributes withResult(String result) {
+ return Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(GcpMetricsConstants.RESULT_LABEL, result)
+ .build();
+ }
+
+ private Attributes withDirection(String dir) {
+ return Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(GcpMetricsConstants.DIRECTION_LABEL, dir)
+ .build();
+ }
+
+ private void initOtelMetrics(Meter meter) {
+ this.otelMeter = meter;
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_CHANNELS)
+ .ofLongs()
+ .setDescription("The number of channels currently in the pool.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportNumChannels(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_CHANNELS)
+ .ofLongs()
+ .setDescription("The minimum number of channels in the pool.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMinChannels(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_CHANNELS)
+ .ofLongs()
+ .setDescription("The maximum number of channels in the pool.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxChannels(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_ALLOWED_CHANNELS)
+ .ofLongs()
+ .setDescription("The maximum number of channels allowed in the pool. (The pool max size)")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxAllowedChannels(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_READY_CHANNELS)
+ .ofLongs()
+ .setDescription("The minimum number of channels simultaneously in the READY state.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMinReadyChannels(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_READY_CHANNELS)
+ .ofLongs()
+ .setDescription("The maximum number of channels simultaneously in the READY state.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxReadyChannels(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_CHANNEL_CONNECT)
+ .ofLongs()
+ .setDescription("The number of times when a channel reached the READY state.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportNumChannelConnect(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_CHANNEL_DISCONNECT)
+ .ofLongs()
+ .setDescription("The number of disconnections (deviations from READY state)")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportNumChannelDisconnect(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_CHANNEL_READINESS_TIME)
+ .ofLongs()
+ .setDescription("The minimum time it took to transition a channel to READY (us).")
+ .setUnit(GcpMetricsConstants.MICROSECOND)
+ .buildWithCallback(m -> m.record(reportMinReadinessTime(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_AVG_CHANNEL_READINESS_TIME)
+ .ofLongs()
+ .setDescription("The average time it took to transition a channel to READY (us).")
+ .setUnit(GcpMetricsConstants.MICROSECOND)
+ .buildWithCallback(
+ m -> {
+ m.record(reportAvgReadinessTime(), otelCommonAttributes);
+ });
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_CHANNEL_READINESS_TIME)
+ .ofLongs()
+ .setDescription("The maximum time it took to transition a channel to READY (us).")
+ .setUnit(GcpMetricsConstants.MICROSECOND)
+ .buildWithCallback(m -> m.record(reportMaxReadinessTime(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_ACTIVE_STREAMS)
+ .ofLongs()
+ .setDescription("The minimum number of active streams on any channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMinActiveStreams(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_ACTIVE_STREAMS)
+ .ofLongs()
+ .setDescription("The maximum number of active streams on any channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxActiveStreams(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_TOTAL_ACTIVE_STREAMS)
+ .ofLongs()
+ .setDescription("The minimum total number of active streams across all channels.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMinTotalActiveStreams(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_TOTAL_ACTIVE_STREAMS)
+ .ofLongs()
+ .setDescription("The maximum total number of active streams across all channels.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxTotalActiveStreams(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_AFFINITY)
+ .ofLongs()
+ .setDescription("The minimum affinity count on any channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMinAffinity(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_AFFINITY)
+ .ofLongs()
+ .setDescription("The maximum affinity count on any channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxAffinity(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_AFFINITY)
+ .ofLongs()
+ .setDescription("The total affinity count across all channels.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportNumAffinity(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_CALLS_COMPLETED)
+ .ofLongs()
+ .setDescription("The number of calls completed across all channels.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(
+ m -> {
+ m.record(reportTotalOkCalls(), withResult(GcpMetricsConstants.RESULT_SUCCESS));
+ m.record(reportTotalErrCalls(), withResult(GcpMetricsConstants.RESULT_ERROR));
+ });
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_CALLS)
+ .ofLongs()
+ .setDescription("The minimum number of completed calls on any channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(
+ m -> {
+ m.record(reportMinOkCalls(), withResult(GcpMetricsConstants.RESULT_SUCCESS));
+ m.record(reportMinErrCalls(), withResult(GcpMetricsConstants.RESULT_ERROR));
+ });
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_CALLS)
+ .ofLongs()
+ .setDescription("The maximum number of completed calls on any channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(
+ m -> {
+ m.record(reportMaxOkCalls(), withResult(GcpMetricsConstants.RESULT_SUCCESS));
+ m.record(reportMaxErrCalls(), withResult(GcpMetricsConstants.RESULT_ERROR));
+ });
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_FALLBACKS)
+ .ofLongs()
+ .setDescription("The number of calls that had fallback to another channel.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(
+ m -> {
+ m.record(reportSucceededFallbacks(), withResult(GcpMetricsConstants.RESULT_SUCCESS));
+ m.record(reportFailedFallbacks(), withResult(GcpMetricsConstants.RESULT_ERROR));
+ });
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS)
+ .ofLongs()
+ .setDescription("The number of unresponsive connections detected.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportUnresponsiveDetectionCount(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DETECTION_TIME)
+ .ofLongs()
+ .setDescription("Min time to detect an unresponsive connection (ms).")
+ .setUnit(GcpMetricsConstants.MILLISECOND)
+ .buildWithCallback(m -> m.record(reportMinUnresponsiveMs(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DETECTION_TIME)
+ .ofLongs()
+ .setDescription("Max time to detect an unresponsive connection (ms).")
+ .setUnit(GcpMetricsConstants.MILLISECOND)
+ .buildWithCallback(m -> m.record(reportMaxUnresponsiveMs(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DROPPED_CALLS)
+ .ofLongs()
+ .setDescription("Min calls dropped before unresponsive detection.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMinUnresponsiveDrops(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DROPPED_CALLS)
+ .ofLongs()
+ .setDescription("Max calls dropped before unresponsive detection.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(m -> m.record(reportMaxUnresponsiveDrops(), otelCommonAttributes));
+
+ meter
+ .gaugeBuilder(metricPrefix + GcpMetricsConstants.METRIC_CHANNEL_POOL_SCALING)
+ .ofLongs()
+ .setDescription("The number of channels channel pool scaled up or down.")
+ .setUnit(GcpMetricsConstants.COUNT)
+ .buildWithCallback(
+ m -> {
+ m.record(reportScaleUp(), withDirection(GcpMetricsConstants.DIRECTION_UP));
+ m.record(reportScaleDown(), withDirection(GcpMetricsConstants.DIRECTION_DOWN));
+ });
+ }
+
+ private void logGauge(String key, long value) {
+ logger.fine(log("stat: %s = %d", key, value));
+ }
+
+ private void logCumulative(String key, long value) {
+ logger.fine(
+ log(
+ () -> {
+ Long prevValue = cumulativeMetricValues.put(key, value);
+ long logValue = prevValue == null ? value : value - prevValue;
+ return String.format("stat: %s = %d", key, logValue);
+ }));
+ }
+
+ @VisibleForTesting
+ void logMetrics() {
+ logMetricsOptions();
+ logChannelsStats();
+ reportMinReadyChannels();
+ reportMaxReadyChannels();
+ reportMinChannels();
+ reportMaxChannels();
+ reportNumChannels();
+ reportMaxAllowedChannels();
+ reportScaleUp();
+ reportScaleDown();
+ reportNumChannelDisconnect();
+ reportNumChannelConnect();
+ reportMinReadinessTime();
+ reportAvgReadinessTime();
+ reportMaxReadinessTime();
+ reportMinActiveStreams();
+ reportMaxActiveStreams();
+ reportMinTotalActiveStreams();
+ reportMaxTotalActiveStreams();
+ reportMinAffinity();
+ reportMaxAffinity();
+ reportNumAffinity();
+ reportMinOkCalls();
+ reportMinErrCalls();
+ reportMaxOkCalls();
+ reportMaxErrCalls();
+ reportTotalOkCalls();
+ reportTotalErrCalls();
+ reportSucceededFallbacks();
+ reportFailedFallbacks();
+ reportUnresponsiveDetectionCount();
+ reportMinUnresponsiveMs();
+ reportMaxUnresponsiveMs();
+ reportMinUnresponsiveDrops();
+ reportMaxUnresponsiveDrops();
+ }
+
+ private MetricOptions createMetricOptions(
+ String description, List labelKeys, String unit) {
+ return MetricOptions.builder()
+ .setDescription(description)
+ .setLabelKeys(labelKeys)
+ .setUnit(unit)
+ .build();
+ }
+
+ private void createDerivedLongGaugeTimeSeries(
+ String name, String description, String unit, T obj, ToLongFunction func) {
+ final DerivedLongGauge metric =
+ metricRegistry.addDerivedLongGauge(
+ metricPrefix + name, createMetricOptions(description, labelKeys, unit));
+
+ metric.removeTimeSeries(labelValues);
+ metric.createTimeSeries(labelValues, obj, func);
+ }
+
+ private void createDerivedLongGaugeTimeSeriesWithResult(
+ String name,
+ String description,
+ String unit,
+ T obj,
+ ToLongFunction funcSucc,
+ ToLongFunction funcErr) {
+ final DerivedLongGauge metric =
+ metricRegistry.addDerivedLongGauge(
+ metricPrefix + name, createMetricOptions(description, labelKeysWithResult, unit));
+
+ metric.removeTimeSeries(labelValuesSuccess);
+ metric.createTimeSeries(labelValuesSuccess, obj, funcSucc);
+ metric.removeTimeSeries(labelValuesError);
+ metric.createTimeSeries(labelValuesError, obj, funcErr);
+ }
+
+ private void createDerivedLongCumulativeTimeSeriesWithDirection(
+ String name,
+ String description,
+ String unit,
+ T obj,
+ ToLongFunction funcUp,
+ ToLongFunction funcDown) {
+ final DerivedLongCumulative metric =
+ metricRegistry.addDerivedLongCumulative(
+ metricPrefix + name, createMetricOptions(description, labelKeysWithDirection, unit));
+
+ metric.removeTimeSeries(labelValuesUp);
+ metric.createTimeSeries(labelValuesUp, obj, funcUp);
+ metric.removeTimeSeries(labelValuesDown);
+ metric.createTimeSeries(labelValuesDown, obj, funcDown);
+ }
+
+ private void createDerivedLongCumulativeTimeSeries(
+ String name, String description, String unit, T obj, ToLongFunction func) {
+ final DerivedLongCumulative metric =
+ metricRegistry.addDerivedLongCumulative(
+ metricPrefix + name, createMetricOptions(description, labelKeys, unit));
+
+ metric.removeTimeSeries(labelValues);
+ metric.createTimeSeries(labelValues, obj, func);
+ }
+
+ private void createDerivedLongCumulativeTimeSeriesWithResult(
+ String name,
+ String description,
+ String unit,
+ T obj,
+ ToLongFunction funcSucc,
+ ToLongFunction funcErr) {
+ final DerivedLongCumulative metric =
+ metricRegistry.addDerivedLongCumulative(
+ metricPrefix + name, createMetricOptions(description, labelKeysWithResult, unit));
+
+ metric.removeTimeSeries(labelValuesSuccess);
+ metric.createTimeSeries(labelValuesSuccess, obj, funcSucc);
+ metric.removeTimeSeries(labelValuesError);
+ metric.createTimeSeries(labelValuesError, obj, funcErr);
+ }
+
+ private long reportNumChannels() {
+ int value = getNumberOfChannels();
+ logGauge(GcpMetricsConstants.METRIC_NUM_CHANNELS, value);
+ return value;
+ }
+
+ private long reportMinChannels() {
+ int value = minChannels.getAndSet(getNumberOfChannels());
+ logGauge(GcpMetricsConstants.METRIC_MIN_CHANNELS, value);
+ return value;
+ }
+
+ private long reportMaxChannels() {
+ int value = maxChannels.getAndSet(getNumberOfChannels());
+ logGauge(GcpMetricsConstants.METRIC_MAX_CHANNELS, value);
+ return value;
+ }
+
+ private long reportMaxAllowedChannels() {
+ logGauge(GcpMetricsConstants.METRIC_MAX_ALLOWED_CHANNELS, maxSize);
+ return maxSize;
+ }
+
+ private long reportMinReadyChannels() {
+ int value = minReadyChannels.getAndSet(readyChannels.get());
+ logGauge(GcpMetricsConstants.METRIC_MIN_READY_CHANNELS, value);
+ return value;
+ }
+
+ private long reportMaxReadyChannels() {
+ int value = maxReadyChannels.getAndSet(readyChannels.get());
+ logGauge(GcpMetricsConstants.METRIC_MAX_READY_CHANNELS, value);
+ return value;
+ }
+
+ private long reportNumChannelConnect() {
+ long value = numChannelConnect.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_CHANNEL_CONNECT, value);
+ return value;
+ }
+
+ private long reportNumChannelDisconnect() {
+ long value = numChannelDisconnect.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_CHANNEL_DISCONNECT, value);
+ return value;
+ }
+
+ private long reportMinReadinessTime() {
+ long value = minReadinessTime.getAndSet(0);
+ logGauge(GcpMetricsConstants.METRIC_MIN_CHANNEL_READINESS_TIME, value);
+ return value;
+ }
+
+ private long reportAvgReadinessTime() {
+ long value = 0;
+ long total = totalReadinessTime.getAndSet(0);
+ long occ = readinessTimeOccurrences.getAndSet(0);
+ if (occ != 0) {
+ value = total / occ;
+ }
+ logGauge(GcpMetricsConstants.METRIC_AVG_CHANNEL_READINESS_TIME, value);
+ return value;
+ }
+
+ private long reportMaxReadinessTime() {
+ long value = maxReadinessTime.getAndSet(0);
+ logGauge(GcpMetricsConstants.METRIC_MAX_CHANNEL_READINESS_TIME, value);
+ return value;
+ }
+
+ private int reportMinActiveStreams() {
+ int value =
+ minActiveStreams.getAndSet(
+ channelRefs.stream().mapToInt(ChannelRef::getActiveStreamsCount).min().orElse(0));
+ logGauge(GcpMetricsConstants.METRIC_MIN_ACTIVE_STREAMS, value);
+ return value;
+ }
+
+ private int reportMaxActiveStreams() {
+ int value =
+ maxActiveStreams.getAndSet(
+ channelRefs.stream().mapToInt(ChannelRef::getActiveStreamsCount).max().orElse(0));
+ logGauge(GcpMetricsConstants.METRIC_MAX_ACTIVE_STREAMS, value);
+ return value;
+ }
+
+ private int reportMinTotalActiveStreams() {
+ int value = minTotalActiveStreams.getAndSet(totalActiveStreams.get());
+ logGauge(GcpMetricsConstants.METRIC_MIN_TOTAL_ACTIVE_STREAMS, value);
+ return value;
+ }
+
+ private int reportMaxTotalActiveStreams() {
+ int value = maxTotalActiveStreams.getAndSet(totalActiveStreams.get());
+ logGauge(GcpMetricsConstants.METRIC_MAX_TOTAL_ACTIVE_STREAMS, value);
+ return value;
+ }
+
+ private int reportMinAffinity() {
+ int value =
+ minAffinity.getAndSet(
+ channelRefs.stream().mapToInt(ChannelRef::getAffinityCount).min().orElse(0));
+ logGauge(GcpMetricsConstants.METRIC_MIN_AFFINITY, value);
+ return value;
+ }
+
+ private int reportMaxAffinity() {
+ int value =
+ maxAffinity.getAndSet(
+ channelRefs.stream().mapToInt(ChannelRef::getAffinityCount).max().orElse(0));
+ logGauge(GcpMetricsConstants.METRIC_MAX_AFFINITY, value);
+ return value;
+ }
+
+ private int reportNumAffinity() {
+ int value = totalAffinityCount.get();
+ logGauge(GcpMetricsConstants.METRIC_NUM_AFFINITY, value);
+ return value;
+ }
+
+ private synchronized long reportMinOkCalls() {
+ minOkReported = true;
+ calcMinMaxOkCalls();
+ logGauge(GcpMetricsConstants.METRIC_MIN_CALLS + "_ok", minOkCalls);
+ return minOkCalls;
+ }
+
+ private synchronized long reportMaxOkCalls() {
+ maxOkReported = true;
+ calcMinMaxOkCalls();
+ logGauge(GcpMetricsConstants.METRIC_MAX_CALLS + "_ok", maxOkCalls);
+ return maxOkCalls;
+ }
+
+ private long reportTotalOkCalls() {
+ long value = totalOkCalls.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_CALLS_COMPLETED + "_ok", value);
+ return value;
+ }
+
+ private LongSummaryStatistics calcStatsAndLog(String logLabel, ToLongFunction func) {
+ StringBuilder str = new StringBuilder(logLabel + ": [");
+ final LongSummaryStatistics stats =
+ channelRefs.stream()
+ .mapToLong(
+ ch -> {
+ long count = func.applyAsLong(ch);
+ if (str.charAt(str.length() - 1) != '[') {
+ str.append(", ");
+ }
+ str.append(count);
+ return count;
+ })
+ .summaryStatistics();
+
+ str.append("]");
+ logger.fine(log(str.toString()));
+ return stats;
+ }
+
+ private void calcMinMaxOkCalls() {
+ if (minOkReported && maxOkReported) {
+ minOkReported = false;
+ maxOkReported = false;
+ return;
+ }
+ final LongSummaryStatistics stats = calcStatsAndLog("Ok calls", ChannelRef::getAndResetOkCalls);
+ minOkCalls = stats.getMin();
+ maxOkCalls = stats.getMax();
+ }
+
+ private synchronized long reportMinErrCalls() {
+ minErrReported = true;
+ calcMinMaxErrCalls();
+ logGauge(GcpMetricsConstants.METRIC_MIN_CALLS + "_err", minErrCalls);
+ return minErrCalls;
+ }
+
+ private synchronized long reportMaxErrCalls() {
+ maxErrReported = true;
+ calcMinMaxErrCalls();
+ logGauge(GcpMetricsConstants.METRIC_MAX_CALLS + "_err", maxErrCalls);
+ return maxErrCalls;
+ }
+
+ private long reportTotalErrCalls() {
+ long value = totalErrCalls.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_CALLS_COMPLETED + "_err", value);
+ return value;
+ }
+
+ private void calcMinMaxErrCalls() {
+ if (minErrReported && maxErrReported) {
+ minErrReported = false;
+ maxErrReported = false;
+ return;
+ }
+ final LongSummaryStatistics stats =
+ calcStatsAndLog("Failed calls", ChannelRef::getAndResetErrCalls);
+ minErrCalls = stats.getMin();
+ maxErrCalls = stats.getMax();
+ }
+
+ private long reportSucceededFallbacks() {
+ long value = fallbacksSucceeded.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_FALLBACKS + "_ok", value);
+ return value;
+ }
+
+ private long reportFailedFallbacks() {
+ long value = fallbacksFailed.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_FALLBACKS + "_fail", value);
+ return value;
+ }
+
+ private long reportUnresponsiveDetectionCount() {
+ long value = unresponsiveDetectionCount.get();
+ logCumulative(GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS, value);
+ return value;
+ }
+
+ private long reportMinUnresponsiveMs() {
+ long value = minUnresponsiveMs.getAndSet(0);
+ logGauge(GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DETECTION_TIME, value);
+ return value;
+ }
+
+ private long reportMaxUnresponsiveMs() {
+ long value = maxUnresponsiveMs.getAndSet(0);
+ logGauge(GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DETECTION_TIME, value);
+ return value;
+ }
+
+ private long reportMinUnresponsiveDrops() {
+ long value = minUnresponsiveDrops.getAndSet(0);
+ logGauge(GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DROPPED_CALLS, value);
+ return value;
+ }
+
+ private long reportMaxUnresponsiveDrops() {
+ long value = maxUnresponsiveDrops.getAndSet(0);
+ logGauge(GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DROPPED_CALLS, value);
+ return value;
+ }
+
+ private long reportScaleUp() {
+ long value = scaleUpCount.get();
+ logCumulative(GcpMetricsConstants.METRIC_CHANNEL_POOL_SCALING + "_up", value);
+ return value;
+ }
+
+ private long reportScaleDown() {
+ long value = scaleDownCount.get();
+ logCumulative(GcpMetricsConstants.METRIC_CHANNEL_POOL_SCALING + "_down", value);
+ return value;
+ }
+
+ private void incReadyChannels(boolean connected) {
+ if (connected) {
+ numChannelConnect.incrementAndGet();
+ }
+ final int newReady = readyChannels.incrementAndGet();
+ maxReadyChannels.accumulateAndGet(newReady, Math::max);
+ }
+
+ private void decReadyChannels(boolean disconnected) {
+ if (disconnected) {
+ numChannelDisconnect.incrementAndGet();
+ }
+ final int newReady = readyChannels.decrementAndGet();
+ minReadyChannels.accumulateAndGet(newReady, Math::min);
+ }
+
+ private void saveReadinessTime(long readinessNanos) {
+ long readinessTimeUs = readinessNanos / 1000;
+ minReadinessTime.compareAndSet(0, readinessTimeUs);
+ minReadinessTime.accumulateAndGet(readinessTimeUs, Math::min);
+ maxReadinessTime.accumulateAndGet(readinessTimeUs, Math::max);
+ totalReadinessTime.addAndGet(readinessTimeUs);
+ readinessTimeOccurrences.incrementAndGet();
+ }
+
+ private void recordUnresponsiveDetection(long nanos, long dropCount) {
+ unresponsiveDetectionCount.incrementAndGet();
+ final long ms = nanos / 1000000;
+ minUnresponsiveMs.compareAndSet(0, ms);
+ minUnresponsiveMs.accumulateAndGet(ms, Math::min);
+ maxUnresponsiveMs.accumulateAndGet(ms, Math::max);
+ minUnresponsiveDrops.compareAndSet(0, dropCount);
+ minUnresponsiveDrops.accumulateAndGet(dropCount, Math::min);
+ maxUnresponsiveDrops.accumulateAndGet(dropCount, Math::max);
+ }
+
+ @Override
+ public void notifyWhenStateChanged(ConnectivityState source, Runnable callback) {
+ if (getState(false).equals(source)) {
+ synchronized (this) {
+ stateChangeCallbacks.add(callback);
+ }
+ return;
+ }
+
+ try {
+ stateNotificationExecutor.execute(callback);
+ } catch (RejectedExecutionException e) {
+ // Ignore exceptions on shutdown.
+ logger.fine(log("State notification change task rejected: %s", e.getMessage()));
+ }
+ }
+
+ /**
+ * ChannelStateMonitor subscribes to channel's state changes and informs {@link GcpManagedChannel}
+ * on any new state. This monitor allows to detect when a channel is not ready and temporarily
+ * route requests via another ready channel if the option is enabled.
+ */
+ private class ChannelStateMonitor implements Runnable {
+ private final ChannelRef channelRef;
+ private final ManagedChannel channel;
+ private ConnectivityState currentState;
+ private long connectingStartNanos;
+ private long connectedSinceNanos;
+
+ private ChannelStateMonitor(ManagedChannel channel, ChannelRef channelRef) {
+ this.channelRef = channelRef;
+ this.channel = channel;
+ run();
+ }
+
+ public long getConnectedSinceNanos() {
+ return connectedSinceNanos;
+ }
+
+ public ConnectivityState getCurrentState() {
+ return currentState;
+ }
+
+ @Override
+ public void run() {
+ if (channel == null) {
+ return;
+ }
+
+ // Is the channel in the pool?
+ boolean isActive = channelRefs.contains(this.channelRef);
+
+ // Keep minSize channels always connected.
+ boolean requestConnection =
+ channelRefs.size() < minSize
+ || channelRefs.stream()
+ .mapToInt(ChannelRef::getId)
+ .sorted()
+ .limit(minSize)
+ .anyMatch(id -> (id == channelRef.getId()));
+
+ ConnectivityState newState = channel.getState(requestConnection);
+ if (logger.isLoggable(Level.FINER)) {
+ logger.finer(
+ log(
+ "Channel %d state change detected: %s -> %s",
+ channelRef.getId(), currentState, newState));
+ }
+ if (newState == ConnectivityState.READY && currentState != ConnectivityState.READY) {
+ connectedSinceNanos = System.nanoTime();
+ if (isActive) {
+ incReadyChannels(true);
+ if (connectingStartNanos > 0) {
+ saveReadinessTime(System.nanoTime() - connectingStartNanos);
+ }
+ }
+ connectingStartNanos = 0;
+ }
+ if (isActive
+ && newState != ConnectivityState.READY
+ && currentState == ConnectivityState.READY) {
+ decReadyChannels(true);
+ }
+ if (newState == ConnectivityState.CONNECTING
+ && currentState != ConnectivityState.CONNECTING) {
+ connectingStartNanos = System.nanoTime();
+ }
+ if (newState != ConnectivityState.READY) {
+ connectedSinceNanos = 0;
+ }
+ currentState = newState;
+
+ processChannelStateChange(channelRef.getId(), newState);
+ if (isActive) {
+ executeStateChangeCallbacks();
+ }
+
+ // Resubscribe.
+ if (newState != ConnectivityState.SHUTDOWN) {
+ channel.notifyWhenStateChanged(newState, this);
+ }
+ }
+ }
+
+ private synchronized void executeStateChangeCallbacks() {
+ List callbacksToTrigger = stateChangeCallbacks;
+ stateChangeCallbacks = new LinkedList<>();
+ try {
+ callbacksToTrigger.forEach(stateNotificationExecutor::execute);
+ } catch (RejectedExecutionException e) {
+ // Ignore exceptions on shutdown.
+ logger.fine(log("State notification change task rejected: %s", e.getMessage()));
+ }
+ }
+
+ @VisibleForTesting
+ void processChannelStateChange(int channelId, ConnectivityState state) {
+ if (!fallbackEnabled) {
+ return;
+ }
+ if (state == ConnectivityState.READY || state == ConnectivityState.IDLE) {
+ // Ready
+ fallbackMap.remove(channelId);
+ return;
+ }
+ // Not ready
+ fallbackMap.putIfAbsent(channelId, new ConcurrentHashMap<>());
+ }
+
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ public int getMinSize() {
+ return minSize;
+ }
+
+ public int getNumberOfChannels() {
+ return channelRefs.size();
+ }
+
+ public int getStreamsLowWatermark() {
+ return maxConcurrentStreamsLowWatermark;
+ }
+
+ public int getMinActiveStreams() {
+ return channelRefs.stream().mapToInt(ChannelRef::getActiveStreamsCount).min().orElse(0);
+ }
+
+ public int getMaxActiveStreams() {
+ return channelRefs.stream().mapToInt(ChannelRef::getActiveStreamsCount).max().orElse(0);
+ }
+
+ /**
+ * Returns a {@link ChannelRef} from the pool for a binding call. If round-robin on bind is
+ * enabled, uses {@link #getChannelRefRoundRobin()} otherwise {@link #getChannelRef(String)}
+ *
+ * @return {@link ChannelRef} channel to use for a call.
+ */
+ protected ChannelRef getChannelRefForBind() {
+ ChannelRef channelRef;
+ if (options.getChannelPoolOptions() != null
+ && options.getChannelPoolOptions().isUseRoundRobinOnBind()) {
+ channelRef = getChannelRefRoundRobin();
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(
+ log("Channel %d picked for bind operation using round-robin.", channelRef.getId()));
+ }
+ } else {
+ channelRef = getChannelRef(null);
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Channel %d picked for bind operation.", channelRef.getId()));
+ }
+ }
+ return channelRef;
+ }
+
+ /**
+ * Returns a {@link ChannelRef} from the pool in round-robin manner. Creates a new channel in the
+ * pool until the pool reaches its max size.
+ *
+ * @return {@link ChannelRef}
+ */
+ protected synchronized ChannelRef getChannelRefRoundRobin() {
+ if (!isDynamicScalingEnabled && channelRefs.size() < maxSize) {
+ return createNewChannel();
+ }
+ maybeDynamicUpscale();
+ bindingIndex++;
+ if (bindingIndex >= channelRefs.size()) {
+ bindingIndex = 0;
+ }
+ return channelRefs.get(bindingIndex);
+ }
+
+ /**
+ * Pick a {@link ChannelRef} (and create a new one if necessary). If notReadyFallbackEnabled is
+ * true in the {@link GcpResiliencyOptions} then instead of a channel in a non-READY state another
+ * channel in the READY state and having fewer than maximum allowed number of active streams will
+ * be provided if available. Subsequent calls with the same affinity key will provide the same
+ * fallback channel as long as the fallback channel is in the READY state.
+ *
+ * @param key affinity key. If it is specified, pick the ChannelRef bound with the affinity key.
+ * Otherwise pick the one with the smallest number of streams.
+ */
+ protected ChannelRef getChannelRef(@Nullable String key) {
+ maybeDynamicUpscale();
+ if (key == null || key.isEmpty()) {
+ return pickLeastBusyChannel(/* forFallback= */ false);
+ }
+ ChannelRef mappedChannel = affinityKeyToChannelRef.get(key);
+ affinityKeyLastUsed.put(key, System.nanoTime());
+ if (mappedChannel == null) {
+ ChannelRef channelRef = pickLeastBusyChannel(/* forFallback= */ false);
+ bind(channelRef, Collections.singletonList(key));
+ return channelRef;
+ }
+ if (!fallbackEnabled) {
+ return mappedChannel;
+ }
+ // Look up if the channelRef is not ready.
+ Map tempMap = fallbackMap.get(mappedChannel.getId());
+ if (tempMap == null) {
+ // Channel is ready.
+ return mappedChannel;
+ }
+ // Channel is not ready. Look up if the affinity key mapped to another channel.
+ Integer channelId = tempMap.get(key);
+ if (channelId != null && !fallbackMap.containsKey(channelId)) {
+ // Fallback channel is ready.
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Using fallback channel: %d -> %d", mappedChannel.getId(), channelId));
+ }
+ fallbacksSucceeded.incrementAndGet();
+ return channelRefs.get(channelId);
+ }
+ // No temp mapping for this key or fallback channel is also broken.
+ ChannelRef channelRef = pickLeastBusyChannel(/* forFallback= */ true);
+ if (!fallbackMap.containsKey(channelRef.getId())
+ && channelRef.getActiveStreamsCount() < DEFAULT_MAX_STREAM) {
+ // Got a ready and not an overloaded channel.
+ if (channelRef.getId() != mappedChannel.getId()) {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(
+ log("Setting fallback channel: %d -> %d", mappedChannel.getId(), channelRef.getId()));
+ }
+ fallbacksSucceeded.incrementAndGet();
+ tempMap.put(key, channelRef.getId());
+ }
+ return channelRef;
+ }
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Failed to find fallback for channel %d", mappedChannel.getId()));
+ }
+ fallbacksFailed.incrementAndGet();
+ if (channelId != null) {
+ // Stick with previous mapping if fallback has failed.
+ return channelRefs.get(channelId);
+ }
+ return mappedChannel;
+ }
+
+ // Create a new channel and add it to channelRefs.
+ // If we have a ready channel not in the pool that we wait for completing its RPCs,
+ // then re-use that channel instead.
+ @VisibleForTesting
+ ChannelRef createNewChannel() {
+ Optional reusedChannelRef = pickChannelForReuse();
+ if (reusedChannelRef.isPresent()) {
+ ChannelRef chRef = reusedChannelRef.get();
+ channelRefs.add(chRef);
+ removedChannelRefs.remove(chRef);
+ logger.finer(log("Channel %d reused.", chRef.getId()));
+ incReadyChannels(false);
+ maxChannels.accumulateAndGet(getNumberOfChannels(), Math::max);
+ return chRef;
+ }
+
+ ChannelRef channelRef = new ChannelRef(delegateChannelBuilder.build());
+ channelRefs.add(channelRef);
+ logger.finer(log("Channel %d created.", channelRef.getId()));
+ maxChannels.accumulateAndGet(getNumberOfChannels(), Math::max);
+ return channelRef;
+ }
+
+ private Optional pickChannelForReuse() {
+ // Pick the most recently connected, if any.
+ Optional chRef =
+ removedChannelRefs.stream().max(Comparator.comparing(ChannelRef::getConnectedSinceNanos));
+
+ // Make sure it is ready, because connectedSinceNanos may be 0.
+ if (chRef.isPresent() && chRef.get().getState() != ConnectivityState.READY) {
+ return Optional.empty();
+ }
+
+ return chRef;
+ }
+
+ // Returns first newly created channel or null if there are already some channels in the pool.
+ @Nullable
+ private ChannelRef createFirstChannel() {
+ if (!channelRefs.isEmpty()) {
+ return null;
+ }
+ synchronized (this) {
+ if (channelRefs.isEmpty()) {
+ return createNewChannel();
+ }
+ }
+ return null;
+ }
+
+ // Creates new channel if maxSize is not reached.
+ // Returns new channel or null.
+ @Nullable
+ private ChannelRef tryCreateNewChannel() {
+ if (channelRefs.size() >= maxSize) {
+ return null;
+ }
+ synchronized (this) {
+ if (channelRefs.size() < maxSize) {
+ return createNewChannel();
+ }
+ }
+ return null;
+ }
+
+ private void maybeDynamicUpscale() {
+ if (!isDynamicScalingEnabled || channelRefs.size() >= maxSize) {
+ return;
+ }
+
+ if ((totalActiveStreams.get() / channelRefs.size()) >= maxRpcPerChannel) {
+ dynamicUpscale();
+ }
+ }
+
+ private synchronized void dynamicUpscale() {
+ if (!isDynamicScalingEnabled || channelRefs.size() >= maxSize) {
+ return;
+ }
+
+ if ((totalActiveStreams.get() / channelRefs.size()) >= maxRpcPerChannel) {
+ createNewChannel();
+ scaleUpCount.incrementAndGet();
+ }
+ }
+
+ // This is pre-dynamic scaling functionality where we only scale up when the minimum number of
+ // streams on any channel reached maxConcurrentStreamsLowWatermark.
+ // If dynamic scaling is enabled we do not use this logic.
+ private boolean shouldScaleUp(int minStreams) {
+ if (channelRefs.size() >= maxSize) {
+ // Pool is full.
+ return false;
+ }
+
+ return !isDynamicScalingEnabled && minStreams >= maxConcurrentStreamsLowWatermark;
+ }
+
+ /**
+ * Pick a {@link ChannelRef} (and create a new one if necessary). If notReadyFallbackEnabled is
+ * true in the {@link GcpResiliencyOptions} then instead of a channel in a non-READY state another
+ * channel in the READY state and having fewer than maximum allowed number of active streams will
+ * be provided if available.
+ */
+ private ChannelRef pickLeastBusyChannel(boolean forFallback) {
+ ChannelRef first = createFirstChannel();
+ if (first != null) {
+ return first;
+ }
+
+ if (!fallbackEnabled) {
+ return pickLeastBusyNoFallback();
+ }
+
+ return pickLeastBusyWithFallback(forFallback);
+ }
+
+ /**
+ * Non-fallback channel selection. Uses the configured {@link
+ * GcpManagedChannelOptions.ChannelPickStrategy}.
+ */
+ private ChannelRef pickLeastBusyNoFallback() {
+ ChannelRef channelCandidate;
+ int minStreams;
+
+ if (channelPickStrategy == GcpManagedChannelOptions.ChannelPickStrategy.POWER_OF_TWO) {
+ channelCandidate = pickFromCandidates(channelRefs);
+ // With power-of-two, streams distribute approximately (not exactly) evenly.
+ // Use max streams for scale-up: if ANY channel hits the watermark, it's overloaded now
+ // and we should add capacity before other channels follow. This preserves the original
+ // per-channel watermark semantics (with LINEAR_SCAN, min == max so it didn't matter).
+ // Global min would delay scale-up; sampled min would be noisy.
+ minStreams = getMaxActiveStreams();
+ } else {
+ channelCandidate = channelRefs.get(0);
+ minStreams = channelCandidate.getActiveStreamsCount();
+ for (ChannelRef channelRef : channelRefs) {
+ int cnt = channelRef.getActiveStreamsCount();
+ if (cnt < minStreams) {
+ minStreams = cnt;
+ channelCandidate = channelRef;
+ }
+ }
+ }
+
+ if (shouldScaleUp(minStreams)) {
+ ChannelRef newChannel = tryCreateNewChannel();
+ if (newChannel != null) {
+ scaleUpCount.incrementAndGet();
+ return newChannel;
+ }
+ }
+ return channelCandidate;
+ }
+
+ /**
+ * Fallback-enabled channel selection. Always uses a full linear scan because the fallback logic
+ * needs to filter channels by readiness state and max stream limits.
+ */
+ private ChannelRef pickLeastBusyWithFallback(boolean forFallback) {
+ // Full scan to collect eligible ("ready") channels not in fallbackMap and under max streams.
+ List readyCandidates = new ArrayList<>();
+ ChannelRef overallCandidate = channelRefs.get(0);
+ int overallMinStreams = overallCandidate.getActiveStreamsCount();
+ int readyMaxStreams = 0;
+
+ for (ChannelRef channelRef : channelRefs) {
+ int cnt = channelRef.getActiveStreamsCount();
+ if (cnt < overallMinStreams) {
+ overallMinStreams = cnt;
+ overallCandidate = channelRef;
+ }
+ if (!fallbackMap.containsKey(channelRef.getId()) && cnt < DEFAULT_MAX_STREAM) {
+ readyCandidates.add(channelRef);
+ if (cnt > readyMaxStreams) {
+ readyMaxStreams = cnt;
+ }
+ }
+ }
+
+ // For scale-up, use maxStreams among ready channels (consistent with non-fallback path).
+ int scaleUpStreams = readyCandidates.isEmpty() ? Integer.MAX_VALUE : readyMaxStreams;
+ if (shouldScaleUp(scaleUpStreams)) {
+ ChannelRef newChannel = tryCreateNewChannel();
+ if (newChannel != null) {
+ scaleUpCount.incrementAndGet();
+ if (!forFallback && readyCandidates.isEmpty()) {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Fallback to newly created channel %d", newChannel.getId()));
+ }
+ fallbacksSucceeded.incrementAndGet();
+ }
+ return newChannel;
+ }
+ }
+
+ if (!readyCandidates.isEmpty()) {
+ // Apply power-of-two among eligible channels to avoid thundering herd.
+ ChannelRef readyCandidate = pickFromCandidates(readyCandidates);
+ if (!forFallback && readyCandidate.getId() != overallCandidate.getId()) {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(
+ log(
+ "Picking fallback channel: %d -> %d",
+ overallCandidate.getId(), readyCandidate.getId()));
+ }
+ fallbacksSucceeded.incrementAndGet();
+ }
+ return readyCandidate;
+ }
+
+ if (!forFallback) {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Failed to find fallback for channel %d", overallCandidate.getId()));
+ }
+ fallbacksFailed.incrementAndGet();
+ }
+ return overallCandidate;
+ }
+
+ /**
+ * Picks a channel from the given candidate list using the configured strategy.
+ *
+ * For {@code POWER_OF_TWO}: samples two distinct random candidates and picks the less busy
+ * one. On tie, prefers the channel with more recent activity (warmer) to preserve connection
+ * warmth under low traffic.
+ *
+ *
For {@code LINEAR_SCAN}: deterministic scan picking the first least-busy channel.
+ */
+ private ChannelRef pickFromCandidates(List candidates) {
+ if (candidates.size() == 1) {
+ return candidates.get(0);
+ }
+ if (channelPickStrategy == GcpManagedChannelOptions.ChannelPickStrategy.POWER_OF_TWO) {
+ ThreadLocalRandom random = ThreadLocalRandom.current();
+ int i = random.nextInt(candidates.size());
+ int j = random.nextInt(candidates.size() - 1);
+ if (j >= i) {
+ j++;
+ }
+ ChannelRef a = candidates.get(i);
+ ChannelRef b = candidates.get(j);
+ int aStreams = a.getActiveStreamsCount();
+ int bStreams = b.getActiveStreamsCount();
+ if (aStreams < bStreams) return a;
+ if (bStreams < aStreams) return b;
+ // Tie: prefer the warmer channel (more recent activity).
+ return a.lastResponseNanos >= b.lastResponseNanos ? a : b;
+ }
+ // LINEAR_SCAN: pick the least busy.
+ ChannelRef best = candidates.get(0);
+ int bestStreams = best.getActiveStreamsCount();
+ for (int k = 1; k < candidates.size(); k++) {
+ int cnt = candidates.get(k).getActiveStreamsCount();
+ if (cnt < bestStreams) {
+ bestStreams = cnt;
+ best = candidates.get(k);
+ }
+ }
+ return best;
+ }
+
+ @Override
+ public String authority() {
+ if (!channelRefs.isEmpty()) {
+ return channelRefs.get(0).getChannel().authority();
+ }
+ final ManagedChannel channel = delegateChannelBuilder.build();
+ final String authority = channel.authority();
+ channel.shutdownNow();
+ return authority;
+ }
+
+ /**
+ * Manage the channelpool using GcpClientCall().
+ *
+ * If method-affinity is specified, we will use the GcpClientCall to fetch the affinitykey and
+ * bind/unbind the channel, otherwise we just need the SimpleGcpClientCall to keep track of the
+ * number of streams in each channel.
+ */
+ @Override
+ public ClientCall newCall(
+ MethodDescriptor methodDescriptor, CallOptions callOptions) {
+ if (callOptions.getOption(DISABLE_AFFINITY_KEY)
+ || DISABLE_AFFINITY_CTX_KEY.get(Context.current())) {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Channel affinity is disabled via context or call options."));
+ }
+ return new GcpClientCall.SimpleGcpClientCall<>(
+ this, getChannelRef(null), methodDescriptor, callOptions);
+ }
+
+ AffinityConfig affinity = methodToAffinity.get(methodDescriptor.getFullMethodName());
+ String key = keyFromOptsCtx(callOptions);
+ if (affinity != null && key == null) {
+ return new GcpClientCall<>(this, methodDescriptor, callOptions, affinity);
+ }
+
+ return new GcpClientCall.SimpleGcpClientCall<>(
+ this, getChannelRef(key), methodDescriptor, callOptions);
+ }
+
+ @Nullable
+ private String keyFromOptsCtx(CallOptions callOptions) {
+ String key = callOptions.getOption(AFFINITY_KEY);
+ if (key != null) {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Affinity key \"%s\" set manually via call options.", key));
+ }
+ return key;
+ }
+
+ key = AFFINITY_CTX_KEY.get(Context.current());
+ if (key != null && logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Affinity key \"%s\" set manually via context.", key));
+ }
+ return key;
+ }
+
+ private synchronized void cancelBackgroundTasks() {
+ if (cleanupTask != null) {
+ cleanupTask.cancel(false);
+ cleanupTask = null;
+ }
+ if (scaleDownTask != null) {
+ scaleDownTask.cancel(false);
+ scaleDownTask = null;
+ }
+ if (logMetricsTask != null) {
+ logMetricsTask.cancel(false);
+ logMetricsTask = null;
+ }
+ }
+
+ @Override
+ public ManagedChannel shutdownNow() {
+ logger.finer(log("Shutdown now started."));
+ for (ChannelRef channelRef : channelRefs) {
+ if (!channelRef.getChannel().isTerminated()) {
+ channelRef.getChannel().shutdownNow();
+ }
+ }
+ for (ChannelRef channelRef : removedChannelRefs) {
+ if (!channelRef.getChannel().isTerminated()) {
+ channelRef.getChannel().shutdownNow();
+ }
+ }
+ cancelBackgroundTasks();
+ if (!stateNotificationExecutor.isTerminated()) {
+ stateNotificationExecutor.shutdownNow();
+ }
+ return this;
+ }
+
+ @Override
+ public ManagedChannel shutdown() {
+ logger.finer(log("Shutdown started."));
+ for (ChannelRef channelRef : channelRefs) {
+ channelRef.getChannel().shutdown();
+ }
+ for (ChannelRef channelRef : removedChannelRefs) {
+ channelRef.getChannel().shutdown();
+ }
+ cancelBackgroundTasks();
+ stateNotificationExecutor.shutdown();
+ return this;
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ long endTimeNanos = System.nanoTime() + unit.toNanos(timeout);
+ List allChannelRefs = new ArrayList<>(channelRefs);
+ allChannelRefs.addAll(removedChannelRefs);
+ for (ChannelRef channelRef : allChannelRefs) {
+ if (channelRef.getChannel().isTerminated()) {
+ continue;
+ }
+ long awaitTimeNanos = endTimeNanos - System.nanoTime();
+ if (awaitTimeNanos <= 0) {
+ break;
+ }
+ channelRef.getChannel().awaitTermination(awaitTimeNanos, NANOSECONDS);
+ }
+ long awaitTimeNanos = endTimeNanos - System.nanoTime();
+ awaitTimeNanos = endTimeNanos - System.nanoTime();
+ if (awaitTimeNanos > 0) {
+ // noinspection ResultOfMethodCallIgnored
+ stateNotificationExecutor.awaitTermination(awaitTimeNanos, NANOSECONDS);
+ }
+ return isTerminated();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ List allChannelRefs = new ArrayList<>(channelRefs);
+ allChannelRefs.addAll(removedChannelRefs);
+ for (ChannelRef channelRef : allChannelRefs) {
+ if (!channelRef.getChannel().isShutdown()) {
+ return false;
+ }
+ }
+ return cleanupTask == null
+ && scaleDownTask == null
+ && logMetricsTask == null
+ && stateNotificationExecutor.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ List allChannelRefs = new ArrayList<>(channelRefs);
+ allChannelRefs.addAll(removedChannelRefs);
+ for (ChannelRef channelRef : allChannelRefs) {
+ if (!channelRef.getChannel().isTerminated()) {
+ return false;
+ }
+ }
+ return cleanupTask == null
+ && scaleDownTask == null
+ && logMetricsTask == null
+ && stateNotificationExecutor.isTerminated();
+ }
+
+ /** Get the current connectivity state of the channel pool. */
+ @Override
+ public ConnectivityState getState(boolean requestConnection) {
+ if (requestConnection && getNumberOfChannels() == 0) {
+ createFirstChannel();
+ }
+ int ready = 0;
+ int idle = 0;
+ int connecting = 0;
+ int transientFailure = 0;
+ int shutdown = 0;
+ for (ChannelRef channelRef : channelRefs) {
+ ConnectivityState cur = channelRef.getChannel().getState(requestConnection);
+ switch (cur) {
+ case READY:
+ ready++;
+ break;
+ case SHUTDOWN:
+ shutdown++;
+ break;
+ case TRANSIENT_FAILURE:
+ transientFailure++;
+ break;
+ case CONNECTING:
+ connecting++;
+ break;
+ case IDLE:
+ idle++;
+ break;
+ }
+ }
+
+ if (ready > 0) {
+ return ConnectivityState.READY;
+ } else if (connecting > 0) {
+ return ConnectivityState.CONNECTING;
+ } else if (transientFailure > 0) {
+ return ConnectivityState.TRANSIENT_FAILURE;
+ } else if (idle > 0) {
+ return ConnectivityState.IDLE;
+ } else if (shutdown > 0) {
+ return ConnectivityState.SHUTDOWN;
+ }
+ // When no channels are created yet it is also IDLE.
+ return ConnectivityState.IDLE;
+ }
+
+ /**
+ * Bind channel with affinity key.
+ *
+ * One channel can be mapped to more than one keys. But one key can only be mapped to one
+ * channel.
+ */
+ protected void bind(ChannelRef channelRef, List affinityKeys) {
+ if (channelRef == null || affinityKeys == null) {
+ return;
+ }
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(
+ log(
+ "Binding %d key(s) to channel %d: [%s]",
+ affinityKeys.size(), channelRef.getId(), String.join(", ", affinityKeys)));
+ }
+ for (String affinityKey : affinityKeys) {
+ while (affinityKeyToChannelRef.putIfAbsent(affinityKey, channelRef) != null) {
+ unbind(Collections.singletonList(affinityKey));
+ }
+ affinityKeyLastUsed.put(affinityKey, System.nanoTime());
+ channelRef.affinityCountIncr();
+ }
+ }
+
+ /** Unbind channel with affinity key. */
+ protected void unbind(List affinityKeys) {
+ if (affinityKeys == null) {
+ return;
+ }
+ for (String affinityKey : affinityKeys) {
+ ChannelRef channelRef = affinityKeyToChannelRef.remove(affinityKey);
+ affinityKeyLastUsed.remove(affinityKey);
+ if (channelRef != null) {
+ channelRef.affinityCountDecr();
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Unbinding key %s from channel %d.", affinityKey, channelRef.getId()));
+ }
+ } else {
+ if (logger.isLoggable(Level.FINEST)) {
+ logger.finest(log("Unbinding key %s but it wasn't bound.", affinityKey));
+ }
+ }
+ }
+ }
+
+ /** Load parameters from ApiConfig. */
+ private void loadApiConfig(ApiConfig apiConfig) {
+ if (apiConfig == null) {
+ return;
+ }
+ // Get the channelPool parameters
+ if (apiConfig.getChannelPool().getMaxSize() > 0) {
+ maxSize = apiConfig.getChannelPool().getMaxSize();
+ }
+ final int lowWatermark = apiConfig.getChannelPool().getMaxConcurrentStreamsLowWatermark();
+ if (lowWatermark >= 0 && lowWatermark <= DEFAULT_MAX_STREAM) {
+ this.maxConcurrentStreamsLowWatermark = lowWatermark;
+ }
+ // Get method parameters.
+ for (MethodConfig method : apiConfig.getMethodList()) {
+ if (method.getAffinity().equals(AffinityConfig.getDefaultInstance())) {
+ continue;
+ }
+ for (String methodName : method.getNameList()) {
+ methodToAffinity.put(methodName, method.getAffinity());
+ }
+ }
+ }
+
+ /**
+ * Get the affinity key from the request message.
+ *
+ * The message can be written in the format of:
+ *
+ *
session1: "the-key-we-want" \n transaction_id: "not-useful" \n transaction { \n session2:
+ * "another session"} \n}
+ *
+ *
If the (affinity) name is "session1", it will return "the-key-we-want".
+ *
+ *
If you want to get the key "another session" in the nested message, the name should be
+ * "session1.session2".
+ */
+ @VisibleForTesting
+ static List getKeysFromMessage(MessageOrBuilder msg, String name) {
+ // The field names in a nested message name are splitted by '.'.
+ int currentLength = name.indexOf('.');
+ String currentName = name;
+ if (currentLength != -1) {
+ currentName = name.substring(0, currentLength);
+ }
+
+ List keys = new ArrayList<>();
+ Map obs = msg.getAllFields();
+ for (Map.Entry entry : obs.entrySet()) {
+ if (entry.getKey().getName().equals(currentName)) {
+ if (currentLength == -1 && entry.getValue() instanceof String) {
+ // Value of the current field.
+ keys.add(entry.getValue().toString());
+ } else if (currentLength != -1 && entry.getValue() instanceof MessageOrBuilder) {
+ // One nested MessageOrBuilder.
+ keys.addAll(
+ getKeysFromMessage(
+ (MessageOrBuilder) entry.getValue(), name.substring(currentLength + 1)));
+ } else if (currentLength != -1 && entry.getValue() instanceof List) {
+ // Repeated nested MessageOrBuilder.
+ List> list = (List>) entry.getValue();
+ if (!list.isEmpty() && list.get(0) instanceof MessageOrBuilder) {
+ for (Object item : list) {
+ keys.addAll(
+ getKeysFromMessage((MessageOrBuilder) item, name.substring(currentLength + 1)));
+ }
+ }
+ }
+ }
+ }
+ return keys;
+ }
+
+ /**
+ * Fetch the affinity key from the message.
+ *
+ * @param message the <ReqT> or <RespT> prototype message.
+ * @param isReq indicates if the message is a request message.
+ */
+ @Nullable
+ protected List checkKeys(
+ Object message, boolean isReq, MethodDescriptor methodDescriptor) {
+ if (!(message instanceof MessageOrBuilder)) {
+ return null;
+ }
+
+ AffinityConfig affinity = methodToAffinity.get(methodDescriptor.getFullMethodName());
+ if (affinity != null) {
+ AffinityConfig.Command cmd = affinity.getCommand();
+ String keyName = affinity.getAffinityKey();
+ List keys = getKeysFromMessage((MessageOrBuilder) message, keyName);
+ if (isReq && (cmd == AffinityConfig.Command.UNBIND || cmd == AffinityConfig.Command.BOUND)) {
+ if (keys.size() > 1) {
+ throw new IllegalStateException("Duplicate affinity key in the request message");
+ }
+ return keys;
+ }
+ if (!isReq && cmd == AffinityConfig.Command.BIND) {
+ return keys;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * A wrapper of real grpc channel, it provides helper functions to calculate affinity counts and
+ * active streams count.
+ */
+ protected class ChannelRef {
+
+ private final ManagedChannel delegate;
+ private final int channelId;
+ private final AtomicInteger affinityCount;
+ // activeStreamsCount are mutated from the GcpClientCall concurrently using the
+ // `activeStreamsCountIncr()` and `activeStreamsCountDecr()` methods.
+ private final AtomicInteger activeStreamsCount;
+ private long lastResponseNanos = nanoClock.get();
+ private final AtomicInteger deadlineExceededCount = new AtomicInteger();
+ private final AtomicLong okCalls = new AtomicLong();
+ private final AtomicLong errCalls = new AtomicLong();
+ private final ChannelStateMonitor channelStateMonitor;
+
+ protected ChannelRef(ManagedChannel channel) {
+ this(channel, 0, 0);
+ }
+
+ protected ChannelRef(ManagedChannel channel, int affinityCount, int activeStreamsCount) {
+ this.delegate = channel;
+ this.channelId = nextChannelId.getAndIncrement();
+ this.affinityCount = new AtomicInteger(affinityCount);
+ this.activeStreamsCount = new AtomicInteger(activeStreamsCount);
+ channelStateMonitor = new ChannelStateMonitor(channel, this);
+ }
+
+ protected long getConnectedSinceNanos() {
+ return channelStateMonitor.getConnectedSinceNanos();
+ }
+
+ protected ConnectivityState getState() {
+ return channelStateMonitor.getCurrentState();
+ }
+
+ protected ManagedChannel getChannel() {
+ return delegate;
+ }
+
+ protected int getId() {
+ return channelId;
+ }
+
+ protected void affinityCountIncr() {
+ int count = affinityCount.incrementAndGet();
+ maxAffinity.accumulateAndGet(count, Math::max);
+ totalAffinityCount.incrementAndGet();
+ }
+
+ protected void affinityCountDecr() {
+ int count = affinityCount.decrementAndGet();
+ minAffinity.accumulateAndGet(count, Math::min);
+ totalAffinityCount.decrementAndGet();
+ }
+
+ protected void resetAffinityCount() {
+ affinityCount.set(0);
+ }
+
+ protected void activeStreamsCountIncr() {
+ int actStreams = activeStreamsCount.incrementAndGet();
+ maxActiveStreams.accumulateAndGet(actStreams, Math::max);
+ int totalActStreams = totalActiveStreams.incrementAndGet();
+ maxTotalActiveStreams.accumulateAndGet(totalActStreams, Math::max);
+ maxTotalActiveStreamsForScaleDown.accumulateAndGet(totalActStreams, Math::max);
+ }
+
+ protected void activeStreamsCountDecr(long startNanos, Status status, boolean fromClientSide) {
+ int actStreams = activeStreamsCount.decrementAndGet();
+ minActiveStreams.accumulateAndGet(actStreams, Math::min);
+ int totalActStreams = totalActiveStreams.decrementAndGet();
+ minTotalActiveStreams.accumulateAndGet(totalActStreams, Math::min);
+ if (status.isOk()) {
+ okCalls.incrementAndGet();
+ totalOkCalls.incrementAndGet();
+ } else {
+ errCalls.incrementAndGet();
+ totalErrCalls.incrementAndGet();
+ }
+ if (unresponsiveDetectionEnabled) {
+ detectUnresponsiveConnection(startNanos, status, fromClientSide);
+ }
+ }
+
+ protected void messageReceived() {
+ lastResponseNanos = nanoClock.get();
+ deadlineExceededCount.set(0);
+ }
+
+ protected int getAffinityCount() {
+ return affinityCount.get();
+ }
+
+ protected int getActiveStreamsCount() {
+ return activeStreamsCount.get();
+ }
+
+ protected long getAndResetOkCalls() {
+ return okCalls.getAndSet(0);
+ }
+
+ protected long getAndResetErrCalls() {
+ return errCalls.getAndSet(0);
+ }
+
+ private void detectUnresponsiveConnection(
+ long startNanos, Status status, boolean fromClientSide) {
+ if (status.getCode().equals(Code.DEADLINE_EXCEEDED)) {
+ if (startNanos < lastResponseNanos) {
+ // Skip deadline exceeded from past calls.
+ return;
+ }
+ if (deadlineExceededCount.incrementAndGet() >= unresponsiveDropCount
+ && msSinceLastResponse() >= unresponsiveMs) {
+ maybeReconnectUnresponsive();
+ }
+ return;
+ }
+ if (!fromClientSide) {
+ // If not a deadline exceeded and not coming from the client side then reset time and count.
+ lastResponseNanos = nanoClock.get();
+ deadlineExceededCount.set(0);
+ }
+ }
+
+ private long msSinceLastResponse() {
+ return (nanoClock.get() - lastResponseNanos) / 1000000;
+ }
+
+ private synchronized void maybeReconnectUnresponsive() {
+ final long msSinceLastResponse = msSinceLastResponse();
+ if (deadlineExceededCount.get() >= unresponsiveDropCount
+ && msSinceLastResponse >= unresponsiveMs) {
+ recordUnresponsiveDetection(
+ nanoClock.get() - lastResponseNanos, deadlineExceededCount.get());
+ logger.finer(
+ log(
+ "Channel %d connection is unresponsive for %d ms and %d deadline exceeded calls. "
+ + "Forcing channel to idle state.",
+ channelId, msSinceLastResponse, deadlineExceededCount.get()));
+ delegate.enterIdle();
+ lastResponseNanos = nanoClock.get();
+ deadlineExceededCount.set(0);
+ }
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannelBuilder.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannelBuilder.java
new file mode 100644
index 000000000000..9c7e97b5118f
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannelBuilder.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.cloud.grpc.proto.ApiConfig;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.protobuf.util.JsonFormat;
+import io.grpc.ForwardingChannelBuilder;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import java.io.File;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.file.Files;
+import java.util.logging.Logger;
+import javax.annotation.Nullable;
+
+public class GcpManagedChannelBuilder extends ForwardingChannelBuilder {
+
+ private static final Logger logger = Logger.getLogger(GcpManagedChannelBuilder.class.getName());
+
+ private final ManagedChannelBuilder> delegate;
+ private int poolSize = 0;
+ private GcpManagedChannelOptions options;
+
+ @VisibleForTesting ApiConfig apiConfig;
+
+ private GcpManagedChannelBuilder(ManagedChannelBuilder> delegate) {
+ this.delegate = delegate;
+ this.options = new GcpManagedChannelOptions();
+ }
+
+ private ApiConfig parseConfigFromJsonFile(File file) {
+ JsonFormat.Parser parser = JsonFormat.parser();
+ ApiConfig.Builder apiConfig = ApiConfig.newBuilder();
+ try {
+ Reader reader = Files.newBufferedReader(file.toPath(), UTF_8);
+ parser.merge(reader, apiConfig);
+ } catch (IOException e) {
+ logger.severe(e.getMessage());
+ return null;
+ }
+ return apiConfig.build();
+ }
+
+ public static GcpManagedChannelBuilder forDelegateBuilder(ManagedChannelBuilder> delegate) {
+ return new GcpManagedChannelBuilder(delegate);
+ }
+
+ /**
+ * Sets the maximum channel pool size. This will override the pool size configuration in
+ * ApiConfig. Deprecated. Use maxSize in GcpManagedChannelOptions.GcpChannelPoolOptions.
+ */
+ @Deprecated
+ public GcpManagedChannelBuilder setPoolSize(int poolSize) {
+ this.poolSize = poolSize;
+ return this;
+ }
+
+ public GcpManagedChannelBuilder withApiConfig(ApiConfig apiConfig) {
+ this.apiConfig = apiConfig;
+ return this;
+ }
+
+ public GcpManagedChannelBuilder withApiConfigJsonFile(File file) {
+ this.apiConfig = parseConfigFromJsonFile(file);
+ return this;
+ }
+
+ public GcpManagedChannelBuilder withApiConfigJsonString(String jsonString) {
+ JsonFormat.Parser parser = JsonFormat.parser();
+ ApiConfig.Builder apiConfig = ApiConfig.newBuilder();
+ try {
+ parser.merge(jsonString, apiConfig);
+ } catch (IOException e) {
+ logger.severe(e.getMessage());
+ return null;
+ }
+ this.apiConfig = apiConfig.build();
+ return this;
+ }
+
+ public GcpManagedChannelBuilder withOptions(@Nullable GcpManagedChannelOptions options) {
+ if (options != null) {
+ this.options = options;
+ }
+ return this;
+ }
+
+ /** Returns the delegated {@code ManagedChannelBuilder}. */
+ @Override
+ protected ManagedChannelBuilder> delegate() {
+ return delegate;
+ }
+
+ /**
+ * Returns the {@link ManagedChannel} built by the delegate by default. Overriding method can
+ * return different value.
+ */
+ @Override
+ public ManagedChannel build() {
+ return new GcpManagedChannel(delegate, apiConfig, poolSize, options);
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannelOptions.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannelOptions.java
new file mode 100644
index 000000000000..94a180b6a6e7
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpManagedChannelOptions.java
@@ -0,0 +1,789 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import com.google.common.base.Preconditions;
+import io.opencensus.metrics.LabelKey;
+import io.opencensus.metrics.LabelValue;
+import io.opencensus.metrics.MetricRegistry;
+import io.opentelemetry.api.metrics.Meter;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.logging.Logger;
+import javax.annotation.Nullable;
+
+/** Options for the {@link GcpManagedChannel}. */
+public class GcpManagedChannelOptions {
+
+ /**
+ * Strategy for picking the least busy channel from the pool.
+ *
+ * This controls how a channel is selected when there is no affinity key or when a new affinity
+ * binding is being established.
+ */
+ public enum ChannelPickStrategy {
+ /**
+ * Scans all channels and picks the one with the fewest active streams. Ties are broken by
+ * iteration order (lowest index wins). This is the legacy behavior.
+ *
+ *
This strategy finds the global minimum but is susceptible to the thundering herd problem:
+ * under burst traffic, all concurrent callers observe the same minimum and pile onto the same
+ * channel.
+ */
+ LINEAR_SCAN,
+
+ /**
+ * Picks two channels at random and returns the one with fewer active streams. Ties are broken
+ * by preferring the more recently active channel (warmth-preserving).
+ *
+ *
This is the default strategy. It avoids the thundering herd problem while keeping warm
+ * channels preferred under low traffic. The trade-off is that it may not always find the global
+ * minimum, but in practice the difference is negligible because stream counts are inherently
+ * racy.
+ */
+ POWER_OF_TWO,
+ }
+
+ private static final Logger logger = Logger.getLogger(GcpManagedChannelOptions.class.getName());
+
+ @Nullable private final GcpChannelPoolOptions channelPoolOptions;
+ @Nullable private final GcpMetricsOptions metricsOptions;
+ @Nullable private final GcpResiliencyOptions resiliencyOptions;
+
+ public GcpManagedChannelOptions() {
+ channelPoolOptions = null;
+ metricsOptions = null;
+ resiliencyOptions = null;
+ }
+
+ public GcpManagedChannelOptions(Builder builder) {
+ channelPoolOptions = builder.channelPoolOptions;
+ metricsOptions = builder.metricsOptions;
+ resiliencyOptions = builder.resiliencyOptions;
+ }
+
+ @Nullable
+ public GcpChannelPoolOptions getChannelPoolOptions() {
+ return channelPoolOptions;
+ }
+
+ @Nullable
+ public GcpMetricsOptions getMetricsOptions() {
+ return metricsOptions;
+ }
+
+ @Nullable
+ public GcpResiliencyOptions getResiliencyOptions() {
+ return resiliencyOptions;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "{channelPoolOptions: %s, resiliencyOptions: %s, metricsOptions: %s}",
+ getChannelPoolOptions(), getResiliencyOptions(), getMetricsOptions());
+ }
+
+ /** Creates a new GcpManagedChannelOptions.Builder. */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /** Creates a new GcpManagedChannelOptions.Builder from GcpManagedChannelOptions. */
+ public static Builder newBuilder(GcpManagedChannelOptions options) {
+ return new Builder(options);
+ }
+
+ public static class Builder {
+ private GcpChannelPoolOptions channelPoolOptions;
+ private GcpMetricsOptions metricsOptions;
+ private GcpResiliencyOptions resiliencyOptions;
+
+ public Builder() {}
+
+ public Builder(GcpManagedChannelOptions options) {
+ this.channelPoolOptions = options.getChannelPoolOptions();
+ this.metricsOptions = options.getMetricsOptions();
+ this.resiliencyOptions = options.getResiliencyOptions();
+ }
+
+ public GcpManagedChannelOptions build() {
+ return new GcpManagedChannelOptions(this);
+ }
+
+ /**
+ * Sets the channel pool configuration for the {@link GcpManagedChannel}.
+ *
+ * @param channelPoolOptions a {@link GcpChannelPoolOptions} to use as a channel pool
+ * configuration.
+ */
+ public Builder withChannelPoolOptions(GcpChannelPoolOptions channelPoolOptions) {
+ this.channelPoolOptions = channelPoolOptions;
+ return this;
+ }
+
+ /**
+ * Sets the metrics configuration for the {@link GcpManagedChannel}.
+ *
+ *
If a {@link MetricRegistry} is provided in {@link GcpMetricsOptions} then the
+ * GcpManagedChannel will emit metrics using that registry. The metrics options also allow to
+ * set up labels (tags) and a prefix for metrics names. The GcpManagedChannel will add its own
+ * label "pool_index" with values "pool-0", "pool-1", etc. for each instance of
+ * GcpManagedChannel created.
+ *
+ *
Example usage (e. g. with export to Cloud Monitoring)
+ *
+ *
+ * // Enable Cloud Monitoring exporter.
+ * StackdriverStatsExporter.createAndRegister();
+ *
+ * // Configure metrics options.
+ * GcpMetricsOptions metricsOptions = GcpMetricsOptions.newBuilder(Metrics.getMetricRegistry())
+ * .withNamePrefix("myapp/gcp-pool/")
+ * .build());
+ *
+ * final GcpManagedChannel pool =
+ * (GcpManagedChannel)
+ * GcpManagedChannelBuilder.forDelegateBuilder(builder)
+ * .withOptions(
+ * GcpManagedChannelOptions.newBuilder()
+ * .withMetricsOptions(metricsOptions)
+ * .build())
+ * .build();
+ *
+ * // Use the pool that will emit metrics which will be exported to Cloud Monitoring.
+ *
+ *
+ * @param metricsOptions a {@link GcpMetricsOptions} to use as metrics configuration.
+ */
+ public Builder withMetricsOptions(GcpMetricsOptions metricsOptions) {
+ this.metricsOptions = metricsOptions;
+ return this;
+ }
+
+ /**
+ * Sets the resiliency configuration for the {@link GcpManagedChannel}.
+ *
+ * @param resiliencyOptions a {@link GcpResiliencyOptions} to use as resiliency configuration.
+ */
+ public Builder withResiliencyOptions(GcpResiliencyOptions resiliencyOptions) {
+ this.resiliencyOptions = resiliencyOptions;
+ return this;
+ }
+ }
+
+ /** Channel pool configuration for the GCP managed channel. */
+ public static class GcpChannelPoolOptions {
+ // The maximum number of channels in the pool.
+ private final int maxSize;
+ // The minimum size of the channel pool. This number of channels will be created and these
+ // channels will try to always keep connection to the server.
+ private final int minSize;
+ // If every channel in the pool has at least this amount of concurrent streams then a new
+ // channel will be created
+ // in the pool unless the pool reached its maximum size.
+ private final int concurrentStreamsLowWatermark;
+ // The number of channels to initialize the pool with.
+ // If it is less than minSize it is ignored.
+ private final int initSize;
+
+ // The following three options enable the dynamic scaling functionality
+ // if all of them are positive.
+
+ // Minimum desired average concurrent calls per channel.
+ private final int minRpcPerChannel;
+ // Maximim desired average concurrent calls per channel.
+ private final int maxRpcPerChannel;
+ // How often to check for a possibility to scale down.
+ private final Duration scaleDownInterval;
+
+ // Use round-robin channel selection for affinity binding calls.
+ private final boolean useRoundRobinOnBind;
+ // How long to keep an affinity key after its last use.
+ private final Duration affinityKeyLifetime;
+ // How frequently affinity key cleanup process runs.
+ private final Duration cleanupInterval;
+ // Strategy for picking the least busy channel.
+ private final ChannelPickStrategy channelPickStrategy;
+
+ public GcpChannelPoolOptions(Builder builder) {
+ maxSize = builder.maxSize;
+ minSize = builder.minSize;
+ initSize = builder.initSize;
+ minRpcPerChannel = builder.minRpcPerChannel;
+ maxRpcPerChannel = builder.maxRpcPerChannel;
+ scaleDownInterval = builder.scaleDownInterval;
+ concurrentStreamsLowWatermark = builder.concurrentStreamsLowWatermark;
+ useRoundRobinOnBind = builder.useRoundRobinOnBind;
+ affinityKeyLifetime = builder.affinityKeyLifetime;
+ cleanupInterval = builder.cleanupInterval;
+ channelPickStrategy = builder.channelPickStrategy;
+ }
+
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ public int getMinSize() {
+ return minSize;
+ }
+
+ public int getInitSize() {
+ return initSize;
+ }
+
+ public int getMinRpcPerChannel() {
+ return minRpcPerChannel;
+ }
+
+ public int getMaxRpcPerChannel() {
+ return maxRpcPerChannel;
+ }
+
+ public Duration getScaleDownInterval() {
+ return scaleDownInterval;
+ }
+
+ public int getConcurrentStreamsLowWatermark() {
+ return concurrentStreamsLowWatermark;
+ }
+
+ public boolean isUseRoundRobinOnBind() {
+ return useRoundRobinOnBind;
+ }
+
+ public Duration getAffinityKeyLifetime() {
+ return affinityKeyLifetime;
+ }
+
+ public Duration getCleanupInterval() {
+ return cleanupInterval;
+ }
+
+ public ChannelPickStrategy getChannelPickStrategy() {
+ return channelPickStrategy;
+ }
+
+ /** Creates a new GcpChannelPoolOptions.Builder. */
+ public static GcpChannelPoolOptions.Builder newBuilder() {
+ return new GcpChannelPoolOptions.Builder();
+ }
+
+ /** Creates a new GcpChannelPoolOptions.Builder from GcpChannelPoolOptions. */
+ public static GcpChannelPoolOptions.Builder newBuilder(GcpChannelPoolOptions options) {
+ return new GcpChannelPoolOptions.Builder(options);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "{maxSize: %d, minSize: %d, concurrentStreamsLowWatermark: %d, useRoundRobinOnBind: %s}",
+ getMaxSize(), getMinSize(), getConcurrentStreamsLowWatermark(), isUseRoundRobinOnBind());
+ }
+
+ public static class Builder {
+ private int maxSize = GcpManagedChannel.DEFAULT_MAX_CHANNEL;
+ private int minSize = 0;
+ private int initSize = 0;
+ private int minRpcPerChannel = 0;
+ private int maxRpcPerChannel = 0;
+ private Duration scaleDownInterval = Duration.ZERO;
+ private int concurrentStreamsLowWatermark = GcpManagedChannel.DEFAULT_MAX_STREAM;
+ private boolean useRoundRobinOnBind = false;
+ private Duration affinityKeyLifetime = Duration.ZERO;
+ private Duration cleanupInterval = Duration.ZERO;
+ private ChannelPickStrategy channelPickStrategy = ChannelPickStrategy.POWER_OF_TWO;
+
+ public Builder() {}
+
+ public Builder(GcpChannelPoolOptions options) {
+ this();
+ if (options == null) {
+ return;
+ }
+ this.maxSize = options.getMaxSize();
+ this.minSize = options.getMinSize();
+ this.initSize = options.getInitSize();
+ this.minRpcPerChannel = options.getMinRpcPerChannel();
+ this.maxRpcPerChannel = options.getMaxRpcPerChannel();
+ this.scaleDownInterval = options.getScaleDownInterval();
+ this.concurrentStreamsLowWatermark = options.getConcurrentStreamsLowWatermark();
+ this.useRoundRobinOnBind = options.isUseRoundRobinOnBind();
+ this.affinityKeyLifetime = options.getAffinityKeyLifetime();
+ this.cleanupInterval = options.getCleanupInterval();
+ this.channelPickStrategy = options.getChannelPickStrategy();
+ }
+
+ public GcpChannelPoolOptions build() {
+ return new GcpChannelPoolOptions(this);
+ }
+
+ /**
+ * Sets the maximum size of the channel pool.
+ *
+ * @param maxSize maximum number of channels the pool can have.
+ */
+ public Builder setMaxSize(int maxSize) {
+ Preconditions.checkArgument(maxSize > 0, "Channel pool size must be positive.");
+ this.maxSize = maxSize;
+ return this;
+ }
+
+ /**
+ * Sets the minimum size of the channel pool. This number of channels will be created and
+ * these channels will try to always keep connection to the server established.
+ *
+ * @param minSize minimum number of channels the pool must have.
+ */
+ public Builder setMinSize(int minSize) {
+ Preconditions.checkArgument(
+ minSize >= 0, "Channel pool minimum size must be 0 or positive.");
+ this.minSize = minSize;
+ return this;
+ }
+
+ /**
+ * Sets the initial channel pool size. This is the number of channels that the pool will start
+ * with. If it is less than {@link #setMinSize(int)} it is ignored.
+ *
+ * @param initSize number of channels to start the pool with.
+ * @return
+ */
+ public Builder setInitSize(int initSize) {
+ Preconditions.checkArgument(
+ initSize >= 0, "Channel pool initial size must be 0 or positive.");
+ this.initSize = initSize;
+ return this;
+ }
+
+ /**
+ * Enables dynamic scaling functionality.
+ *
+ * When the average number of concurrent calls per channel reaches maxRpcPerChannel
+ * the pool will create and add a new channel unless already at max size.
+ *
+ *
Every scaleDownInterval a check for downscaling is performed. Based on the
+ * maximum total concurrent calls observed since the last check, the desired number of
+ * channels is calculated as:
+ *
+ *
(max_total_concurrent_calls / minRpcPerChannel) rounded up.
+ *
+ *
If the calculated desired number of channels is lower than the current number of
+ * channels, the pool will be downscaled to the desired number or min size (whichever is
+ * greater).
+ *
+ *
When downscaling, channels with the oldest connections are selected. Then the selected
+ * channels are removed from the pool but are not instructed to shutdown until all calls are
+ * completed. In a case when the pool is scaling up and there is a ready channel awaiting
+ * calls completion, the channel will be re-used instead of creating a new channel.
+ *
+ * @param minRpcPerChannel minimum desired average concurrent calls per channel.
+ * @param maxRpcPerChannel maximum desired average concurrent calls per channel.
+ * @param scaleDownInterval how often to check for a possibility to scale down.
+ */
+ public Builder setDynamicScaling(
+ int minRpcPerChannel, int maxRpcPerChannel, Duration scaleDownInterval) {
+ Preconditions.checkArgument(
+ minRpcPerChannel > 0, "Minimum RPCs per channel must be positive.");
+ Preconditions.checkArgument(
+ maxRpcPerChannel > 0, "Maximum RPCs per channel must be positive.");
+ Preconditions.checkArgument(
+ !scaleDownInterval.isNegative() && !scaleDownInterval.isZero(),
+ "Scale down interval must be positive.");
+ this.minRpcPerChannel = minRpcPerChannel;
+ this.maxRpcPerChannel = maxRpcPerChannel;
+ this.scaleDownInterval = scaleDownInterval;
+ return this;
+ }
+
+ /**
+ * Disables dynamic scaling functionality.
+ *
+ * @see #setDynamicScaling(int, int, Duration)
+ */
+ public Builder disableDynamicScaling() {
+ this.minRpcPerChannel = 0;
+ this.maxRpcPerChannel = 0;
+ this.scaleDownInterval = Duration.ZERO;
+ return this;
+ }
+
+ /**
+ * Sets the concurrent streams low watermark. If every channel in the pool has at least this
+ * amount of concurrent streams then a new channel will be created in the pool unless the pool
+ * reached its maximum size.
+ *
+ * @param concurrentStreamsLowWatermark number of streams every channel must reach before
+ * adding a new channel to the pool.
+ */
+ public Builder setConcurrentStreamsLowWatermark(int concurrentStreamsLowWatermark) {
+ this.concurrentStreamsLowWatermark = concurrentStreamsLowWatermark;
+ return this;
+ }
+
+ /**
+ * Enables/disables using round-robin channel selection for affinity binding calls.
+ *
+ * @param enabled If true, use round-robin channel selection for affinity binding calls.
+ */
+ public Builder setUseRoundRobinOnBind(boolean enabled) {
+ this.useRoundRobinOnBind = enabled;
+ return this;
+ }
+
+ /**
+ * How long to keep an affinity key after its last use. Zero value means keeping keys forever.
+ *
+ * @param affinityKeyLifetime time since last use of a key to include the key in a cleanup.
+ */
+ public Builder setAffinityKeyLifetime(Duration affinityKeyLifetime) {
+ Preconditions.checkArgument(
+ !affinityKeyLifetime.isNegative(), "Affinity key lifetime may not be negative.");
+ this.affinityKeyLifetime = affinityKeyLifetime;
+ if (!affinityKeyLifetime.isZero() && this.cleanupInterval.isZero()) {
+ this.cleanupInterval = affinityKeyLifetime.dividedBy(10);
+ }
+ return this;
+ }
+
+ /**
+ * How frequently affinity key cleanup process should run. Zero value disables cleanup
+ * process. If affinityKeyLifetime is not zero, this defaults to affinityKeyLifetime / 10.
+ *
+ * @param cleanupInterval frequency of affinity key cleanup.
+ */
+ public Builder setCleanupInterval(Duration cleanupInterval) {
+ Preconditions.checkArgument(
+ !cleanupInterval.isNegative(), "Cleanup interval must not be negative.");
+ Preconditions.checkArgument(
+ !cleanupInterval.isZero() || this.affinityKeyLifetime.isZero(),
+ "Cleanup interval must not be zero when affinity key interval is above zero.");
+ this.cleanupInterval = cleanupInterval;
+ return this;
+ }
+
+ /**
+ * Sets the strategy for picking the least busy channel from the pool.
+ *
+ *
Defaults to {@link ChannelPickStrategy#POWER_OF_TWO} which avoids the thundering herd
+ * problem by randomly sampling two channels and picking the less busy one, with ties broken
+ * by channel warmth (most recently active).
+ *
+ *
Use {@link ChannelPickStrategy#LINEAR_SCAN} to restore the legacy behavior of scanning
+ * all channels and always picking the one with the fewest active streams.
+ *
+ * @param strategy the channel pick strategy to use.
+ */
+ public Builder setChannelPickStrategy(ChannelPickStrategy strategy) {
+ Preconditions.checkNotNull(strategy, "Channel pick strategy must not be null.");
+ this.channelPickStrategy = strategy;
+ return this;
+ }
+ }
+ }
+
+ /** Metrics configuration for the GCP managed channel. */
+ public static class GcpMetricsOptions {
+ private final MetricRegistry metricRegistry;
+ private final List labelKeys;
+ private final List labelValues;
+ private final String namePrefix;
+ @Nullable private final Meter otelMeter;
+ @Nullable private final List otelLabelKeys;
+ @Nullable private final List otelLabelValues;
+
+ public GcpMetricsOptions(Builder builder) {
+ metricRegistry = builder.metricRegistry;
+ labelKeys = builder.labelKeys;
+ labelValues = builder.labelValues;
+ namePrefix = builder.namePrefix;
+ otelMeter = builder.otelMeter;
+ otelLabelKeys = builder.otelLabelKeys;
+ otelLabelValues = builder.otelLabelValues;
+ }
+
+ public MetricRegistry getMetricRegistry() {
+ return metricRegistry;
+ }
+
+ public List getLabelKeys() {
+ return labelKeys;
+ }
+
+ public List getLabelValues() {
+ return labelValues;
+ }
+
+ public String getNamePrefix() {
+ return namePrefix;
+ }
+
+ @Nullable
+ public Meter getOpenTelemetryMeter() {
+ return otelMeter;
+ }
+
+ @Nullable
+ public List getOtelLabelKeys() {
+ return otelLabelKeys;
+ }
+
+ @Nullable
+ public List getOtelLabelValues() {
+ return otelLabelValues;
+ }
+
+ @Override
+ public String toString() {
+ Iterator keyIterator = getLabelKeys().iterator();
+ Iterator valueIterator = getLabelValues().iterator();
+
+ final List labels = new ArrayList<>();
+ while (keyIterator.hasNext() && valueIterator.hasNext()) {
+ labels.add(
+ String.format(
+ "%s: \"%s\"", keyIterator.next().getKey(), valueIterator.next().getValue()));
+ }
+ return String.format(
+ "{namePrefix: \"%s\", labels: [%s], metricRegistry: %s, otelMeter: %s}",
+ getNamePrefix(), String.join(", ", labels), getMetricRegistry(), getOpenTelemetryMeter());
+ }
+
+ /** Creates a new GcpMetricsOptions.Builder. */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /** Creates a new GcpMetricsOptions.Builder from GcpMetricsOptions. */
+ public static Builder newBuilder(GcpMetricsOptions options) {
+ return new Builder(options);
+ }
+
+ public static class Builder {
+ private MetricRegistry metricRegistry;
+ private List labelKeys;
+ private List labelValues;
+ private String namePrefix;
+ private Meter otelMeter;
+ private List otelLabelKeys;
+ private List otelLabelValues;
+
+ /** Constructor for GcpMetricsOptions.Builder. */
+ public Builder() {
+ labelKeys = new ArrayList<>();
+ labelValues = new ArrayList<>();
+ namePrefix = "";
+ otelLabelKeys = new ArrayList<>();
+ otelLabelValues = new ArrayList<>();
+ }
+
+ public Builder(GcpMetricsOptions options) {
+ this();
+ if (options == null) {
+ return;
+ }
+ this.metricRegistry = options.getMetricRegistry();
+ this.labelKeys = options.getLabelKeys();
+ this.labelValues = options.getLabelValues();
+ this.namePrefix = options.getNamePrefix();
+ this.otelMeter = options.getOpenTelemetryMeter();
+ this.otelLabelKeys = options.getOtelLabelKeys();
+ this.otelLabelValues = options.getOtelLabelValues();
+ }
+
+ public GcpMetricsOptions build() {
+ return new GcpMetricsOptions(this);
+ }
+
+ public Builder withMetricRegistry(MetricRegistry registry) {
+ this.metricRegistry = registry;
+ return this;
+ }
+
+ /**
+ * Sets label keys and values to report with the metrics. The size of keys and values lists
+ * must match. Otherwise the labels will not be applied.
+ *
+ * @param labelKeys a list of {@link LabelKey}.
+ * @param labelValues a list of {@link LabelValue}.
+ */
+ public Builder withLabels(List labelKeys, List labelValues) {
+ if (labelKeys == null || labelValues == null || labelKeys.size() != labelValues.size()) {
+ logger.warning("Unable to set label keys and values - size mismatch or null.");
+ return this;
+ }
+ this.labelKeys = labelKeys;
+ this.labelValues = labelValues;
+ return this;
+ }
+
+ /**
+ * Sets the prefix for all metric names reported by GcpManagedChannel.
+ *
+ * @param namePrefix the prefix for metrics names.
+ */
+ public Builder withNamePrefix(String namePrefix) {
+ this.namePrefix = namePrefix;
+ return this;
+ }
+
+ /**
+ * Sets the OpenTelemetry {@link Meter} to be used to emit metrics. If provided, metrics will
+ * be exported using OpenTelemetry APIs. If both MetricRegistry and Meter are null, metrics
+ * are disabled.
+ */
+ public Builder withOpenTelemetryMeter(Meter meter) {
+ this.otelMeter = meter;
+ return this;
+ }
+
+ /**
+ * Sets label keys and values for OpenTelemetry metrics. The size of keys and values lists
+ * must match. These labels are applied to all OTel metrics emitted by the channel.
+ */
+ public Builder withOtelLabels(List labelKeys, List labelValues) {
+ if (labelKeys == null || labelValues == null || labelKeys.size() != labelValues.size()) {
+ logger.warning("Unable to set OTel label keys and values - size mismatch or null.");
+ return this;
+ }
+ this.otelLabelKeys = labelKeys;
+ this.otelLabelValues = labelValues;
+ return this;
+ }
+ }
+ }
+
+ /** Resiliency configuration for the GCP managed channel. */
+ public static class GcpResiliencyOptions {
+ private final boolean notReadyFallbackEnabled;
+ private final boolean unresponsiveDetectionEnabled;
+ private final int unresponsiveDetectionMs;
+ private final int unresponsiveDetectionDroppedCount;
+
+ public GcpResiliencyOptions(Builder builder) {
+ notReadyFallbackEnabled = builder.notReadyFallbackEnabled;
+ unresponsiveDetectionEnabled = builder.unresponsiveDetectionEnabled;
+ unresponsiveDetectionMs = builder.unresponsiveDetectionMs;
+ unresponsiveDetectionDroppedCount = builder.unresponsiveDetectionDroppedCount;
+ }
+
+ /** Creates a new GcpResiliencyOptions.Builder. */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /** Creates a new GcpResiliencyOptions.Builder from GcpResiliencyOptions. */
+ public static Builder newBuilder(GcpResiliencyOptions options) {
+ return new Builder(options);
+ }
+
+ public boolean isNotReadyFallbackEnabled() {
+ return notReadyFallbackEnabled;
+ }
+
+ public boolean isUnresponsiveDetectionEnabled() {
+ return unresponsiveDetectionEnabled;
+ }
+
+ public int getUnresponsiveDetectionMs() {
+ return unresponsiveDetectionMs;
+ }
+
+ public int getUnresponsiveDetectionDroppedCount() {
+ return unresponsiveDetectionDroppedCount;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "{notReadyFallbackEnabled: %s, unresponsiveDetectionEnabled: %s, "
+ + "unresponsiveDetectionMs: %d, unresponsiveDetectionDroppedCount: %d}",
+ isNotReadyFallbackEnabled(),
+ isUnresponsiveDetectionEnabled(),
+ getUnresponsiveDetectionMs(),
+ getUnresponsiveDetectionDroppedCount());
+ }
+
+ public static class Builder {
+ private boolean notReadyFallbackEnabled = false;
+ private boolean unresponsiveDetectionEnabled = false;
+ private int unresponsiveDetectionMs = 0;
+ private int unresponsiveDetectionDroppedCount = 0;
+
+ public Builder() {}
+
+ public Builder(GcpResiliencyOptions options) {
+ this.notReadyFallbackEnabled = options.isNotReadyFallbackEnabled();
+ this.unresponsiveDetectionEnabled = options.isUnresponsiveDetectionEnabled();
+ this.unresponsiveDetectionMs = options.getUnresponsiveDetectionMs();
+ this.unresponsiveDetectionDroppedCount = options.getUnresponsiveDetectionDroppedCount();
+ }
+
+ public GcpResiliencyOptions build() {
+ return new GcpResiliencyOptions(this);
+ }
+
+ /**
+ * If true, temporarily fallback requests to a ready channel from a channel which is not ready
+ * to send a request immediately. The fallback will happen if the pool has another channel in
+ * the READY state and that channel has less than maximum allowed concurrent active streams.
+ */
+ public Builder setNotReadyFallback(boolean enabled) {
+ notReadyFallbackEnabled = enabled;
+ return this;
+ }
+
+ /**
+ * Enable unresponsive connection detection.
+ *
+ * If an RPC channel fails to receive any RPC message from the server for {@code ms}
+ * milliseconds and there were {@code numDroppedRequests} calls (started after the last
+ * response from the server) that resulted in DEADLINE_EXCEEDED then a graceful reconnection
+ * of the channel will be performed.
+ *
+ *
During the reconnection a new subchannel (connection) will be created for new RPCs, and
+ * the calls on the old subchannel will still have a chance to complete if the server side
+ * responds. When all RPCs on the old subchannel finish the old connection will be closed.
+ *
+ *
The {@code ms} should not be less than the timeout used for the majority of calls. And
+ * {@code numDroppedRequests} must be > 0.
+ *
+ *
The logic treats any message from the server almost as a "ping" response. But only calls
+ * started after the last response received and ended up in DEADLINE_EXCEEDED count towards
+ * {@code numDroppedRequests}. Because of that, it may not detect an unresponsive connection
+ * if you have long-running streaming calls only.
+ */
+ public Builder withUnresponsiveConnectionDetection(int ms, int numDroppedRequests) {
+ Preconditions.checkArgument(ms > 0, "ms should be > 0, got %s", ms);
+ Preconditions.checkArgument(
+ numDroppedRequests > 0, "numDroppedRequests should be > 0, got %s", numDroppedRequests);
+ unresponsiveDetectionEnabled = true;
+ unresponsiveDetectionMs = ms;
+ unresponsiveDetectionDroppedCount = numDroppedRequests;
+ return this;
+ }
+
+ /** Disable unresponsive connection detection. */
+ public Builder disableUnresponsiveConnectionDetection() {
+ unresponsiveDetectionEnabled = false;
+ return this;
+ }
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMetricsConstants.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMetricsConstants.java
new file mode 100644
index 000000000000..94db0da5c354
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMetricsConstants.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+class GcpMetricsConstants {
+ public static String POOL_INDEX_LABEL = "pool_index";
+ public static String POOL_INDEX_DESC = "gRPC GCP channel pool index.";
+ public static String RESULT_LABEL = "result";
+ public static String RESULT_DESC = "Outcome.";
+ public static String RESULT_SUCCESS = "SUCCESS";
+ public static String RESULT_ERROR = "ERROR";
+ public static String ENDPOINT_LABEL = "endpoint";
+ public static String ENDPOINT_LABEL_DESC = "gRPC endpoint (addr:port).";
+ public static String STATUS_LABEL = "status";
+ public static String STATUS_LABEL_DESC = "Endpoint status.";
+ public static String STATUS_AVAILABLE = "AVAILABLE";
+ public static String STATUS_UNAVAILABLE = "UNAVAILABLE";
+ public static String ME_NAME_LABEL = "me_name";
+ public static String ME_NAME_LABEL_DESC = "Multi-endpoint name.";
+ public static String SWITCH_TYPE_LABEL = "switch_type";
+ public static String SWITCH_TYPE_LABEL_DESC = "Switch type (fallback/recover/replace).";
+ public static String TYPE_FALLBACK = "FALLBACK";
+ public static String TYPE_RECOVER = "RECOVER";
+ public static String TYPE_REPLACE = "REPLACE";
+ public static String DIRECTION_LABEL = "direction";
+ public static String DIRECTION_LABEL_DESC = "Direction (up/down).";
+ public static String DIRECTION_UP = "UP";
+ public static String DIRECTION_DOWN = "DOWN";
+
+ // Unit to represent count.
+ static final String COUNT = "1";
+ static final String MICROSECOND = "us";
+ static final String MILLISECOND = "ms";
+
+ public static String METRIC_NUM_CHANNELS = "num_channels";
+ public static String METRIC_MIN_CHANNELS = "min_channels";
+ public static String METRIC_MAX_CHANNELS = "max_channels";
+ public static String METRIC_MIN_READY_CHANNELS = "min_ready_channels";
+ public static String METRIC_MAX_READY_CHANNELS = "max_ready_channels";
+ public static String METRIC_MAX_ALLOWED_CHANNELS = "max_allowed_channels";
+ public static String METRIC_NUM_CHANNEL_DISCONNECT = "num_channel_disconnect";
+ public static String METRIC_NUM_CHANNEL_CONNECT = "num_channel_connect";
+ public static String METRIC_MIN_CHANNEL_READINESS_TIME = "min_channel_readiness_time";
+ public static String METRIC_AVG_CHANNEL_READINESS_TIME = "avg_channel_readiness_time";
+ public static String METRIC_MAX_CHANNEL_READINESS_TIME = "max_channel_readiness_time";
+ public static String METRIC_MIN_ACTIVE_STREAMS = "min_active_streams_per_channel";
+ public static String METRIC_MAX_ACTIVE_STREAMS = "max_active_streams_per_channel";
+ public static String METRIC_MIN_TOTAL_ACTIVE_STREAMS = "min_total_active_streams";
+ public static String METRIC_MAX_TOTAL_ACTIVE_STREAMS = "max_total_active_streams";
+ public static String METRIC_MIN_AFFINITY = "min_affinity_per_channel";
+ public static String METRIC_MAX_AFFINITY = "max_affinity_per_channel";
+ public static String METRIC_NUM_AFFINITY = "num_affinity";
+ public static String METRIC_MIN_CALLS = "min_calls_per_channel";
+ public static String METRIC_MAX_CALLS = "max_calls_per_channel";
+ public static String METRIC_NUM_CALLS_COMPLETED = "num_calls_completed";
+ public static String METRIC_NUM_FALLBACKS = "num_fallbacks";
+ public static String METRIC_NUM_UNRESPONSIVE_DETECTIONS = "num_unresponsive_detections";
+ public static String METRIC_MIN_UNRESPONSIVE_DETECTION_TIME = "min_unresponsive_detection_time";
+ public static String METRIC_MAX_UNRESPONSIVE_DETECTION_TIME = "max_unresponsive_detection_time";
+ public static String METRIC_MIN_UNRESPONSIVE_DROPPED_CALLS = "min_unresponsive_dropped_calls";
+ public static String METRIC_MAX_UNRESPONSIVE_DROPPED_CALLS = "max_unresponsive_dropped_calls";
+ public static String METRIC_ENDPOINT_STATE = "endpoint_state";
+ public static String METRIC_ENDPOINT_SWITCH = "endpoint_switch";
+ public static String METRIC_CURRENT_ENDPOINT = "current_endpoint";
+ public static String METRIC_CHANNEL_POOL_SCALING = "channel_pool_scaling";
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMultiEndpointChannel.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMultiEndpointChannel.java
new file mode 100644
index 000000000000..59242916caa5
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMultiEndpointChannel.java
@@ -0,0 +1,857 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import static com.google.cloud.grpc.GcpMetricsConstants.COUNT;
+import static com.google.cloud.grpc.GcpMetricsConstants.ENDPOINT_LABEL;
+import static com.google.cloud.grpc.GcpMetricsConstants.ENDPOINT_LABEL_DESC;
+import static com.google.cloud.grpc.GcpMetricsConstants.METRIC_CURRENT_ENDPOINT;
+import static com.google.cloud.grpc.GcpMetricsConstants.METRIC_ENDPOINT_STATE;
+import static com.google.cloud.grpc.GcpMetricsConstants.METRIC_ENDPOINT_SWITCH;
+import static com.google.cloud.grpc.GcpMetricsConstants.ME_NAME_LABEL;
+import static com.google.cloud.grpc.GcpMetricsConstants.ME_NAME_LABEL_DESC;
+import static com.google.cloud.grpc.GcpMetricsConstants.STATUS_AVAILABLE;
+import static com.google.cloud.grpc.GcpMetricsConstants.STATUS_LABEL;
+import static com.google.cloud.grpc.GcpMetricsConstants.STATUS_LABEL_DESC;
+import static com.google.cloud.grpc.GcpMetricsConstants.STATUS_UNAVAILABLE;
+import static com.google.cloud.grpc.GcpMetricsConstants.SWITCH_TYPE_LABEL;
+import static com.google.cloud.grpc.GcpMetricsConstants.SWITCH_TYPE_LABEL_DESC;
+import static com.google.cloud.grpc.GcpMetricsConstants.TYPE_FALLBACK;
+import static com.google.cloud.grpc.GcpMetricsConstants.TYPE_RECOVER;
+import static com.google.cloud.grpc.GcpMetricsConstants.TYPE_REPLACE;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.cloud.grpc.GcpManagedChannelOptions.GcpChannelPoolOptions;
+import com.google.cloud.grpc.GcpManagedChannelOptions.GcpMetricsOptions;
+import com.google.cloud.grpc.multiendpoint.MultiEndpoint;
+import com.google.cloud.grpc.proto.ApiConfig;
+import com.google.common.base.Preconditions;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.errorprone.annotations.concurrent.GuardedBy;
+import io.grpc.CallOptions;
+import io.grpc.ClientCall;
+import io.grpc.ClientCall.Listener;
+import io.grpc.ConnectivityState;
+import io.grpc.Context;
+import io.grpc.Grpc;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.opencensus.metrics.DerivedLongCumulative;
+import io.opencensus.metrics.DerivedLongGauge;
+import io.opencensus.metrics.LabelKey;
+import io.opencensus.metrics.LabelValue;
+import io.opencensus.metrics.MetricOptions;
+import io.opencensus.metrics.MetricRegistry;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.api.metrics.Meter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * The purpose of GcpMultiEndpointChannel is twofold:
+ *
+ *
+ * - Fallback to an alternative endpoint (host:port) of a gRPC service when the original
+ * endpoint is completely unavailable.
+ *
- Be able to route an RPC call to a specific group of endpoints.
+ *
+ *
+ * A group of endpoints is called a {@link MultiEndpoint} and is essentially a list of endpoints
+ * where priority is defined by the position in the list with the first endpoint having top
+ * priority. A MultiEndpoint tracks endpoints' availability. When a MultiEndpoint is picked for an
+ * RPC call, it picks the top priority endpoint that is currently available. More information on the
+ * {@link MultiEndpoint} class.
+ *
+ *
GcpMultiEndpointChannel can have one or more MultiEndpoint identified by its name -- arbitrary
+ * string provided in the {@link GcpMultiEndpointOptions} when configuring MultiEndpoints. This name
+ * can be used to route an RPC call to this MultiEndpoint by setting the {@link #ME_KEY} key value
+ * of the RPC {@link CallOptions}.
+ *
+ *
GcpMultiEndpointChannel receives a list of GcpMultiEndpointOptions for initial configuration.
+ * An updated configuration can be provided at any time later using {@link
+ * GcpMultiEndpointChannel#setMultiEndpoints(List)}. The first item in the GcpMultiEndpointOptions
+ * list defines the default MultiEndpoint that will be used when no MultiEndpoint name is provided
+ * with an RPC call.
+ *
+ *
Example:
+ *
+ *
Let's assume we have a service with read and write operations and the following backends:
+ *
+ *
+ * - service.example.com -- the main set of backends supporting all operations
+ *
- service-fallback.example.com -- read-write replica supporting all operations
+ *
- ro-service.example.com -- read-only replica supporting only read operations
+ *
+ *
+ * Example configuration:
+ *
+ *
+ * - MultiEndpoint named "default" with endpoints:
+ *
+ * - service.example.com:443
+ *
- service-fallback.example.com:443
+ *
+ * - MultiEndpoint named "read" with endpoints:
+ *
+ * - ro-service.example.com:443
+ *
- service-fallback.example.com:443
+ *
- service.example.com:443
+ *
+ *
+ *
+ * With the configuration above GcpMultiEndpointChannel will use the "default" MultiEndpoint by
+ * default. It means that RPC calls by default will use the main endpoint and if it is not available
+ * then the read-write replica.
+ *
+ *
To offload some read calls to the read-only replica we can specify "read" MultiEndpoint in the
+ * CallOptions. Then these calls will use the read-only replica endpoint and if it is not available
+ * then the read-write replica and if it is also not available then the main endpoint.
+ *
+ *
GcpMultiEndpointChannel creates a {@link GcpManagedChannel} channel pool for every unique
+ * endpoint. For the example above three channel pools will be created.
+ */
+public class GcpMultiEndpointChannel extends ManagedChannel {
+
+ private static final Logger logger = Logger.getLogger(GcpMultiEndpointChannel.class.getName());
+ public static final CallOptions.Key ME_KEY = CallOptions.Key.create("MultiEndpoint");
+ public static final Context.Key ME_CONTEXT_KEY = Context.key("MultiEndpoint");
+ private final LabelKey endpointKey = LabelKey.create(ENDPOINT_LABEL, ENDPOINT_LABEL_DESC);
+ private final Map multiEndpoints = new ConcurrentHashMap<>();
+ private MultiEndpoint defaultMultiEndpoint;
+ private final ApiConfig apiConfig;
+ private final GcpManagedChannelOptions gcpManagedChannelOptions;
+ private final GcpMetricsOptions gcpMetricsOptions;
+ private DerivedLongGauge endpointStateMetric;
+ private DerivedLongCumulative endpointSwitchMetric;
+ private DerivedLongGauge currentEndpointMetric;
+ private Meter otelMeter;
+ private Attributes otelCommonAttributes;
+
+ private final Map currentEndpointWatchers =
+ new ConcurrentHashMap<>();
+
+ private final Map pools = new ConcurrentHashMap<>();
+
+ @GuardedBy("this")
+ private final Set currentEndpoints = new HashSet<>();
+
+ private final ScheduledExecutorService executor =
+ new ScheduledThreadPoolExecutor(1, GcpThreadFactory.newThreadFactory("gcp-me-%d"));
+
+ /**
+ * Constructor for {@link GcpMultiEndpointChannel}.
+ *
+ * @param meOptions list of MultiEndpoint configurations.
+ * @param apiConfig the ApiConfig object for configuring GcpManagedChannel.
+ * @param gcpManagedChannelOptions the options for GcpManagedChannel.
+ */
+ public GcpMultiEndpointChannel(
+ List meOptions,
+ ApiConfig apiConfig,
+ GcpManagedChannelOptions gcpManagedChannelOptions) {
+ this.apiConfig = apiConfig;
+ this.gcpManagedChannelOptions = gcpManagedChannelOptions;
+ this.gcpMetricsOptions = gcpManagedChannelOptions.getMetricsOptions();
+ createMetrics();
+ setMultiEndpoints(meOptions);
+ }
+
+ private class EndpointStateMonitor implements Runnable {
+
+ private final ManagedChannel channel;
+ private final String endpoint;
+ private ConnectivityState currentState;
+
+ private EndpointStateMonitor(ManagedChannel channel, String endpoint) {
+ this.endpoint = endpoint;
+ this.channel = channel;
+ setUpMetrics();
+ run();
+ }
+
+ private void setUpMetrics() {
+ if (endpointStateMetric == null) {
+ return;
+ }
+
+ endpointStateMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(endpoint), LabelValue.create(STATUS_AVAILABLE)),
+ this,
+ EndpointStateMonitor::reportAvailable);
+ endpointStateMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(endpoint), LabelValue.create(STATUS_UNAVAILABLE)),
+ this,
+ EndpointStateMonitor::reportUnavailable);
+ }
+
+ private void removeMetrics() {
+ if (endpointStateMetric == null) {
+ return;
+ }
+
+ endpointStateMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(endpoint), LabelValue.create(STATUS_AVAILABLE)));
+ endpointStateMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(endpoint), LabelValue.create(STATUS_UNAVAILABLE)));
+ }
+
+ private long reportAvailable() {
+ return ConnectivityState.READY.equals(currentState) ? 1 : 0;
+ }
+
+ private long reportUnavailable() {
+ return ConnectivityState.READY.equals(currentState) ? 0 : 1;
+ }
+
+ @Override
+ public void run() {
+ if (channel == null) {
+ removeMetrics();
+ return;
+ }
+ currentState = checkPoolState(channel, endpoint);
+ if (currentState != ConnectivityState.SHUTDOWN) {
+ channel.notifyWhenStateChanged(currentState, this);
+ } else {
+ removeMetrics();
+ }
+ }
+ }
+
+ // Checks and returns channel pool state. Also notifies all MultiEndpoints of the pool state.
+ private ConnectivityState checkPoolState(ManagedChannel channel, String endpoint) {
+ ConnectivityState state = channel.getState(false);
+ // Update endpoint state in all multiendpoints.
+ for (MultiEndpoint me : multiEndpoints.values()) {
+ me.setEndpointAvailable(endpoint, state.equals(ConnectivityState.READY));
+ }
+ return state;
+ }
+
+ private GcpManagedChannelOptions prepareGcpManagedChannelConfig(
+ GcpManagedChannelOptions gcpOptions, String endpoint) {
+ final GcpMetricsOptions.Builder metricsOptions =
+ GcpMetricsOptions.newBuilder(gcpOptions.getMetricsOptions());
+
+ final List labelKeys = new ArrayList<>(metricsOptions.build().getLabelKeys());
+ final List labelValues = new ArrayList<>(metricsOptions.build().getLabelValues());
+
+ labelKeys.add(endpointKey);
+ labelValues.add(LabelValue.create(endpoint));
+
+ // Mirror OpenCensus endpoint label for OpenTelemetry as well.
+ List otelLabelKeys = metricsOptions.build().getOtelLabelKeys();
+ List otelLabelValues = metricsOptions.build().getOtelLabelValues();
+ if (otelLabelKeys != null && otelLabelValues != null) {
+ List newOtelKeys = new ArrayList<>(otelLabelKeys);
+ List newOtelValues = new ArrayList<>(otelLabelValues);
+ newOtelKeys.add(ENDPOINT_LABEL);
+ newOtelValues.add(endpoint);
+ metricsOptions.withOtelLabels(newOtelKeys, newOtelValues);
+ }
+
+ // Make sure the pool will have at least 1 channel always connected. If maximum size > 1 then we
+ // want at least 2 channels or square root of maximum channels whichever is larger.
+ // Do not override if minSize is already specified as > 0.
+ final GcpChannelPoolOptions.Builder poolOptions =
+ GcpChannelPoolOptions.newBuilder(gcpOptions.getChannelPoolOptions());
+ if (poolOptions.build().getMinSize() < 1) {
+ int minSize = Math.min(2, poolOptions.build().getMaxSize());
+ minSize = Math.max(minSize, ((int) Math.sqrt(poolOptions.build().getMaxSize())));
+ poolOptions.setMinSize(minSize);
+ }
+
+ return GcpManagedChannelOptions.newBuilder(gcpOptions)
+ .withChannelPoolOptions(poolOptions.build())
+ .withMetricsOptions(metricsOptions.withLabels(labelKeys, labelValues).build())
+ .build();
+ }
+
+ private ManagedChannelBuilder> channelBuilderForEndpoint(String endpoint) {
+ String serviceAddress;
+ // Assume https by default.
+ int port = 443;
+ try {
+ URL url = new URL(endpoint);
+ serviceAddress = url.getHost();
+ port = url.getPort() < 0 ? url.getDefaultPort() : url.getPort();
+ } catch (MalformedURLException ex) {
+ // When no protocol is specified, fallback to plain host:port parsing.
+ int colon = endpoint.lastIndexOf(':');
+ if (colon < 0) {
+ serviceAddress = endpoint;
+ } else {
+ serviceAddress = endpoint.substring(0, colon);
+ port = Integer.parseInt(endpoint.substring(colon + 1));
+ }
+ }
+ return ManagedChannelBuilder.forAddress(serviceAddress, port);
+ }
+
+ private static class CurrentEndpointWatcher {
+ private final MultiEndpoint me;
+ private final String endpoint;
+
+ public CurrentEndpointWatcher(MultiEndpoint me, String endpoint) {
+ this.me = me;
+ this.endpoint = endpoint;
+ }
+
+ public long getMetricValue() {
+ return endpoint.equals(me.getCurrentId()) ? 1 : 0;
+ }
+ }
+
+ private void setUpMetricsForMultiEndpoint(GcpMultiEndpointOptions options, MultiEndpoint me) {
+ String name = options.getName();
+ List endpoints = options.getEndpoints();
+ if (endpointSwitchMetric != null) {
+ endpointSwitchMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(TYPE_FALLBACK)),
+ me,
+ MultiEndpoint::getFallbackCnt);
+ endpointSwitchMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(TYPE_RECOVER)),
+ me,
+ MultiEndpoint::getRecoverCnt);
+ endpointSwitchMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(TYPE_REPLACE)),
+ me,
+ MultiEndpoint::getReplaceCnt);
+ }
+ if (currentEndpointMetric != null) {
+ for (String e : endpoints) {
+ CurrentEndpointWatcher watcher = new CurrentEndpointWatcher(me, e);
+ currentEndpointWatchers.put(name + ":" + e, watcher);
+ currentEndpointMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(e)),
+ watcher,
+ CurrentEndpointWatcher::getMetricValue);
+ }
+ }
+ }
+
+ private void updateMetricsForMultiEndpoint(GcpMultiEndpointOptions options, MultiEndpoint me) {
+ if (currentEndpointMetric == null) {
+ return;
+ }
+ Set newEndpoints = new HashSet<>(options.getEndpoints());
+ Set existingEndpoints = new HashSet<>(me.getEndpoints());
+ for (String e : existingEndpoints) {
+ if (!newEndpoints.contains(e)) {
+ currentEndpointMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(options.getName()), LabelValue.create(e)));
+ currentEndpointWatchers.remove(options.getName() + ":" + e);
+ }
+ }
+ for (String e : newEndpoints) {
+ if (!existingEndpoints.contains(e)) {
+ CurrentEndpointWatcher watcher = new CurrentEndpointWatcher(me, e);
+ currentEndpointWatchers.put(options.getName() + ":" + e, watcher);
+ currentEndpointMetric.createTimeSeries(
+ appendCommonValues(LabelValue.create(options.getName()), LabelValue.create(e)),
+ watcher,
+ CurrentEndpointWatcher::getMetricValue);
+ }
+ }
+ }
+
+ private void removeMetricsForMultiEndpoint(String name, MultiEndpoint me) {
+ if (endpointSwitchMetric != null) {
+ endpointSwitchMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(TYPE_FALLBACK)));
+ endpointSwitchMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(TYPE_RECOVER)));
+ endpointSwitchMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(TYPE_REPLACE)));
+ }
+ if (currentEndpointMetric != null) {
+ for (String e : me.getEndpoints()) {
+ currentEndpointMetric.removeTimeSeries(
+ appendCommonValues(LabelValue.create(name), LabelValue.create(e)));
+ currentEndpointWatchers.remove(name + ":" + e);
+ }
+ }
+ }
+
+ /**
+ * Update the list of MultiEndpoint configurations.
+ *
+ * MultiEndpoints are matched with the current ones by name.
+ *
+ *
+ * - If a current MultiEndpoint is missing in the updated list, the MultiEndpoint will be
+ * removed.
+ *
- A new MultiEndpoint will be created for every new name in the list.
+ *
- For an existing MultiEndpoint only its endpoints will be updated (no recovery timeout
+ * change).
+ *
+ *
+ * Endpoints are matched by the endpoint address (usually in the form of address:port).
+ *
+ *
+ * - If an existing endpoint is not used by any MultiEndpoint in the updated list, then the
+ * channel poll for this endpoint will be shutdown.
+ *
- A channel pool will be created for every new endpoint.
+ *
- For an existing endpoint nothing will change (the channel pool will not be re-created,
+ * thus no channel credentials change, nor channel configurator change).
+ *
+ */
+ public synchronized void setMultiEndpoints(List meOptions) {
+ Preconditions.checkNotNull(meOptions);
+ Preconditions.checkArgument(!meOptions.isEmpty(), "MultiEndpoints list is empty");
+ Set currentMultiEndpoints = new HashSet<>();
+
+ // Must have all multiendpoints before initializing the pools so that all multiendpoints
+ // can get status update of every pool.
+ meOptions.forEach(
+ options -> {
+ currentMultiEndpoints.add(options.getName());
+ // Create or update MultiEndpoint
+ MultiEndpoint existingMe = multiEndpoints.get(options.getName());
+ if (existingMe != null) {
+ updateMetricsForMultiEndpoint(options, existingMe);
+ existingMe.setEndpoints(options.getEndpoints());
+ } else {
+ MultiEndpoint me =
+ new MultiEndpoint.Builder(options.getEndpoints())
+ .withRecoveryTimeout(options.getRecoveryTimeout())
+ .withSwitchingDelay(options.getSwitchingDelay())
+ .build();
+ setUpMetricsForMultiEndpoint(options, me);
+ multiEndpoints.put(options.getName(), me);
+ }
+ });
+
+ final Set existingPools = new HashSet<>(pools.keySet());
+ currentEndpoints.clear();
+ // TODO: Support the same endpoint in different MultiEndpoint to use different channel
+ // credentials.
+ // TODO: Support different endpoints in the same MultiEndpoint to use different channel
+ // credentials.
+ meOptions.forEach(
+ options -> {
+ // Create missing pools
+ options
+ .getEndpoints()
+ .forEach(
+ endpoint -> {
+ currentEndpoints.add(endpoint);
+ pools.computeIfAbsent(
+ endpoint,
+ e -> {
+ ManagedChannelBuilder> managedChannelBuilder;
+ if (options.getChannelCredentials() != null) {
+ managedChannelBuilder =
+ Grpc.newChannelBuilder(e, options.getChannelCredentials());
+ } else {
+ managedChannelBuilder = channelBuilderForEndpoint(e);
+ }
+ if (options.getChannelConfigurator() != null) {
+ managedChannelBuilder =
+ options.getChannelConfigurator().apply(managedChannelBuilder);
+ }
+
+ GcpManagedChannel channel =
+ new GcpManagedChannel(
+ managedChannelBuilder,
+ apiConfig,
+ // Add endpoint to metric labels.
+ prepareGcpManagedChannelConfig(gcpManagedChannelOptions, e));
+ // Start monitoring the pool state.
+ new EndpointStateMonitor(channel, e);
+ return channel;
+ });
+ });
+ });
+ existingPools.retainAll(currentEndpoints);
+ existingPools.forEach(
+ e -> {
+ // Communicate current state to MultiEndpoints.
+ checkPoolState(pools.get(e), e);
+ });
+ defaultMultiEndpoint = multiEndpoints.get(meOptions.get(0).getName());
+
+ // Remove obsolete multiendpoints.
+ Iterator iter = multiEndpoints.keySet().iterator();
+ while (iter.hasNext()) {
+ String name = iter.next();
+ if (currentMultiEndpoints.contains(name)) {
+ continue;
+ }
+ removeMetricsForMultiEndpoint(name, multiEndpoints.get(name));
+ iter.remove();
+ }
+
+ // Shutdown and remove the pools not present in options.
+ final Set poolsToRemove = new HashSet<>(pools.keySet());
+ poolsToRemove.removeIf(currentEndpoints::contains);
+ if (!poolsToRemove.isEmpty()) {
+ // Get max switching delay.
+ Optional maxDelay =
+ meOptions.stream()
+ .map(GcpMultiEndpointOptions::getSwitchingDelay)
+ .max(Comparator.naturalOrder());
+ if (maxDelay.isPresent() && maxDelay.get().toMillis() > 0) {
+ executor.schedule(
+ () -> maybeCleanupPools(poolsToRemove), maxDelay.get().toMillis(), MILLISECONDS);
+ } else {
+ maybeCleanupPools(poolsToRemove);
+ }
+ }
+ }
+
+ private synchronized void maybeCleanupPools(Set endpoints) {
+ for (String endpoint : endpoints) {
+ if (currentEndpoints.contains(endpoint)) {
+ continue;
+ }
+ pools.get(endpoint).shutdown();
+ pools.remove(endpoint);
+ }
+ }
+
+ private void createMetrics() {
+ if (gcpMetricsOptions == null) {
+ return;
+ }
+
+ MetricRegistry metricRegistry = gcpMetricsOptions.getMetricRegistry();
+ if (gcpMetricsOptions.getOpenTelemetryMeter() != null) {
+ // Prefer OpenTelemetry if present.
+ this.otelMeter = gcpMetricsOptions.getOpenTelemetryMeter();
+ AttributesBuilder builder = Attributes.builder();
+ if (gcpMetricsOptions.getOtelLabelKeys() != null
+ && gcpMetricsOptions.getOtelLabelValues() != null) {
+ for (int i = 0;
+ i
+ < Math.min(
+ gcpMetricsOptions.getOtelLabelKeys().size(),
+ gcpMetricsOptions.getOtelLabelValues().size());
+ i++) {
+ String k = gcpMetricsOptions.getOtelLabelKeys().get(i);
+ String v = gcpMetricsOptions.getOtelLabelValues().get(i);
+ if (k != null && !k.isEmpty() && v != null) {
+ builder.put(k, v);
+ }
+ }
+ }
+ otelCommonAttributes = builder.build();
+ String prefix = gcpMetricsOptions.getNamePrefix();
+ otelMeter
+ .gaugeBuilder(prefix + METRIC_ENDPOINT_SWITCH)
+ .ofLongs()
+ .setDescription(
+ "Reports occurrences of changes of current endpoint for a multi-endpoint with the name, specifying change type.")
+ .setUnit(COUNT)
+ .buildWithCallback(
+ m -> {
+ for (Map.Entry entry : multiEndpoints.entrySet()) {
+ String meName = entry.getKey();
+ MultiEndpoint me = entry.getValue();
+ m.record(
+ me.getFallbackCnt(),
+ Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(ME_NAME_LABEL, meName)
+ .put(SWITCH_TYPE_LABEL, TYPE_FALLBACK)
+ .build());
+ m.record(
+ me.getRecoverCnt(),
+ Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(ME_NAME_LABEL, meName)
+ .put(SWITCH_TYPE_LABEL, TYPE_RECOVER)
+ .build());
+ m.record(
+ me.getReplaceCnt(),
+ Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(ME_NAME_LABEL, meName)
+ .put(SWITCH_TYPE_LABEL, TYPE_REPLACE)
+ .build());
+ }
+ });
+
+ otelMeter
+ .gaugeBuilder(prefix + METRIC_CURRENT_ENDPOINT)
+ .ofLongs()
+ .setDescription("Reports 1 when an endpoint is current for multi-endpoint with the name.")
+ .setUnit(COUNT)
+ .buildWithCallback(
+ m -> {
+ for (Map.Entry entry : multiEndpoints.entrySet()) {
+ String meName = entry.getKey();
+ MultiEndpoint me = entry.getValue();
+ for (String endpoint : me.getEndpoints()) {
+ m.record(
+ endpoint.equals(me.getCurrentId()) ? 1L : 0L,
+ Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(ME_NAME_LABEL, meName)
+ .put(ENDPOINT_LABEL, endpoint)
+ .build());
+ }
+ }
+ });
+
+ otelMeter
+ .gaugeBuilder(prefix + METRIC_ENDPOINT_STATE)
+ .ofLongs()
+ .setDescription("Reports 1 when endpoint is in the status.")
+ .setUnit(COUNT)
+ .buildWithCallback(
+ m -> {
+ for (Map.Entry entry : pools.entrySet()) {
+ String endpoint = entry.getKey();
+ GcpManagedChannel channel = entry.getValue();
+ boolean available = ConnectivityState.READY.equals(channel.getState(false));
+ m.record(
+ available ? 1L : 0L,
+ Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(ENDPOINT_LABEL, endpoint)
+ .put(STATUS_LABEL, STATUS_AVAILABLE)
+ .build());
+ m.record(
+ available ? 0L : 1L,
+ Attributes.builder()
+ .putAll(otelCommonAttributes)
+ .put(ENDPOINT_LABEL, endpoint)
+ .put(STATUS_LABEL, STATUS_UNAVAILABLE)
+ .build());
+ }
+ });
+ return;
+ }
+
+ if (metricRegistry == null) {
+ return;
+ }
+
+ if (endpointStateMetric != null) {
+ return;
+ }
+
+ String prefix = gcpMetricsOptions.getNamePrefix();
+
+ endpointStateMetric =
+ metricRegistry.addDerivedLongGauge(
+ prefix + METRIC_ENDPOINT_STATE,
+ createMetricOptions(
+ "Reports 1 when endpoint is in the status.",
+ COUNT,
+ LabelKey.create(ENDPOINT_LABEL, ENDPOINT_LABEL_DESC),
+ LabelKey.create(STATUS_LABEL, STATUS_LABEL_DESC)));
+
+ endpointSwitchMetric =
+ metricRegistry.addDerivedLongCumulative(
+ prefix + METRIC_ENDPOINT_SWITCH,
+ createMetricOptions(
+ "Reports occurrences of changes of current endpoint for a multi-endpoint with "
+ + "the name, specifying change type.",
+ COUNT,
+ LabelKey.create(ME_NAME_LABEL, ME_NAME_LABEL_DESC),
+ LabelKey.create(SWITCH_TYPE_LABEL, SWITCH_TYPE_LABEL_DESC)));
+
+ currentEndpointMetric =
+ metricRegistry.addDerivedLongGauge(
+ prefix + METRIC_CURRENT_ENDPOINT,
+ createMetricOptions(
+ "Reports 1 when an endpoint is current for multi-endpoint with the name.",
+ COUNT,
+ LabelKey.create(ME_NAME_LABEL, ME_NAME_LABEL_DESC),
+ LabelKey.create(ENDPOINT_LABEL, ENDPOINT_LABEL_DESC)));
+ }
+
+ private List appendCommonValues(LabelValue... labelValues) {
+ final List values = new ArrayList<>();
+ Collections.addAll(values, labelValues);
+ if (gcpMetricsOptions != null && gcpMetricsOptions.getLabelValues() != null) {
+ values.addAll(gcpMetricsOptions.getLabelValues());
+ }
+ return values;
+ }
+
+ private MetricOptions createMetricOptions(
+ String description, String unit, LabelKey... labelKeys) {
+ final List keys = new ArrayList<>();
+ Collections.addAll(keys, labelKeys);
+ if (gcpMetricsOptions != null && gcpMetricsOptions.getLabelKeys() != null) {
+ keys.addAll(gcpMetricsOptions.getLabelKeys());
+ }
+ return MetricOptions.builder()
+ .setDescription(description)
+ .setLabelKeys(keys)
+ .setUnit(unit)
+ .build();
+ }
+
+ /**
+ * Initiates an orderly shutdown in which preexisting calls continue but new calls are immediately
+ * cancelled.
+ *
+ * @return this
+ * @since 1.0.0
+ */
+ @Override
+ @CanIgnoreReturnValue
+ public ManagedChannel shutdown() {
+ pools.values().forEach(GcpManagedChannel::shutdown);
+ return this;
+ }
+
+ /**
+ * Returns whether the channel is shutdown. Shutdown channels immediately cancel any new calls,
+ * but may still have some calls being processed.
+ *
+ * @see #shutdown()
+ * @see #isTerminated()
+ * @since 1.0.0
+ */
+ @Override
+ public boolean isShutdown() {
+ return pools.values().stream().allMatch(GcpManagedChannel::isShutdown);
+ }
+
+ /**
+ * Returns whether the channel is terminated. Terminated channels have no running calls and
+ * relevant resources released (like TCP connections).
+ *
+ * @see #isShutdown()
+ * @since 1.0.0
+ */
+ @Override
+ public boolean isTerminated() {
+ return pools.values().stream().allMatch(GcpManagedChannel::isTerminated);
+ }
+
+ /**
+ * Initiates a forceful shutdown in which preexisting and new calls are cancelled. Although
+ * forceful, the shutdown process is still not instantaneous; {@link #isTerminated()} will likely
+ * return {@code false} immediately after this method returns.
+ *
+ * @return this
+ * @since 1.0.0
+ */
+ @Override
+ @CanIgnoreReturnValue
+ public ManagedChannel shutdownNow() {
+ pools.values().forEach(GcpManagedChannel::shutdownNow);
+ return this;
+ }
+
+ /**
+ * Waits for the channel to become terminated, giving up if the timeout is reached.
+ *
+ * @return whether the channel is terminated, as would be done by {@link #isTerminated()}.
+ * @since 1.0.0
+ */
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ long endTimeNanos = System.nanoTime() + unit.toNanos(timeout);
+ for (GcpManagedChannel gcpManagedChannel : pools.values()) {
+ if (gcpManagedChannel.isTerminated()) {
+ continue;
+ }
+ long awaitTimeNanos = endTimeNanos - System.nanoTime();
+ if (awaitTimeNanos <= 0) {
+ break;
+ }
+ gcpManagedChannel.awaitTermination(awaitTimeNanos, NANOSECONDS);
+ }
+ return isTerminated();
+ }
+
+ /**
+ * Check the value of {@link #ME_KEY} key in the {@link CallOptions} and if found use the
+ * MultiEndpoint with the same name for this call.
+ *
+ * Create a {@link ClientCall} to the remote operation specified by the given {@link
+ * MethodDescriptor}. The returned {@link ClientCall} does not trigger any remote behavior until
+ * {@link ClientCall#start(Listener, Metadata)} is invoked.
+ *
+ * @param methodDescriptor describes the name and parameter types of the operation to call.
+ * @param callOptions runtime options to be applied to this call.
+ * @return a {@link ClientCall} bound to the specified method.
+ * @since 1.0.0
+ */
+ @Override
+ public ClientCall newCall(
+ MethodDescriptor methodDescriptor, CallOptions callOptions) {
+ String multiEndpointKey = callOptions.getOption(ME_KEY);
+ if (multiEndpointKey == null) {
+ multiEndpointKey = ME_CONTEXT_KEY.get(Context.current());
+ }
+ MultiEndpoint me = defaultMultiEndpoint;
+ if (multiEndpointKey != null) {
+ me = multiEndpoints.getOrDefault(multiEndpointKey, defaultMultiEndpoint);
+ }
+ return pools.get(me.getCurrentId()).newCall(methodDescriptor, callOptions);
+ }
+
+ /**
+ * The authority of the current endpoint of the default MultiEndpoint. Typically, this is in the
+ * format {@code host:port}.
+ *
+ * To get the authority of the current endpoint of another MultiEndpoint use {@link
+ * #authorityFor(String)} method.
+ *
+ *
This may return different values over time because MultiEndpoint may switch between
+ * endpoints.
+ *
+ * @since 1.0.0
+ */
+ @Override
+ public String authority() {
+ return pools.get(defaultMultiEndpoint.getCurrentId()).authority();
+ }
+
+ /**
+ * The authority of the current endpoint of the specified MultiEndpoint. Typically, this is in the
+ * format {@code host:port}.
+ *
+ *
This may return different values over time because MultiEndpoint may switch between
+ * endpoints.
+ */
+ public String authorityFor(String multiEndpointName) {
+ MultiEndpoint multiEndpoint = multiEndpoints.get(multiEndpointName);
+ if (multiEndpoint == null) {
+ return null;
+ }
+ return pools.get(multiEndpoint.getCurrentId()).authority();
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMultiEndpointOptions.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMultiEndpointOptions.java
new file mode 100644
index 000000000000..ca502fbd2f25
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpMultiEndpointOptions.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import com.google.api.core.ApiFunction;
+import com.google.cloud.grpc.multiendpoint.MultiEndpoint;
+import com.google.common.base.Preconditions;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import io.grpc.ChannelCredentials;
+import io.grpc.ManagedChannelBuilder;
+import java.time.Duration;
+import java.util.List;
+
+/** {@link MultiEndpoint} configuration for the {@link GcpMultiEndpointChannel}. */
+public class GcpMultiEndpointOptions {
+
+ private final String name;
+ private final List endpoints;
+ private final ApiFunction, ManagedChannelBuilder>> channelConfigurator;
+ private final ChannelCredentials channelCredentials;
+ private final Duration recoveryTimeout;
+ private final Duration switchingDelay;
+
+ public static String DEFAULT_NAME = "default";
+
+ public GcpMultiEndpointOptions(Builder builder) {
+ this.name = builder.name;
+ this.endpoints = builder.endpoints;
+ this.channelConfigurator = builder.channelConfigurator;
+ this.channelCredentials = builder.channelCredentials;
+ this.recoveryTimeout = builder.recoveryTimeout;
+ this.switchingDelay = builder.switchingDelay;
+ }
+
+ /**
+ * Creates a new GcpMultiEndpointOptions.Builder.
+ *
+ * @param endpoints list of endpoints for the MultiEndpoint.
+ */
+ public static Builder newBuilder(List endpoints) {
+ return new Builder(endpoints);
+ }
+
+ /** Creates a new GcpMultiEndpointOptions.Builder from GcpMultiEndpointOptions. */
+ public static Builder newBuilder(GcpMultiEndpointOptions options) {
+ return new Builder(options);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public List getEndpoints() {
+ return endpoints;
+ }
+
+ public ApiFunction, ManagedChannelBuilder>> getChannelConfigurator() {
+ return channelConfigurator;
+ }
+
+ public ChannelCredentials getChannelCredentials() {
+ return channelCredentials;
+ }
+
+ public Duration getRecoveryTimeout() {
+ return recoveryTimeout;
+ }
+
+ public Duration getSwitchingDelay() {
+ return switchingDelay;
+ }
+
+ public static class Builder {
+
+ private String name = GcpMultiEndpointOptions.DEFAULT_NAME;
+ private List endpoints;
+ private ApiFunction, ManagedChannelBuilder>> channelConfigurator;
+ private ChannelCredentials channelCredentials;
+ private Duration recoveryTimeout = Duration.ZERO;
+ private Duration switchingDelay = Duration.ZERO;
+
+ public Builder(List endpoints) {
+ setEndpoints(endpoints);
+ }
+
+ public Builder(GcpMultiEndpointOptions options) {
+ this.name = options.getName();
+ this.endpoints = options.getEndpoints();
+ this.channelConfigurator = options.getChannelConfigurator();
+ this.channelCredentials = options.getChannelCredentials();
+ this.recoveryTimeout = options.getRecoveryTimeout();
+ this.switchingDelay = options.getSwitchingDelay();
+ }
+
+ public GcpMultiEndpointOptions build() {
+ return new GcpMultiEndpointOptions(this);
+ }
+
+ private void setEndpoints(List endpoints) {
+ Preconditions.checkNotNull(endpoints);
+ Preconditions.checkArgument(!endpoints.isEmpty(), "At least one endpoint must be specified.");
+ Preconditions.checkArgument(
+ endpoints.stream().noneMatch(s -> s.trim().isEmpty()), "No empty endpoints allowed.");
+ this.endpoints = endpoints;
+ }
+
+ /**
+ * Sets the name of the MultiEndpoint.
+ *
+ * @param name MultiEndpoint name.
+ */
+ @CanIgnoreReturnValue
+ public GcpMultiEndpointOptions.Builder withName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ /**
+ * Sets the endpoints of the MultiEndpoint.
+ *
+ * @param endpoints List of endpoints in the form of host:port in descending priority order.
+ */
+ @CanIgnoreReturnValue
+ public GcpMultiEndpointOptions.Builder withEndpoints(List endpoints) {
+ this.setEndpoints(endpoints);
+ return this;
+ }
+
+ /**
+ * Sets the channel configurator for the MultiEndpoint channel pool.
+ *
+ * @param channelConfigurator function to perform on the ManagedChannelBuilder in the channel
+ * pool.
+ */
+ @CanIgnoreReturnValue
+ public GcpMultiEndpointOptions.Builder withChannelConfigurator(
+ ApiFunction, ManagedChannelBuilder>> channelConfigurator) {
+ this.channelConfigurator = channelConfigurator;
+ return this;
+ }
+
+ /**
+ * Sets the channel credentials to use in the MultiEndpoint channel pool.
+ *
+ * @param channelCredentials channel credentials.
+ */
+ @CanIgnoreReturnValue
+ public GcpMultiEndpointOptions.Builder withChannelCredentials(
+ ChannelCredentials channelCredentials) {
+ this.channelCredentials = channelCredentials;
+ return this;
+ }
+
+ /**
+ * Sets the recovery timeout for the MultiEndpoint. See more info in the {@link MultiEndpoint}.
+ *
+ * @param recoveryTimeout recovery timeout.
+ */
+ @CanIgnoreReturnValue
+ public GcpMultiEndpointOptions.Builder withRecoveryTimeout(Duration recoveryTimeout) {
+ this.recoveryTimeout = recoveryTimeout;
+ return this;
+ }
+
+ /**
+ * Sets the switching delay for the MultiEndpoint.
+ *
+ * When switching between endpoints the MultiEndpoint will stick to previous endpoint for the
+ * switching delay.
+ *
+ * @param switchingDelay switching delay.
+ */
+ @CanIgnoreReturnValue
+ public GcpMultiEndpointOptions.Builder withSwitchingDelay(Duration switchingDelay) {
+ this.switchingDelay = switchingDelay;
+ return this;
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpThreadFactory.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpThreadFactory.java
new file mode 100644
index 000000000000..40b92b8ad266
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GcpThreadFactory.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.util.concurrent.ThreadFactory;
+
+/** Thread factory helper for grpc-gcp background executors. */
+public final class GcpThreadFactory {
+ /** System property to control daemon threads for grpc-gcp background executors. */
+ public static final String USE_DAEMON_THREADS_PROPERTY =
+ "com.google.cloud.grpc.use_daemon_threads";
+
+ private GcpThreadFactory() {}
+
+ /**
+ * Creates a {@link ThreadFactory} that names threads and honors the daemon-thread setting.
+ *
+ *
Defaults to daemon threads to avoid keeping the JVM alive when only background work remains.
+ * Set {@code -Dcom.google.cloud.grpc.use_daemon_threads=false} to use non-daemon threads.
+ */
+ public static ThreadFactory newThreadFactory(String nameFormat) {
+ boolean useDaemon = true;
+ String prop = System.getProperty(USE_DAEMON_THREADS_PROPERTY);
+ if (prop != null) {
+ useDaemon = Boolean.parseBoolean(prop);
+ }
+ return new ThreadFactoryBuilder().setNameFormat(nameFormat).setDaemon(useDaemon).build();
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GrpcGcpUtil.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GrpcGcpUtil.java
new file mode 100644
index 000000000000..d7b7eee230ec
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/GrpcGcpUtil.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+public class GrpcGcpUtil {
+ public static final String IMPLEMENTATION_VERSION = implementationVersion();
+
+ private static String implementationVersion() {
+ Package grpcPackage = GrpcGcpUtil.class.getPackage();
+ if (grpcPackage != null && grpcPackage.getImplementationVersion() != null) {
+ return grpcPackage.getImplementationVersion();
+ }
+ return "local";
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/AutoValue_OpenTelemetryMetricsResource.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/AutoValue_OpenTelemetryMetricsResource.java
new file mode 100644
index 000000000000..3e77b3d6d682
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/AutoValue_OpenTelemetryMetricsResource.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import io.opentelemetry.api.metrics.DoubleGauge;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.ObservableLongUpDownCounter;
+import javax.annotation.Nullable;
+
+// Generated by com.google.auto.value.processor.AutoValueProcessor
+final class AutoValue_OpenTelemetryMetricsResource extends OpenTelemetryMetricsResource {
+
+ @Nullable private final ObservableLongUpDownCounter currentChannelCounter;
+
+ @Nullable private final LongCounter fallbackCounter;
+
+ @Nullable private final LongCounter callStatusCounter;
+
+ @Nullable private final DoubleGauge errorRatioGauge;
+
+ @Nullable private final LongCounter probeResultCounter;
+
+ @Nullable private final DoubleGauge channelDowntimeGauge;
+
+ private AutoValue_OpenTelemetryMetricsResource(
+ @Nullable ObservableLongUpDownCounter currentChannelCounter,
+ @Nullable LongCounter fallbackCounter,
+ @Nullable LongCounter callStatusCounter,
+ @Nullable DoubleGauge errorRatioGauge,
+ @Nullable LongCounter probeResultCounter,
+ @Nullable DoubleGauge channelDowntimeGauge) {
+ this.currentChannelCounter = currentChannelCounter;
+ this.fallbackCounter = fallbackCounter;
+ this.callStatusCounter = callStatusCounter;
+ this.errorRatioGauge = errorRatioGauge;
+ this.probeResultCounter = probeResultCounter;
+ this.channelDowntimeGauge = channelDowntimeGauge;
+ }
+
+ @Nullable
+ @Override
+ ObservableLongUpDownCounter currentChannelCounter() {
+ return currentChannelCounter;
+ }
+
+ @Nullable
+ @Override
+ LongCounter fallbackCounter() {
+ return fallbackCounter;
+ }
+
+ @Nullable
+ @Override
+ LongCounter callStatusCounter() {
+ return callStatusCounter;
+ }
+
+ @Nullable
+ @Override
+ DoubleGauge errorRatioGauge() {
+ return errorRatioGauge;
+ }
+
+ @Nullable
+ @Override
+ LongCounter probeResultCounter() {
+ return probeResultCounter;
+ }
+
+ @Nullable
+ @Override
+ DoubleGauge channelDowntimeGauge() {
+ return channelDowntimeGauge;
+ }
+
+ @Override
+ public String toString() {
+ return "OpenTelemetryMetricsResource{"
+ + "currentChannelCounter="
+ + currentChannelCounter
+ + ", "
+ + "fallbackCounter="
+ + fallbackCounter
+ + ", "
+ + "callStatusCounter="
+ + callStatusCounter
+ + ", "
+ + "errorRatioGauge="
+ + errorRatioGauge
+ + ", "
+ + "probeResultCounter="
+ + probeResultCounter
+ + ", "
+ + "channelDowntimeGauge="
+ + channelDowntimeGauge
+ + "}";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) {
+ return true;
+ }
+ if (o instanceof OpenTelemetryMetricsResource) {
+ OpenTelemetryMetricsResource that = (OpenTelemetryMetricsResource) o;
+ return (this.currentChannelCounter == null
+ ? that.currentChannelCounter() == null
+ : this.currentChannelCounter.equals(that.currentChannelCounter()))
+ && (this.fallbackCounter == null
+ ? that.fallbackCounter() == null
+ : this.fallbackCounter.equals(that.fallbackCounter()))
+ && (this.callStatusCounter == null
+ ? that.callStatusCounter() == null
+ : this.callStatusCounter.equals(that.callStatusCounter()))
+ && (this.errorRatioGauge == null
+ ? that.errorRatioGauge() == null
+ : this.errorRatioGauge.equals(that.errorRatioGauge()))
+ && (this.probeResultCounter == null
+ ? that.probeResultCounter() == null
+ : this.probeResultCounter.equals(that.probeResultCounter()))
+ && (this.channelDowntimeGauge == null
+ ? that.channelDowntimeGauge() == null
+ : this.channelDowntimeGauge.equals(that.channelDowntimeGauge()));
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int h$ = 1;
+ h$ *= 1000003;
+ h$ ^= (currentChannelCounter == null) ? 0 : currentChannelCounter.hashCode();
+ h$ *= 1000003;
+ h$ ^= (fallbackCounter == null) ? 0 : fallbackCounter.hashCode();
+ h$ *= 1000003;
+ h$ ^= (callStatusCounter == null) ? 0 : callStatusCounter.hashCode();
+ h$ *= 1000003;
+ h$ ^= (errorRatioGauge == null) ? 0 : errorRatioGauge.hashCode();
+ h$ *= 1000003;
+ h$ ^= (probeResultCounter == null) ? 0 : probeResultCounter.hashCode();
+ h$ *= 1000003;
+ h$ ^= (channelDowntimeGauge == null) ? 0 : channelDowntimeGauge.hashCode();
+ return h$;
+ }
+
+ static final class Builder extends OpenTelemetryMetricsResource.Builder {
+ private ObservableLongUpDownCounter currentChannelCounter;
+ private LongCounter fallbackCounter;
+ private LongCounter callStatusCounter;
+ private DoubleGauge errorRatioGauge;
+ private LongCounter probeResultCounter;
+ private DoubleGauge channelDowntimeGauge;
+
+ Builder() {}
+
+ @Override
+ OpenTelemetryMetricsResource.Builder currentChannelCounter(
+ ObservableLongUpDownCounter currentChannelCounter) {
+ this.currentChannelCounter = currentChannelCounter;
+ return this;
+ }
+
+ @Override
+ OpenTelemetryMetricsResource.Builder fallbackCounter(LongCounter fallbackCounter) {
+ this.fallbackCounter = fallbackCounter;
+ return this;
+ }
+
+ @Override
+ OpenTelemetryMetricsResource.Builder callStatusCounter(LongCounter callStatusCounter) {
+ this.callStatusCounter = callStatusCounter;
+ return this;
+ }
+
+ @Override
+ OpenTelemetryMetricsResource.Builder errorRatioGauge(DoubleGauge errorRatioGauge) {
+ this.errorRatioGauge = errorRatioGauge;
+ return this;
+ }
+
+ @Override
+ OpenTelemetryMetricsResource.Builder probeResultCounter(LongCounter probeResultCounter) {
+ this.probeResultCounter = probeResultCounter;
+ return this;
+ }
+
+ @Override
+ OpenTelemetryMetricsResource.Builder channelDowntimeGauge(DoubleGauge channelDowntimeGauge) {
+ this.channelDowntimeGauge = channelDowntimeGauge;
+ return this;
+ }
+
+ @Override
+ OpenTelemetryMetricsResource build() {
+ return new AutoValue_OpenTelemetryMetricsResource(
+ this.currentChannelCounter,
+ this.fallbackCounter,
+ this.callStatusCounter,
+ this.errorRatioGauge,
+ this.probeResultCounter,
+ this.channelDowntimeGauge);
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackChannel.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackChannel.java
new file mode 100644
index 000000000000..b991234848c1
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackChannel.java
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.cloud.grpc.GcpThreadFactory;
+import com.google.common.annotations.VisibleForTesting;
+import io.grpc.CallOptions;
+import io.grpc.Channel;
+import io.grpc.ClientCall;
+import io.grpc.ClientInterceptor;
+import io.grpc.ClientInterceptors;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.MethodDescriptor;
+import io.grpc.Status;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Logger;
+import javax.annotation.Nullable;
+
+public class GcpFallbackChannel extends ManagedChannel {
+ private static final Logger logger = Logger.getLogger(GcpFallbackChannel.class.getName());
+ static final String INIT_FAILURE_REASON = "init failure";
+ private final GcpFallbackChannelOptions options;
+ // Primary channel that was provided in constructor.
+ @Nullable private final ManagedChannel primaryDelegateChannel;
+ // Fallback channel that was provided in constructor.
+ @Nullable private final ManagedChannel fallbackDelegateChannel;
+ // Wrapped primary channel to be used for RPCs.
+ private final Channel primaryChannel;
+ // Wrapped fallback channel to be used for RPCs.
+ private final Channel fallbackChannel;
+ private final AtomicLong primarySuccesses = new AtomicLong(0);
+ private final AtomicLong primaryFailures = new AtomicLong(0);
+ private final AtomicLong fallbackSuccesses = new AtomicLong(0);
+ private final AtomicLong fallbackFailures = new AtomicLong(0);
+ private boolean inFallbackMode = false;
+ private final GcpFallbackOpenTelemetry openTelemetry;
+
+ private final ScheduledExecutorService execService;
+
+ public GcpFallbackChannel(
+ GcpFallbackChannelOptions options,
+ ManagedChannel primaryChannel,
+ ManagedChannel fallbackChannel) {
+ this(options, primaryChannel, fallbackChannel, null);
+ }
+
+ public GcpFallbackChannel(
+ GcpFallbackChannelOptions options,
+ ManagedChannelBuilder> primaryChannelBuilder,
+ ManagedChannelBuilder> fallbackChannelBuilder) {
+ this(options, primaryChannelBuilder, fallbackChannelBuilder, null);
+ }
+
+ @VisibleForTesting
+ GcpFallbackChannel(
+ GcpFallbackChannelOptions options,
+ ManagedChannelBuilder> primaryChannelBuilder,
+ ManagedChannelBuilder> fallbackChannelBuilder,
+ ScheduledExecutorService execService) {
+ checkNotNull(options);
+ checkNotNull(primaryChannelBuilder);
+ checkNotNull(fallbackChannelBuilder);
+ if (execService != null) {
+ this.execService = execService;
+ } else {
+ this.execService =
+ Executors.newScheduledThreadPool(3, GcpThreadFactory.newThreadFactory("gcp-fallback-%d"));
+ }
+ this.options = options;
+ if (options.getGcpOpenTelemetry() != null) {
+ this.openTelemetry = options.getGcpOpenTelemetry();
+ } else {
+ this.openTelemetry = GcpFallbackOpenTelemetry.newBuilder().build();
+ }
+ ManagedChannel primaryChannel = null;
+ try {
+ primaryChannel = primaryChannelBuilder.build();
+ } catch (Exception e) {
+ logger.warning(
+ String.format(
+ "Primary channel initialization failed: %s. Will use fallback channel.",
+ e.getMessage()));
+ }
+ primaryDelegateChannel = primaryChannel;
+
+ ManagedChannel fallbackChannel = null;
+ try {
+ fallbackChannel = fallbackChannelBuilder.build();
+ } catch (Exception e) {
+ if (primaryChannel == null) {
+ throw new RuntimeException(
+ "Both primary and fallback channels initialization failed: " + e.getMessage(), e);
+ }
+
+ logger.warning(
+ String.format(
+ "Fallback channel initialization failed: %s. Will use only the primary channel.",
+ e.getMessage()));
+ }
+ fallbackDelegateChannel = fallbackChannel;
+
+ if (primaryDelegateChannel != null) {
+ this.primaryChannel =
+ ClientInterceptors.intercept(
+ primaryDelegateChannel, new MonitoringInterceptor(this::processPrimaryStatusCode));
+ } else {
+ this.primaryChannel = null;
+ }
+
+ if (fallbackDelegateChannel != null) {
+ this.fallbackChannel =
+ ClientInterceptors.intercept(
+ fallbackDelegateChannel, new MonitoringInterceptor(this::processFallbackStatusCode));
+ } else {
+ this.fallbackChannel = null;
+ }
+
+ init();
+ }
+
+ @VisibleForTesting
+ GcpFallbackChannel(
+ GcpFallbackChannelOptions options,
+ ManagedChannel primaryChannel,
+ ManagedChannel fallbackChannel,
+ ScheduledExecutorService execService) {
+ checkNotNull(options);
+ checkNotNull(primaryChannel);
+ checkNotNull(fallbackChannel);
+ if (execService != null) {
+ this.execService = execService;
+ } else {
+ this.execService =
+ Executors.newScheduledThreadPool(3, GcpThreadFactory.newThreadFactory("gcp-fallback-%d"));
+ }
+ this.options = options;
+ if (options.getGcpOpenTelemetry() != null) {
+ this.openTelemetry = options.getGcpOpenTelemetry();
+ } else {
+ this.openTelemetry = GcpFallbackOpenTelemetry.newBuilder().build();
+ }
+ primaryDelegateChannel = primaryChannel;
+ fallbackDelegateChannel = fallbackChannel;
+ ClientInterceptor primaryMonitorInterceptor =
+ new MonitoringInterceptor(this::processPrimaryStatusCode);
+ this.primaryChannel =
+ ClientInterceptors.intercept(primaryDelegateChannel, primaryMonitorInterceptor);
+ ClientInterceptor fallbackMonitorInterceptor =
+ new MonitoringInterceptor(this::processFallbackStatusCode);
+ this.fallbackChannel =
+ ClientInterceptors.intercept(fallbackDelegateChannel, fallbackMonitorInterceptor);
+ init();
+ }
+
+ public boolean isInFallbackMode() {
+ return inFallbackMode || primaryChannel == null;
+ }
+
+ private void init() {
+ if (options.getPrimaryProbingFunction() != null) {
+ execService.scheduleAtFixedRate(
+ this::probePrimary,
+ options.getPrimaryProbingInterval().toMillis(),
+ options.getPrimaryProbingInterval().toMillis(),
+ MILLISECONDS);
+ }
+
+ if (options.getFallbackProbingFunction() != null) {
+ execService.scheduleAtFixedRate(
+ this::probeFallback,
+ options.getFallbackProbingInterval().toMillis(),
+ options.getFallbackProbingInterval().toMillis(),
+ MILLISECONDS);
+ }
+
+ if (options.isEnableFallback()
+ && options.getPeriod() != null
+ && options.getPeriod().toMillis() > 0) {
+ execService.scheduleAtFixedRate(
+ this::checkErrorRates,
+ options.getPeriod().toMillis(),
+ options.getPeriod().toMillis(),
+ MILLISECONDS);
+ }
+ }
+
+ private void checkErrorRates() {
+ long successes = primarySuccesses.getAndSet(0);
+ long failures = primaryFailures.getAndSet(0);
+ float errRate = 0f;
+ if (failures + successes > 0) {
+ errRate = (float) failures / (failures + successes);
+ }
+ // Report primary error rate.
+ openTelemetry.getModule().reportErrorRate(options.getPrimaryChannelName(), errRate);
+
+ if (!isInFallbackMode() && options.isEnableFallback() && fallbackChannel != null) {
+ if (failures >= options.getMinFailedCalls() && errRate >= options.getErrorRateThreshold()) {
+ if (inFallbackMode != true) {
+ openTelemetry
+ .getModule()
+ .reportFallback(options.getPrimaryChannelName(), options.getFallbackChannelName());
+ }
+ inFallbackMode = true;
+ }
+ }
+ successes = fallbackSuccesses.getAndSet(0);
+ failures = fallbackFailures.getAndSet(0);
+ errRate = 0f;
+ if (failures + successes > 0) {
+ errRate = (float) failures / (failures + successes);
+ }
+ // Report fallback error rate.
+ openTelemetry.getModule().reportErrorRate(options.getFallbackChannelName(), errRate);
+
+ openTelemetry
+ .getModule()
+ .reportCurrentChannel(options.getPrimaryChannelName(), inFallbackMode == false);
+ openTelemetry
+ .getModule()
+ .reportCurrentChannel(options.getFallbackChannelName(), inFallbackMode == true);
+ }
+
+ private void processPrimaryStatusCode(Status.Code statusCode) {
+ if (options.getErroneousStates().contains(statusCode)) {
+ // Count error.
+ primaryFailures.incrementAndGet();
+ } else {
+ // Count success.
+ primarySuccesses.incrementAndGet();
+ }
+ // Report status code.
+ openTelemetry.getModule().reportStatus(options.getPrimaryChannelName(), statusCode);
+ }
+
+ private void processFallbackStatusCode(Status.Code statusCode) {
+ if (options.getErroneousStates().contains(statusCode)) {
+ // Count error.
+ fallbackFailures.incrementAndGet();
+ } else {
+ // Count success.
+ fallbackSuccesses.incrementAndGet();
+ }
+ // Report status code.
+ openTelemetry.getModule().reportStatus(options.getFallbackChannelName(), statusCode);
+ }
+
+ private void probePrimary() {
+ String result = "";
+ if (primaryDelegateChannel == null) {
+ result = INIT_FAILURE_REASON;
+ } else {
+ result = options.getPrimaryProbingFunction().apply(primaryDelegateChannel);
+ }
+ // Report metric based on result.
+ openTelemetry.getModule().reportProbeResult(options.getPrimaryChannelName(), result);
+ }
+
+ private void probeFallback() {
+ String result = "";
+ if (fallbackDelegateChannel == null) {
+ result = INIT_FAILURE_REASON;
+ } else {
+ result = options.getFallbackProbingFunction().apply(fallbackDelegateChannel);
+ }
+ // Report metric based on result.
+ openTelemetry.getModule().reportProbeResult(options.getFallbackChannelName(), result);
+ }
+
+ @Override
+ public ClientCall newCall(
+ MethodDescriptor methodDescriptor, CallOptions callOptions) {
+ if (isInFallbackMode()) {
+ return fallbackChannel.newCall(methodDescriptor, callOptions);
+ }
+
+ return primaryChannel.newCall(methodDescriptor, callOptions);
+ }
+
+ @Override
+ public String authority() {
+ if (isInFallbackMode()) {
+ return fallbackChannel.authority();
+ }
+
+ return primaryChannel.authority();
+ }
+
+ @Override
+ public ManagedChannel shutdown() {
+ if (primaryDelegateChannel != null) {
+ primaryDelegateChannel.shutdown();
+ }
+ if (fallbackDelegateChannel != null) {
+ fallbackDelegateChannel.shutdown();
+ }
+ execService.shutdown();
+ return this;
+ }
+
+ @Override
+ public ManagedChannel shutdownNow() {
+ if (primaryDelegateChannel != null) {
+ primaryDelegateChannel.shutdownNow();
+ }
+ if (fallbackDelegateChannel != null) {
+ fallbackDelegateChannel.shutdownNow();
+ }
+ execService.shutdownNow();
+ return this;
+ }
+
+ @Override
+ public boolean isShutdown() {
+ if (primaryDelegateChannel != null && !primaryDelegateChannel.isShutdown()) {
+ return false;
+ }
+
+ if (fallbackDelegateChannel != null && !fallbackDelegateChannel.isShutdown()) {
+ return false;
+ }
+
+ return execService.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ if (primaryDelegateChannel != null && !primaryDelegateChannel.isTerminated()) {
+ return false;
+ }
+
+ if (fallbackDelegateChannel != null && !fallbackDelegateChannel.isTerminated()) {
+ return false;
+ }
+
+ return execService.isTerminated();
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ long endTimeNanos = System.nanoTime() + unit.toNanos(timeout);
+ if (primaryDelegateChannel != null) {
+ boolean terminated = primaryDelegateChannel.awaitTermination(timeout, unit);
+ if (!terminated) {
+ return false;
+ }
+ }
+
+ long awaitTimeNanos = endTimeNanos - System.nanoTime();
+ if (fallbackDelegateChannel != null) {
+ boolean terminated = fallbackDelegateChannel.awaitTermination(awaitTimeNanos, NANOSECONDS);
+ if (!terminated) {
+ return false;
+ }
+ awaitTimeNanos = endTimeNanos - System.nanoTime();
+ }
+
+ return execService.awaitTermination(awaitTimeNanos, NANOSECONDS);
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackChannelOptions.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackChannelOptions.java
new file mode 100644
index 000000000000..31d5cd981907
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackChannelOptions.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import static io.grpc.Status.Code.DEADLINE_EXCEEDED;
+import static io.grpc.Status.Code.UNAUTHENTICATED;
+import static io.grpc.Status.Code.UNAVAILABLE;
+
+import io.grpc.Channel;
+import io.grpc.Status;
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.function.Function;
+
+public class GcpFallbackChannelOptions {
+ private final boolean enableFallback;
+ private final float errorRateThreshold;
+ private final Set erroneousStates;
+ private final Duration period;
+ private final int minFailedCalls;
+ private final Function primaryProbingFunction;
+ private final Function fallbackProbingFunction;
+ private final Duration primaryProbingInterval;
+ private final Duration fallbackProbingInterval;
+ private final String primaryChannelName;
+ private final String fallbackChannelName;
+ private final GcpFallbackOpenTelemetry openTelemetry;
+
+ public GcpFallbackChannelOptions(Builder builder) {
+ this.enableFallback = builder.enableFallback;
+ this.errorRateThreshold = builder.errorRateThreshold;
+ this.erroneousStates = builder.erroneousStates;
+ this.period = builder.period;
+ this.minFailedCalls = builder.minFailedCalls;
+ this.primaryProbingFunction = builder.primaryProbingFunction;
+ this.fallbackProbingFunction = builder.fallbackProbingFunction;
+ this.primaryProbingInterval = builder.primaryProbingInterval;
+ this.fallbackProbingInterval = builder.fallbackProbingInterval;
+ this.primaryChannelName = builder.primaryChannelName;
+ this.fallbackChannelName = builder.fallbackChannelName;
+ this.openTelemetry = builder.openTelemetry;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public boolean isEnableFallback() {
+ return enableFallback;
+ }
+
+ public float getErrorRateThreshold() {
+ return errorRateThreshold;
+ }
+
+ public Set getErroneousStates() {
+ return erroneousStates;
+ }
+
+ public Duration getPeriod() {
+ return period;
+ }
+
+ public int getMinFailedCalls() {
+ return minFailedCalls;
+ }
+
+ public Function getPrimaryProbingFunction() {
+ return primaryProbingFunction;
+ }
+
+ public Function getFallbackProbingFunction() {
+ return fallbackProbingFunction;
+ }
+
+ public Duration getPrimaryProbingInterval() {
+ return primaryProbingInterval;
+ }
+
+ public Duration getFallbackProbingInterval() {
+ return fallbackProbingInterval;
+ }
+
+ public String getPrimaryChannelName() {
+ return primaryChannelName;
+ }
+
+ public String getFallbackChannelName() {
+ return fallbackChannelName;
+ }
+
+ public GcpFallbackOpenTelemetry getGcpOpenTelemetry() {
+ return openTelemetry;
+ }
+
+ public static class Builder {
+ private boolean enableFallback = true;
+ private float errorRateThreshold = 1f;
+ private Set erroneousStates =
+ EnumSet.of(UNAVAILABLE, DEADLINE_EXCEEDED, UNAUTHENTICATED);
+ private Duration period = Duration.ofMinutes(1);
+ private int minFailedCalls = 3;
+
+ private Function primaryProbingFunction = null;
+ private Function fallbackProbingFunction = null;
+
+ private Duration primaryProbingInterval = Duration.ofMinutes(1);
+ private Duration fallbackProbingInterval = Duration.ofMinutes(15);
+
+ private String primaryChannelName = "primary";
+ private String fallbackChannelName = "fallback";
+
+ private GcpFallbackOpenTelemetry openTelemetry = null;
+
+ public Builder() {}
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public Builder setEnableFallback(boolean enableFallback) {
+ this.enableFallback = enableFallback;
+ return this;
+ }
+
+ public Builder setErrorRateThreshold(float errorRateThreshold) {
+ this.errorRateThreshold = errorRateThreshold;
+ return this;
+ }
+
+ public Builder setErroneousStates(Set erroneousStates) {
+ this.erroneousStates = erroneousStates;
+ return this;
+ }
+
+ public Builder setPeriod(Duration period) {
+ this.period = period;
+ return this;
+ }
+
+ public Builder setMinFailedCalls(int minFailedCalls) {
+ this.minFailedCalls = minFailedCalls;
+ return this;
+ }
+
+ public Builder setProbingFunction(Function probingFunction) {
+ this.primaryProbingFunction = probingFunction;
+ this.fallbackProbingFunction = probingFunction;
+ return this;
+ }
+
+ public Builder setPrimaryProbingFunction(Function primaryProbingFunction) {
+ this.primaryProbingFunction = primaryProbingFunction;
+ return this;
+ }
+
+ public Builder setFallbackProbingFunction(Function fallbackProbingFunction) {
+ this.fallbackProbingFunction = fallbackProbingFunction;
+ return this;
+ }
+
+ public Builder setPrimaryProbingInterval(Duration primaryProbingInterval) {
+ this.primaryProbingInterval = primaryProbingInterval;
+ return this;
+ }
+
+ public Builder setFallbackProbingInterval(Duration fallbackProbingInterval) {
+ this.fallbackProbingInterval = fallbackProbingInterval;
+ return this;
+ }
+
+ public Builder setPrimaryChannelName(String primaryChannelName) {
+ this.primaryChannelName = primaryChannelName;
+ return this;
+ }
+
+ public Builder setFallbackChannelName(String fallbackChannelName) {
+ this.fallbackChannelName = fallbackChannelName;
+ return this;
+ }
+
+ public Builder setGcpFallbackOpenTelemetry(GcpFallbackOpenTelemetry openTelemetry) {
+ this.openTelemetry = openTelemetry;
+ return this;
+ }
+
+ public GcpFallbackChannelOptions build() {
+ return new GcpFallbackChannelOptions(this);
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackOpenTelemetry.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackOpenTelemetry.java
new file mode 100644
index 000000000000..49026e5bcaf7
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/GcpFallbackOpenTelemetry.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import static com.google.cloud.grpc.GrpcGcpUtil.IMPLEMENTATION_VERSION;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.metrics.Meter;
+import io.opentelemetry.api.metrics.MeterProvider;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * The entrypoint for OpenTelemetry metrics functionality in gRPC-GCP Fallback channel.
+ *
+ * GcpFallbackOpenTelemetry uses {@link io.opentelemetry.api.OpenTelemetry} APIs for
+ * instrumentation. When no SDK is explicitly added no telemetry data will be collected. See {@code
+ * io.opentelemetry.sdk.OpenTelemetrySdk} for information on how to construct the SDK.
+ */
+public final class GcpFallbackOpenTelemetry {
+ static final String INSTRUMENTATION_SCOPE = "grpc-gcp";
+ static final String METRIC_PREFIX = "eef";
+
+ static final String CURRENT_CHANNEL_METRIC = "current_channel";
+ static final String FALLBACK_COUNT_METRIC = "fallback_count";
+ static final String CALL_STATUS_METRIC = "call_status";
+ static final String ERROR_RATIO_METRIC = "error_ratio";
+ static final String PROBE_RESULT_METRIC = "probe_result";
+ static final String CHANNEL_DOWNTIME_METRIC = "channel_downtime";
+
+ static final AttributeKey CHANNEL_NAME = AttributeKey.stringKey("channel_name");
+ static final AttributeKey FROM_CHANNEL_NAME = AttributeKey.stringKey("from_channel_name");
+ static final AttributeKey TO_CHANNEL_NAME = AttributeKey.stringKey("to_channel_name");
+ static final AttributeKey STATUS_CODE = AttributeKey.stringKey("status_code");
+ static final AttributeKey PROBE_RESULT = AttributeKey.stringKey("result");
+
+ static final ImmutableSet DEFAULT_METRICS_SET =
+ ImmutableSet.of(
+ CURRENT_CHANNEL_METRIC,
+ FALLBACK_COUNT_METRIC,
+ CALL_STATUS_METRIC,
+ ERROR_RATIO_METRIC,
+ PROBE_RESULT_METRIC,
+ CHANNEL_DOWNTIME_METRIC);
+
+ private final OpenTelemetry openTelemetrySdk;
+ private final MeterProvider meterProvider;
+ private final Meter meter;
+ private final Map enableMetrics;
+ private final boolean disableDefault;
+ private final OpenTelemetryMetricsModule openTelemetryMetricsModule;
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ private GcpFallbackOpenTelemetry(Builder builder) {
+ this.openTelemetrySdk = checkNotNull(builder.openTelemetrySdk, "openTelemetrySdk");
+ this.meterProvider = checkNotNull(openTelemetrySdk.getMeterProvider(), "meterProvider");
+ this.meter =
+ this.meterProvider
+ .meterBuilder(INSTRUMENTATION_SCOPE)
+ .setInstrumentationVersion(IMPLEMENTATION_VERSION)
+ .build();
+ this.enableMetrics = ImmutableMap.copyOf(builder.enableMetrics);
+ this.disableDefault = builder.disableAll;
+ this.openTelemetryMetricsModule =
+ new OpenTelemetryMetricsModule(meter, enableMetrics, disableDefault);
+ }
+
+ /** Builder for configuring {@link GcpFallbackOpenTelemetry}. */
+ public static class Builder {
+ private OpenTelemetry openTelemetrySdk = OpenTelemetry.noop();
+ private final Map enableMetrics = new HashMap<>();
+ private boolean disableAll;
+
+ private Builder() {}
+
+ /**
+ * Sets the {@link io.opentelemetry.api.OpenTelemetry} entrypoint to use. This can be used to
+ * configure OpenTelemetry by returning the instance created by a {@code
+ * io.opentelemetry.sdk.OpenTelemetrySdkBuilder}.
+ */
+ public Builder withSdk(OpenTelemetry sdk) {
+ this.openTelemetrySdk = sdk;
+ return this;
+ }
+
+ /**
+ * Enables the specified metrics for collection and export. By default, all metrics are enabled.
+ */
+ public Builder enableMetrics(Collection enableMetrics) {
+ for (String metric : enableMetrics) {
+ this.enableMetrics.put(metric, true);
+ }
+ return this;
+ }
+
+ /** Disables the specified metrics from being collected and exported. */
+ public Builder disableMetrics(Collection disableMetrics) {
+ for (String metric : disableMetrics) {
+ this.enableMetrics.put(metric, false);
+ }
+ return this;
+ }
+
+ /** Disable all metrics. Any desired metric must be explicitly enabled after this. */
+ public Builder disableAllMetrics() {
+ this.enableMetrics.clear();
+ this.disableAll = true;
+ return this;
+ }
+
+ /**
+ * Returns a new {@link GcpFallbackOpenTelemetry} built with the configuration of this {@link
+ * Builder}.
+ */
+ public GcpFallbackOpenTelemetry build() {
+ return new GcpFallbackOpenTelemetry(this);
+ }
+ }
+
+ OpenTelemetryMetricsModule getModule() {
+ return openTelemetryMetricsModule;
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/MonitoringInterceptor.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/MonitoringInterceptor.java
new file mode 100644
index 000000000000..e7be6b24caf5
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/MonitoringInterceptor.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import io.grpc.CallOptions;
+import io.grpc.Channel;
+import io.grpc.ClientCall;
+import io.grpc.ClientInterceptor;
+import io.grpc.ForwardingClientCall;
+import io.grpc.ForwardingClientCallListener;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.grpc.Status;
+import io.grpc.Status.Code;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import javax.annotation.Nullable;
+
+class MonitoringInterceptor implements ClientInterceptor {
+ private Consumer statusCodeConsumer;
+
+ MonitoringInterceptor(Consumer statusCodeConsumer) {
+ this.statusCodeConsumer = statusCodeConsumer;
+ }
+
+ @Override
+ public ClientCall interceptCall(
+ MethodDescriptor method, CallOptions callOptions, Channel next) {
+ return new MonitoredClientCall<>(statusCodeConsumer, next, method, callOptions);
+ }
+
+ static class MonitoredClientCall extends ForwardingClientCall {
+
+ private final ClientCall delegateCall;
+ private final AtomicBoolean decremented = new AtomicBoolean(false);
+ private final Consumer statusCodeConsumer;
+
+ protected MonitoredClientCall(
+ Consumer statusCodeConsumer,
+ Channel channel,
+ MethodDescriptor methodDescriptor,
+ CallOptions callOptions) {
+ this.statusCodeConsumer = statusCodeConsumer;
+ this.delegateCall = channel.newCall(methodDescriptor, callOptions);
+ }
+
+ @Override
+ protected ClientCall delegate() {
+ return delegateCall;
+ }
+
+ @Override
+ public void start(Listener responseListener, Metadata headers) {
+
+ Listener listener =
+ new ForwardingClientCallListener.SimpleForwardingClientCallListener(
+ responseListener) {
+ @Override
+ public void onClose(Status status, Metadata trailers) {
+ // Use atomic to account for the race between onClose and cancel.
+ if (!decremented.getAndSet(true)) {
+ statusCodeConsumer.accept(status.getCode());
+ }
+ super.onClose(status, trailers);
+ }
+ };
+
+ delegateCall.start(listener, headers);
+ }
+
+ @Override
+ public void cancel(@Nullable String message, @Nullable Throwable cause) {
+ // Use atomic to account for the race between onClose and cancel.
+ if (!decremented.getAndSet(true)) {
+ statusCodeConsumer.accept(Status.Code.CANCELLED);
+ }
+ delegateCall.cancel(message, cause);
+ }
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/OpenTelemetryMetricsModule.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/OpenTelemetryMetricsModule.java
new file mode 100644
index 000000000000..d8ddaf97b163
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/OpenTelemetryMetricsModule.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.CALL_STATUS_METRIC;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.CHANNEL_DOWNTIME_METRIC;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.CHANNEL_NAME;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.CURRENT_CHANNEL_METRIC;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.DEFAULT_METRICS_SET;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.ERROR_RATIO_METRIC;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.FALLBACK_COUNT_METRIC;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.FROM_CHANNEL_NAME;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.METRIC_PREFIX;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.PROBE_RESULT;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.PROBE_RESULT_METRIC;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.STATUS_CODE;
+import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.TO_CHANNEL_NAME;
+
+import io.grpc.Status.Code;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.DoubleGauge;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.Meter;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+final class OpenTelemetryMetricsModule {
+ private final OpenTelemetryMetricsResource resource;
+ private final Map firstFailure = new ConcurrentHashMap<>();
+ private final Map channelActive = new ConcurrentHashMap<>();
+
+ OpenTelemetryMetricsModule(
+ Meter meter, Map enableMetrics, boolean disableDefault) {
+ this.resource = createMetricInstruments(meter, enableMetrics, disableDefault);
+ }
+
+ static boolean isMetricEnabled(
+ String metricName, Map enableMetrics, boolean disableDefault) {
+ Boolean explicitlyEnabled = enableMetrics.get(metricName);
+ if (explicitlyEnabled != null) {
+ return explicitlyEnabled;
+ }
+ return DEFAULT_METRICS_SET.contains(metricName) && !disableDefault;
+ }
+
+ private OpenTelemetryMetricsResource createMetricInstruments(
+ Meter meter, Map enableMetrics, boolean disableDefault) {
+ OpenTelemetryMetricsResource.Builder builder = OpenTelemetryMetricsResource.builder();
+
+ if (isMetricEnabled(CURRENT_CHANNEL_METRIC, enableMetrics, disableDefault)) {
+ builder.currentChannelCounter(
+ meter
+ .upDownCounterBuilder(String.format("%s.%s", METRIC_PREFIX, CURRENT_CHANNEL_METRIC))
+ .setUnit("{channel}")
+ .setDescription("1 for currently active channel, 0 otherwise.")
+ .buildWithCallback(
+ counter -> {
+ channelActive.forEach(
+ (channelName, isActive) -> {
+ counter.record(
+ isActive ? 1 : 0, Attributes.of(CHANNEL_NAME, channelName));
+ });
+ }));
+ }
+
+ if (isMetricEnabled(FALLBACK_COUNT_METRIC, enableMetrics, disableDefault)) {
+ builder.fallbackCounter(
+ meter
+ .counterBuilder(String.format("%s.%s", METRIC_PREFIX, FALLBACK_COUNT_METRIC))
+ .setUnit("{occurrence}")
+ .setDescription("Number of fallbacks occurred from one channel to another.")
+ .build());
+ }
+
+ if (isMetricEnabled(CALL_STATUS_METRIC, enableMetrics, disableDefault)) {
+ builder.callStatusCounter(
+ meter
+ .counterBuilder(String.format("%s.%s", METRIC_PREFIX, CALL_STATUS_METRIC))
+ .setUnit("{call}")
+ .setDescription("Number of calls with a status and channel.")
+ .build());
+ }
+
+ if (isMetricEnabled(ERROR_RATIO_METRIC, enableMetrics, disableDefault)) {
+ builder.errorRatioGauge(
+ meter
+ .gaugeBuilder(String.format("%s.%s", METRIC_PREFIX, ERROR_RATIO_METRIC))
+ .setUnit("1")
+ .setDescription("Ratio of failed calls to total calls for a channel.")
+ .build());
+ }
+
+ if (isMetricEnabled(PROBE_RESULT_METRIC, enableMetrics, disableDefault)) {
+ builder.probeResultCounter(
+ meter
+ .counterBuilder(String.format("%s.%s", METRIC_PREFIX, PROBE_RESULT_METRIC))
+ .setUnit("{result}")
+ .setDescription("Results of probing functions execution.")
+ .build());
+ }
+
+ if (isMetricEnabled(CHANNEL_DOWNTIME_METRIC, enableMetrics, disableDefault)) {
+ builder.channelDowntimeGauge(
+ meter
+ .gaugeBuilder(String.format("%s.%s", METRIC_PREFIX, CHANNEL_DOWNTIME_METRIC))
+ .setUnit("s")
+ .setDescription("How many consecutive seconds probing fails for the channel.")
+ .build());
+ }
+
+ return builder.build();
+ }
+
+ void reportErrorRate(String channelName, float errorRate) {
+ DoubleGauge errorRatioGauge = resource.errorRatioGauge();
+
+ if (errorRatioGauge == null) {
+ return;
+ }
+
+ Attributes attributes = Attributes.of(CHANNEL_NAME, channelName);
+ errorRatioGauge.set(errorRate, attributes);
+ }
+
+ void reportStatus(String channelName, Code statusCode) {
+ LongCounter callStatusCounter = resource.callStatusCounter();
+ if (callStatusCounter == null) {
+ return;
+ }
+
+ Attributes attributes =
+ Attributes.of(CHANNEL_NAME, channelName, STATUS_CODE, statusCode.toString());
+
+ callStatusCounter.add(1, attributes);
+ }
+
+ void reportProbeResult(String channelName, String result) {
+ if (result == null) {
+ return;
+ }
+
+ LongCounter probeResultCounter = resource.probeResultCounter();
+ if (probeResultCounter != null) {
+
+ Attributes attributes =
+ Attributes.of(
+ CHANNEL_NAME, channelName,
+ PROBE_RESULT, result);
+
+ probeResultCounter.add(1, attributes);
+ }
+
+ DoubleGauge downtimeGauge = resource.channelDowntimeGauge();
+ if (downtimeGauge == null) {
+ return;
+ }
+
+ Attributes attributes = Attributes.of(CHANNEL_NAME, channelName);
+
+ if (result.isEmpty()) {
+ firstFailure.remove(channelName);
+ downtimeGauge.set(0, attributes);
+ } else {
+ firstFailure.putIfAbsent(channelName, System.nanoTime());
+ downtimeGauge.set(
+ (double) (System.nanoTime() - firstFailure.get(channelName)) / 1_000_000_000, attributes);
+ }
+ }
+
+ void reportCurrentChannel(String channelName, boolean current) {
+ channelActive.put(channelName, current);
+ }
+
+ void reportFallback(String fromChannelName, String toChannelName) {
+ LongCounter fallbackCounter = resource.fallbackCounter();
+ if (fallbackCounter == null) {
+ return;
+ }
+
+ Attributes attributes =
+ Attributes.of(
+ FROM_CHANNEL_NAME, fromChannelName,
+ TO_CHANNEL_NAME, toChannelName);
+
+ fallbackCounter.add(1, attributes);
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/OpenTelemetryMetricsResource.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/OpenTelemetryMetricsResource.java
new file mode 100644
index 000000000000..00a127b937bc
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/fallback/OpenTelemetryMetricsResource.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.fallback;
+
+import com.google.auto.value.AutoValue;
+import com.google.cloud.grpc.fallback.AutoValue_OpenTelemetryMetricsResource.Builder;
+import io.opentelemetry.api.metrics.DoubleGauge;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.ObservableLongUpDownCounter;
+import javax.annotation.Nullable;
+
+@AutoValue
+abstract class OpenTelemetryMetricsResource {
+
+ @Nullable
+ abstract ObservableLongUpDownCounter currentChannelCounter();
+
+ @Nullable
+ abstract LongCounter fallbackCounter();
+
+ @Nullable
+ abstract LongCounter callStatusCounter();
+
+ @Nullable
+ abstract DoubleGauge errorRatioGauge();
+
+ @Nullable
+ abstract LongCounter probeResultCounter();
+
+ @Nullable
+ abstract DoubleGauge channelDowntimeGauge();
+
+ static Builder builder() {
+ return new AutoValue_OpenTelemetryMetricsResource.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+ abstract Builder currentChannelCounter(ObservableLongUpDownCounter counter);
+
+ abstract Builder fallbackCounter(LongCounter counter);
+
+ abstract Builder callStatusCounter(LongCounter counter);
+
+ abstract Builder errorRatioGauge(DoubleGauge gauge);
+
+ abstract Builder probeResultCounter(LongCounter counter);
+
+ abstract Builder channelDowntimeGauge(DoubleGauge gauge);
+
+ abstract OpenTelemetryMetricsResource build();
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/multiendpoint/Endpoint.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/multiendpoint/Endpoint.java
new file mode 100644
index 000000000000..c6b861794864
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/multiendpoint/Endpoint.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.multiendpoint;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.util.concurrent.ScheduledFuture;
+
+/** Endpoint holds an endpoint's state, priority and a future of upcoming state change. */
+@CheckReturnValue
+final class Endpoint {
+
+ /** Holds a state of an endpoint. */
+ public enum EndpointState {
+ UNAVAILABLE,
+ AVAILABLE,
+ RECOVERING,
+ }
+
+ private final String id;
+ private EndpointState state;
+ private long lastStateChangeNano;
+ private int priority;
+ private ScheduledFuture> changeStateFuture;
+ private final MultiEndpoint multiEndpoint;
+
+ Endpoint(String id, EndpointState state, int priority, MultiEndpoint multiEndpoint) {
+ this.id = id;
+ this.priority = priority;
+ this.multiEndpoint = multiEndpoint;
+ setAvailability(EndpointState.AVAILABLE.equals(state));
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public EndpointState getState() {
+ return state;
+ }
+
+ public int getPriority() {
+ return priority;
+ }
+
+ public void setPriority(int priority) {
+ this.priority = priority;
+ }
+
+ // Internal method to change state to any state.
+ private void setState(EndpointState state) {
+ synchronized (multiEndpoint) {
+ if (changeStateFuture != null) {
+ changeStateFuture.cancel(false);
+ }
+ this.state = state;
+ lastStateChangeNano = System.nanoTime();
+ }
+ }
+
+ void setAvailability(boolean available) {
+ synchronized (multiEndpoint) {
+ if (available) {
+ setState(EndpointState.AVAILABLE);
+ return;
+ }
+
+ if (state != null && !EndpointState.AVAILABLE.equals(state)) {
+ return;
+ }
+
+ if (!multiEndpoint.isRecoveryEnabled()) {
+ setState(EndpointState.UNAVAILABLE);
+ return;
+ }
+
+ setState(EndpointState.RECOVERING);
+ final long stateChangeNano = lastStateChangeNano;
+ changeStateFuture =
+ multiEndpoint.executor.schedule(
+ () -> triggerRecoveryTimeout(stateChangeNano),
+ multiEndpoint.getRecoveryTimeout().toMillis(),
+ MILLISECONDS);
+ }
+ }
+
+ private void triggerRecoveryTimeout(long statusChangeNano) {
+ synchronized (multiEndpoint) {
+ if (lastStateChangeNano != statusChangeNano) {
+ // This timer is outdated.
+ return;
+ }
+
+ setState(EndpointState.UNAVAILABLE);
+ multiEndpoint.maybeUpdateCurrentEndpoint();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "Endpoint{"
+ + "id='"
+ + id
+ + "', state="
+ + state
+ + ", lastStateChangeNano="
+ + lastStateChangeNano
+ + ", priority="
+ + priority
+ + ", changeStateFuture="
+ + changeStateFuture
+ + '}';
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/multiendpoint/MultiEndpoint.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/multiendpoint/MultiEndpoint.java
new file mode 100644
index 000000000000..3db658b72942
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/multiendpoint/MultiEndpoint.java
@@ -0,0 +1,313 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc.multiendpoint;
+
+import static java.util.Comparator.comparingInt;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.cloud.grpc.GcpThreadFactory;
+import com.google.cloud.grpc.multiendpoint.Endpoint.EndpointState;
+import com.google.common.base.Preconditions;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.errorprone.annotations.concurrent.GuardedBy;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.stream.Collectors;
+
+/**
+ * MultiEndpoint holds a list of endpoints, tracks their availability and defines the current
+ * endpoint. An endpoint has a priority defined by its position in the list (first item has top
+ * priority). MultiEndpoint returns top priority endpoint that is available as current. If no
+ * endpoint is available, MultiEndpoint returns the top priority endpoint.
+ *
+ * Sometimes switching between endpoints can be costly, and it is worth waiting for some time
+ * after current endpoint becomes unavailable. For this case, use {@link
+ * Builder#withRecoveryTimeout} to set the recovery timeout. MultiEndpoint will keep the current
+ * endpoint for up to recovery timeout after it became unavailable to give it some time to recover.
+ *
+ *
The list of endpoints can be changed at any time with {@link #setEndpoints} method.
+ * MultiEndpoint will preserve endpoints' state and update their priority according to their new
+ * positions.
+ *
+ *
After updating the list of endpoints, MultiEndpoint will switch the current endpoint to the
+ * top-priority available endpoint. If you have many processes using MultiEndpoint, this may lead to
+ * immediate shift of all traffic which may be undesired. To smooth this transfer, use {@link
+ * Builder#withSwitchingDelay} with randomized value to introduce a jitter. MultiEndpoint will delay
+ * switching from an available endpoint to another endpoint for this amount of time.
+ *
+ *
The initial state of endpoint is "unavailable" or "recovering" if using recovery timeout.
+ */
+@CheckReturnValue
+public final class MultiEndpoint {
+ @GuardedBy("this")
+ private final Map endpointsMap = new HashMap<>();
+
+ @GuardedBy("this")
+ private volatile String currentId;
+
+ private final Duration recoveryTimeout;
+ private final Duration switchingDelay;
+
+ @GuardedBy("this")
+ private ScheduledFuture> scheduledSwitch;
+
+ @GuardedBy("this")
+ private volatile String switchTo;
+
+ private long fallbackCnt = 0;
+ private long recoverCnt = 0;
+ private long replaceCnt = 0;
+
+ final ScheduledExecutorService executor =
+ new ScheduledThreadPoolExecutor(1, GcpThreadFactory.newThreadFactory("gcp-me-core-%d"));
+
+ private MultiEndpoint(Builder builder) {
+ this.recoveryTimeout = builder.recoveryTimeout;
+ this.switchingDelay = builder.switchingDelay;
+ this.setEndpoints(builder.endpoints);
+ }
+
+ /** Builder for MultiEndpoint. */
+ public static final class Builder {
+ private final List endpoints;
+ private Duration recoveryTimeout = Duration.ZERO;
+ private Duration switchingDelay = Duration.ZERO;
+
+ public Builder(List endpoints) {
+ Preconditions.checkNotNull(endpoints);
+ Preconditions.checkArgument(!endpoints.isEmpty(), "Endpoints list must not be empty.");
+ this.endpoints = endpoints;
+ }
+
+ /**
+ * MultiEndpoint will keep the current endpoint for up to recovery timeout after it became
+ * unavailable to give it some time to recover.
+ */
+ @CanIgnoreReturnValue
+ public Builder withRecoveryTimeout(Duration timeout) {
+ Preconditions.checkNotNull(timeout);
+ recoveryTimeout = timeout;
+ return this;
+ }
+
+ /**
+ * MultiEndpoint will delay switching from an available endpoint to another endpoint for this
+ * amount of time.
+ */
+ @CanIgnoreReturnValue
+ public Builder withSwitchingDelay(Duration delay) {
+ Preconditions.checkNotNull(delay);
+ switchingDelay = delay;
+ return this;
+ }
+
+ public MultiEndpoint build() {
+ return new MultiEndpoint(this);
+ }
+ }
+
+ /**
+ * Returns current endpoint id.
+ *
+ * Note that the read is not synchronized and in case of a race condition there is a chance of
+ * getting an outdated current id.
+ */
+ @SuppressWarnings("GuardedBy")
+ public String getCurrentId() {
+ return currentId;
+ }
+
+ public long getFallbackCnt() {
+ return fallbackCnt;
+ }
+
+ public long getRecoverCnt() {
+ return recoverCnt;
+ }
+
+ public long getReplaceCnt() {
+ return replaceCnt;
+ }
+
+ public List getEndpoints() {
+ return endpointsMap.values().stream()
+ .sorted(comparingInt(Endpoint::getPriority))
+ .map(Endpoint::getId)
+ .collect(Collectors.toList());
+ }
+
+ synchronized Map getEndpointsMap() {
+ return endpointsMap;
+ }
+
+ Duration getRecoveryTimeout() {
+ return recoveryTimeout;
+ }
+
+ boolean isRecoveryEnabled() {
+ return !recoveryTimeout.isNegative() && !recoveryTimeout.isZero();
+ }
+
+ private boolean isSwitchingDelayed() {
+ return !switchingDelay.isNegative() && !switchingDelay.isZero();
+ }
+
+ /** Inform MultiEndpoint when an endpoint becomes available or unavailable. */
+ public synchronized void setEndpointAvailable(String endpointId, boolean available) {
+ Endpoint endpoint = endpointsMap.get(endpointId);
+ if (endpoint == null) {
+ return;
+ }
+
+ endpoint.setAvailability(available);
+ maybeUpdateCurrentEndpoint();
+ }
+
+ /**
+ * Provide an updated list of endpoints to MultiEndpoint.
+ *
+ * MultiEndpoint will preserve current endpoints' state and update their priority according to
+ * their new positions.
+ */
+ public synchronized void setEndpoints(List endpoints) {
+ Preconditions.checkNotNull(endpoints);
+ Preconditions.checkArgument(!endpoints.isEmpty(), "Endpoints list must not be empty.");
+
+ // Remove obsolete endpoints.
+ endpointsMap.keySet().retainAll(endpoints);
+
+ // Add new endpoints and update priority.
+ int priority = 0;
+ for (String endpointId : endpoints) {
+ Endpoint existingEndpoint = endpointsMap.get(endpointId);
+ if (existingEndpoint != null) {
+ existingEndpoint.setPriority(priority++);
+ continue;
+ }
+ endpointsMap.put(
+ endpointId, new Endpoint(endpointId, EndpointState.UNAVAILABLE, priority++, this));
+ }
+
+ maybeUpdateCurrentEndpoint();
+ }
+
+ // Updates currentId to the top-priority available endpoint unless the current endpoint is
+ // recovering.
+ synchronized void maybeUpdateCurrentEndpoint() {
+ Optional topEndpoint =
+ endpointsMap.values().stream()
+ .filter((c) -> c.getState().equals(EndpointState.AVAILABLE))
+ .min(comparingInt(Endpoint::getPriority));
+
+ Endpoint current = endpointsMap.get(currentId);
+ if (current != null && current.getState().equals(EndpointState.RECOVERING)) {
+ // Keep recovering endpoint as current unless a higher priority endpoint became available.
+ if (!topEndpoint.isPresent() || topEndpoint.get().getPriority() >= current.getPriority()) {
+ return;
+ }
+ }
+
+ if (!topEndpoint.isPresent() && current == null) {
+ topEndpoint = endpointsMap.values().stream().min(comparingInt(Endpoint::getPriority));
+ }
+
+ topEndpoint.ifPresent(endpoint -> updateCurrentEndpoint(current, endpoint.getId()));
+ }
+
+ private synchronized void updateCurrentEndpoint(Endpoint current, String newCurrentId) {
+ // If no current or became unavailable then switch immediately.
+ if (current == null || current.getState().equals(EndpointState.UNAVAILABLE)) {
+ registerSwitch(currentId, newCurrentId);
+ currentId = newCurrentId;
+ return;
+ }
+
+ if (!isSwitchingDelayed()) {
+ registerSwitch(currentId, newCurrentId);
+ currentId = newCurrentId;
+ return;
+ }
+
+ switchTo = newCurrentId;
+ if (switchTo.equals(currentId)) {
+ return;
+ }
+
+ if (scheduledSwitch == null || scheduledSwitch.isDone()) {
+ scheduledSwitch =
+ executor.schedule(this::switchCurrentEndpoint, switchingDelay.toMillis(), MILLISECONDS);
+ }
+ }
+
+ private synchronized void switchCurrentEndpoint() {
+ registerSwitch(currentId, switchTo);
+ currentId = switchTo;
+ }
+
+ private void registerSwitch(String fromId, String toId) {
+ if (toId.equals(fromId) || fromId == null) {
+ return;
+ }
+
+ int fromIdx = -1;
+ int toIdx = -1;
+ for (Endpoint e : endpointsMap.values()) {
+ if (e.getId().equals(fromId)) {
+ fromIdx = e.getPriority();
+ } else if (e.getId().equals(toId)) {
+ toIdx = e.getPriority();
+ }
+ }
+
+ if (fromIdx == -1) {
+ replaceCnt++;
+ } else if (fromIdx < toIdx) {
+ fallbackCnt++;
+ } else {
+ recoverCnt++;
+ }
+ }
+
+ // It is okay to read currentId and endpointsMap without obtaining a lock here.
+ @SuppressWarnings("GuardedBy")
+ @Override
+ public String toString() {
+ return "MultiEndpoint{"
+ + "endpointsMap="
+ + endpointsMap
+ + ", currentId='"
+ + currentId
+ + "', recoveryTimeout="
+ + recoveryTimeout
+ + ", switchingDelay="
+ + switchingDelay
+ + ", scheduledSwitch="
+ + scheduledSwitch
+ + ", switchTo='"
+ + switchTo
+ + "', executor="
+ + executor
+ + '}';
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/AffinityConfig.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/AffinityConfig.java
new file mode 100644
index 000000000000..48b55e254749
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/AffinityConfig.java
@@ -0,0 +1,951 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+/** Protobuf type {@code grpc.gcp.AffinityConfig} */
+public final class AffinityConfig extends com.google.protobuf.GeneratedMessageV3
+ implements
+ // @@protoc_insertion_point(message_implements:grpc.gcp.AffinityConfig)
+ AffinityConfigOrBuilder {
+ private static final long serialVersionUID = 0L;
+
+ // Use AffinityConfig.newBuilder() to construct.
+ private AffinityConfig(com.google.protobuf.GeneratedMessageV3.Builder> builder) {
+ super(builder);
+ }
+
+ private AffinityConfig() {
+ command_ = 0;
+ affinityKey_ = "";
+ }
+
+ @java.lang.Override
+ @SuppressWarnings({"unused"})
+ protected java.lang.Object newInstance(UnusedPrivateParameter unused) {
+ return new AffinityConfig();
+ }
+
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_AffinityConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_AffinityConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.AffinityConfig.class,
+ com.google.cloud.grpc.proto.AffinityConfig.Builder.class);
+ }
+
+ /** Protobuf enum {@code grpc.gcp.AffinityConfig.Command} */
+ public enum Command implements com.google.protobuf.ProtocolMessageEnum {
+ /**
+ *
+ *
+ *
+ * The annotated method will be required to be bound to an existing session
+ * to execute the RPC. The corresponding <affinity_key_field_path> will be
+ * used to find the affinity key from the request message.
+ *
+ *
+ * BOUND = 0;
+ */
+ BOUND(0),
+ /**
+ *
+ *
+ *
+ * The annotated method will establish the channel affinity with the channel
+ * which is used to execute the RPC. The corresponding
+ * <affinity_key_field_path> will be used to find the affinity key from the
+ * response message.
+ *
+ *
+ * BIND = 1;
+ */
+ BIND(1),
+ /**
+ *
+ *
+ *
+ * The annotated method will remove the channel affinity with the channel
+ * which is used to execute the RPC. The corresponding
+ * <affinity_key_field_path> will be used to find the affinity key from the
+ * request message.
+ *
+ *
+ * UNBIND = 2;
+ */
+ UNBIND(2),
+ UNRECOGNIZED(-1),
+ ;
+
+ /**
+ *
+ *
+ *
+ * The annotated method will be required to be bound to an existing session
+ * to execute the RPC. The corresponding <affinity_key_field_path> will be
+ * used to find the affinity key from the request message.
+ *
+ *
+ * BOUND = 0;
+ */
+ public static final int BOUND_VALUE = 0;
+
+ /**
+ *
+ *
+ *
+ * The annotated method will establish the channel affinity with the channel
+ * which is used to execute the RPC. The corresponding
+ * <affinity_key_field_path> will be used to find the affinity key from the
+ * response message.
+ *
+ *
+ * BIND = 1;
+ */
+ public static final int BIND_VALUE = 1;
+
+ /**
+ *
+ *
+ *
+ * The annotated method will remove the channel affinity with the channel
+ * which is used to execute the RPC. The corresponding
+ * <affinity_key_field_path> will be used to find the affinity key from the
+ * request message.
+ *
+ *
+ * UNBIND = 2;
+ */
+ public static final int UNBIND_VALUE = 2;
+
+ public final int getNumber() {
+ if (this == UNRECOGNIZED) {
+ throw new java.lang.IllegalArgumentException(
+ "Can't get the number of an unknown enum value.");
+ }
+ return value;
+ }
+
+ /**
+ * @param value The numeric wire value of the corresponding enum entry.
+ * @return The enum associated with the given numeric wire value.
+ * @deprecated Use {@link #forNumber(int)} instead.
+ */
+ @java.lang.Deprecated
+ public static Command valueOf(int value) {
+ return forNumber(value);
+ }
+
+ /**
+ * @param value The numeric wire value of the corresponding enum entry.
+ * @return The enum associated with the given numeric wire value.
+ */
+ public static Command forNumber(int value) {
+ switch (value) {
+ case 0:
+ return BOUND;
+ case 1:
+ return BIND;
+ case 2:
+ return UNBIND;
+ default:
+ return null;
+ }
+ }
+
+ public static com.google.protobuf.Internal.EnumLiteMap internalGetValueMap() {
+ return internalValueMap;
+ }
+
+ private static final com.google.protobuf.Internal.EnumLiteMap internalValueMap =
+ new com.google.protobuf.Internal.EnumLiteMap() {
+ public Command findValueByNumber(int number) {
+ return Command.forNumber(number);
+ }
+ };
+
+ public final com.google.protobuf.Descriptors.EnumValueDescriptor getValueDescriptor() {
+ if (this == UNRECOGNIZED) {
+ throw new java.lang.IllegalStateException(
+ "Can't get the descriptor of an unrecognized enum value.");
+ }
+ return getDescriptor().getValues().get(ordinal());
+ }
+
+ public final com.google.protobuf.Descriptors.EnumDescriptor getDescriptorForType() {
+ return getDescriptor();
+ }
+
+ public static final com.google.protobuf.Descriptors.EnumDescriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.AffinityConfig.getDescriptor().getEnumTypes().get(0);
+ }
+
+ private static final Command[] VALUES = values();
+
+ public static Command valueOf(com.google.protobuf.Descriptors.EnumValueDescriptor desc) {
+ if (desc.getType() != getDescriptor()) {
+ throw new java.lang.IllegalArgumentException("EnumValueDescriptor is not for this type.");
+ }
+ if (desc.getIndex() == -1) {
+ return UNRECOGNIZED;
+ }
+ return VALUES[desc.getIndex()];
+ }
+
+ private final int value;
+
+ private Command(int value) {
+ this.value = value;
+ }
+
+ // @@protoc_insertion_point(enum_scope:grpc.gcp.AffinityConfig.Command)
+ }
+
+ public static final int COMMAND_FIELD_NUMBER = 2;
+ private int command_ = 0;
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return The enum numeric value on the wire for command.
+ */
+ @java.lang.Override
+ public int getCommandValue() {
+ return command_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return The command.
+ */
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig.Command getCommand() {
+ com.google.cloud.grpc.proto.AffinityConfig.Command result =
+ com.google.cloud.grpc.proto.AffinityConfig.Command.forNumber(command_);
+ return result == null
+ ? com.google.cloud.grpc.proto.AffinityConfig.Command.UNRECOGNIZED
+ : result;
+ }
+
+ public static final int AFFINITY_KEY_FIELD_NUMBER = 3;
+
+ @SuppressWarnings("serial")
+ private volatile java.lang.Object affinityKey_ = "";
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return The affinityKey.
+ */
+ @java.lang.Override
+ public java.lang.String getAffinityKey() {
+ java.lang.Object ref = affinityKey_;
+ if (ref instanceof java.lang.String) {
+ return (java.lang.String) ref;
+ } else {
+ com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref;
+ java.lang.String s = bs.toStringUtf8();
+ affinityKey_ = s;
+ return s;
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return The bytes for affinityKey.
+ */
+ @java.lang.Override
+ public com.google.protobuf.ByteString getAffinityKeyBytes() {
+ java.lang.Object ref = affinityKey_;
+ if (ref instanceof java.lang.String) {
+ com.google.protobuf.ByteString b =
+ com.google.protobuf.ByteString.copyFromUtf8((java.lang.String) ref);
+ affinityKey_ = b;
+ return b;
+ } else {
+ return (com.google.protobuf.ByteString) ref;
+ }
+ }
+
+ private byte memoizedIsInitialized = -1;
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ byte isInitialized = memoizedIsInitialized;
+ if (isInitialized == 1) return true;
+ if (isInitialized == 0) return false;
+
+ memoizedIsInitialized = 1;
+ return true;
+ }
+
+ @java.lang.Override
+ public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException {
+ if (command_ != com.google.cloud.grpc.proto.AffinityConfig.Command.BOUND.getNumber()) {
+ output.writeEnum(2, command_);
+ }
+ if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(affinityKey_)) {
+ com.google.protobuf.GeneratedMessageV3.writeString(output, 3, affinityKey_);
+ }
+ getUnknownFields().writeTo(output);
+ }
+
+ @java.lang.Override
+ public int getSerializedSize() {
+ int size = memoizedSize;
+ if (size != -1) return size;
+
+ size = 0;
+ if (command_ != com.google.cloud.grpc.proto.AffinityConfig.Command.BOUND.getNumber()) {
+ size += com.google.protobuf.CodedOutputStream.computeEnumSize(2, command_);
+ }
+ if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(affinityKey_)) {
+ size += com.google.protobuf.GeneratedMessageV3.computeStringSize(3, affinityKey_);
+ }
+ size += getUnknownFields().getSerializedSize();
+ memoizedSize = size;
+ return size;
+ }
+
+ @java.lang.Override
+ public boolean equals(final java.lang.Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof com.google.cloud.grpc.proto.AffinityConfig)) {
+ return super.equals(obj);
+ }
+ com.google.cloud.grpc.proto.AffinityConfig other =
+ (com.google.cloud.grpc.proto.AffinityConfig) obj;
+
+ if (command_ != other.command_) return false;
+ if (!getAffinityKey().equals(other.getAffinityKey())) return false;
+ if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+ return true;
+ }
+
+ @java.lang.Override
+ public int hashCode() {
+ if (memoizedHashCode != 0) {
+ return memoizedHashCode;
+ }
+ int hash = 41;
+ hash = (19 * hash) + getDescriptor().hashCode();
+ hash = (37 * hash) + COMMAND_FIELD_NUMBER;
+ hash = (53 * hash) + command_;
+ hash = (37 * hash) + AFFINITY_KEY_FIELD_NUMBER;
+ hash = (53 * hash) + getAffinityKey().hashCode();
+ hash = (29 * hash) + getUnknownFields().hashCode();
+ memoizedHashCode = hash;
+ return hash;
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(java.nio.ByteBuffer data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ com.google.protobuf.ByteString data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ com.google.protobuf.ByteString data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(byte[] data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseDelimitedFrom(
+ java.io.InputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseDelimitedFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ com.google.protobuf.CodedInputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig parseFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ @java.lang.Override
+ public Builder newBuilderForType() {
+ return newBuilder();
+ }
+
+ public static Builder newBuilder() {
+ return DEFAULT_INSTANCE.toBuilder();
+ }
+
+ public static Builder newBuilder(com.google.cloud.grpc.proto.AffinityConfig prototype) {
+ return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+ }
+
+ @java.lang.Override
+ public Builder toBuilder() {
+ return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this);
+ }
+
+ @java.lang.Override
+ protected Builder newBuilderForType(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ Builder builder = new Builder(parent);
+ return builder;
+ }
+
+ /** Protobuf type {@code grpc.gcp.AffinityConfig} */
+ public static final class Builder extends com.google.protobuf.GeneratedMessageV3.Builder
+ implements
+ // @@protoc_insertion_point(builder_implements:grpc.gcp.AffinityConfig)
+ com.google.cloud.grpc.proto.AffinityConfigOrBuilder {
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_AffinityConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_AffinityConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.AffinityConfig.class,
+ com.google.cloud.grpc.proto.AffinityConfig.Builder.class);
+ }
+
+ // Construct using com.google.cloud.grpc.proto.AffinityConfig.newBuilder()
+ private Builder() {}
+
+ private Builder(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ super(parent);
+ }
+
+ @java.lang.Override
+ public Builder clear() {
+ super.clear();
+ bitField0_ = 0;
+ command_ = 0;
+ affinityKey_ = "";
+ return this;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_AffinityConfig_descriptor;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig getDefaultInstanceForType() {
+ return com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance();
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig build() {
+ com.google.cloud.grpc.proto.AffinityConfig result = buildPartial();
+ if (!result.isInitialized()) {
+ throw newUninitializedMessageException(result);
+ }
+ return result;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig buildPartial() {
+ com.google.cloud.grpc.proto.AffinityConfig result =
+ new com.google.cloud.grpc.proto.AffinityConfig(this);
+ if (bitField0_ != 0) {
+ buildPartial0(result);
+ }
+ onBuilt();
+ return result;
+ }
+
+ private void buildPartial0(com.google.cloud.grpc.proto.AffinityConfig result) {
+ int from_bitField0_ = bitField0_;
+ if (((from_bitField0_ & 0x00000001) != 0)) {
+ result.command_ = command_;
+ }
+ if (((from_bitField0_ & 0x00000002) != 0)) {
+ result.affinityKey_ = affinityKey_;
+ }
+ }
+
+ @java.lang.Override
+ public Builder clone() {
+ return super.clone();
+ }
+
+ @java.lang.Override
+ public Builder setField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.setField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder clearField(com.google.protobuf.Descriptors.FieldDescriptor field) {
+ return super.clearField(field);
+ }
+
+ @java.lang.Override
+ public Builder clearOneof(com.google.protobuf.Descriptors.OneofDescriptor oneof) {
+ return super.clearOneof(oneof);
+ }
+
+ @java.lang.Override
+ public Builder setRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, int index, java.lang.Object value) {
+ return super.setRepeatedField(field, index, value);
+ }
+
+ @java.lang.Override
+ public Builder addRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.addRepeatedField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(com.google.protobuf.Message other) {
+ if (other instanceof com.google.cloud.grpc.proto.AffinityConfig) {
+ return mergeFrom((com.google.cloud.grpc.proto.AffinityConfig) other);
+ } else {
+ super.mergeFrom(other);
+ return this;
+ }
+ }
+
+ public Builder mergeFrom(com.google.cloud.grpc.proto.AffinityConfig other) {
+ if (other == com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance()) return this;
+ if (other.command_ != 0) {
+ setCommandValue(other.getCommandValue());
+ }
+ if (!other.getAffinityKey().isEmpty()) {
+ affinityKey_ = other.affinityKey_;
+ bitField0_ |= 0x00000002;
+ onChanged();
+ }
+ this.mergeUnknownFields(other.getUnknownFields());
+ onChanged();
+ return this;
+ }
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ return true;
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ if (extensionRegistry == null) {
+ throw new java.lang.NullPointerException();
+ }
+ try {
+ boolean done = false;
+ while (!done) {
+ int tag = input.readTag();
+ switch (tag) {
+ case 0:
+ done = true;
+ break;
+ case 16:
+ {
+ command_ = input.readEnum();
+ bitField0_ |= 0x00000001;
+ break;
+ } // case 16
+ case 26:
+ {
+ affinityKey_ = input.readStringRequireUtf8();
+ bitField0_ |= 0x00000002;
+ break;
+ } // case 26
+ default:
+ {
+ if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+ done = true; // was an endgroup tag
+ }
+ break;
+ } // default:
+ } // switch (tag)
+ } // while (!done)
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.unwrapIOException();
+ } finally {
+ onChanged();
+ } // finally
+ return this;
+ }
+
+ private int bitField0_;
+
+ private int command_ = 0;
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return The enum numeric value on the wire for command.
+ */
+ @java.lang.Override
+ public int getCommandValue() {
+ return command_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @param value The enum numeric value on the wire for command to set.
+ * @return This builder for chaining.
+ */
+ public Builder setCommandValue(int value) {
+ command_ = value;
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return The command.
+ */
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig.Command getCommand() {
+ com.google.cloud.grpc.proto.AffinityConfig.Command result =
+ com.google.cloud.grpc.proto.AffinityConfig.Command.forNumber(command_);
+ return result == null
+ ? com.google.cloud.grpc.proto.AffinityConfig.Command.UNRECOGNIZED
+ : result;
+ }
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @param value The command to set.
+ * @return This builder for chaining.
+ */
+ public Builder setCommand(com.google.cloud.grpc.proto.AffinityConfig.Command value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ bitField0_ |= 0x00000001;
+ command_ = value.getNumber();
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return This builder for chaining.
+ */
+ public Builder clearCommand() {
+ bitField0_ = (bitField0_ & ~0x00000001);
+ command_ = 0;
+ onChanged();
+ return this;
+ }
+
+ private java.lang.Object affinityKey_ = "";
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return The affinityKey.
+ */
+ public java.lang.String getAffinityKey() {
+ java.lang.Object ref = affinityKey_;
+ if (!(ref instanceof java.lang.String)) {
+ com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref;
+ java.lang.String s = bs.toStringUtf8();
+ affinityKey_ = s;
+ return s;
+ } else {
+ return (java.lang.String) ref;
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return The bytes for affinityKey.
+ */
+ public com.google.protobuf.ByteString getAffinityKeyBytes() {
+ java.lang.Object ref = affinityKey_;
+ if (ref instanceof String) {
+ com.google.protobuf.ByteString b =
+ com.google.protobuf.ByteString.copyFromUtf8((java.lang.String) ref);
+ affinityKey_ = b;
+ return b;
+ } else {
+ return (com.google.protobuf.ByteString) ref;
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @param value The affinityKey to set.
+ * @return This builder for chaining.
+ */
+ public Builder setAffinityKey(java.lang.String value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ affinityKey_ = value;
+ bitField0_ |= 0x00000002;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return This builder for chaining.
+ */
+ public Builder clearAffinityKey() {
+ affinityKey_ = getDefaultInstance().getAffinityKey();
+ bitField0_ = (bitField0_ & ~0x00000002);
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @param value The bytes for affinityKey to set.
+ * @return This builder for chaining.
+ */
+ public Builder setAffinityKeyBytes(com.google.protobuf.ByteString value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ checkByteStringIsUtf8(value);
+ affinityKey_ = value;
+ bitField0_ |= 0x00000002;
+ onChanged();
+ return this;
+ }
+
+ @java.lang.Override
+ public final Builder setUnknownFields(final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.setUnknownFields(unknownFields);
+ }
+
+ @java.lang.Override
+ public final Builder mergeUnknownFields(
+ final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.mergeUnknownFields(unknownFields);
+ }
+
+ // @@protoc_insertion_point(builder_scope:grpc.gcp.AffinityConfig)
+ }
+
+ // @@protoc_insertion_point(class_scope:grpc.gcp.AffinityConfig)
+ private static final com.google.cloud.grpc.proto.AffinityConfig DEFAULT_INSTANCE;
+
+ static {
+ DEFAULT_INSTANCE = new com.google.cloud.grpc.proto.AffinityConfig();
+ }
+
+ public static com.google.cloud.grpc.proto.AffinityConfig getDefaultInstance() {
+ return DEFAULT_INSTANCE;
+ }
+
+ private static final com.google.protobuf.Parser PARSER =
+ new com.google.protobuf.AbstractParser() {
+ @java.lang.Override
+ public AffinityConfig parsePartialFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ Builder builder = newBuilder();
+ try {
+ builder.mergeFrom(input, extensionRegistry);
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.setUnfinishedMessage(builder.buildPartial());
+ } catch (com.google.protobuf.UninitializedMessageException e) {
+ throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+ } catch (java.io.IOException e) {
+ throw new com.google.protobuf.InvalidProtocolBufferException(e)
+ .setUnfinishedMessage(builder.buildPartial());
+ }
+ return builder.buildPartial();
+ }
+ };
+
+ public static com.google.protobuf.Parser parser() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Parser getParserForType() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig getDefaultInstanceForType() {
+ return DEFAULT_INSTANCE;
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/AffinityConfigOrBuilder.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/AffinityConfigOrBuilder.java
new file mode 100644
index 000000000000..15dece289847
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/AffinityConfigOrBuilder.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+public interface AffinityConfigOrBuilder
+ extends
+ // @@protoc_insertion_point(interface_extends:grpc.gcp.AffinityConfig)
+ com.google.protobuf.MessageOrBuilder {
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return The enum numeric value on the wire for command.
+ */
+ int getCommandValue();
+
+ /**
+ *
+ *
+ *
+ * The affinity command applies on the selected gRPC methods.
+ *
+ *
+ * .grpc.gcp.AffinityConfig.Command command = 2;
+ *
+ * @return The command.
+ */
+ com.google.cloud.grpc.proto.AffinityConfig.Command getCommand();
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return The affinityKey.
+ */
+ java.lang.String getAffinityKey();
+
+ /**
+ *
+ *
+ *
+ * The field path of the affinity key in the request/response message.
+ * For example: "f.a", "f.b.d", etc.
+ *
+ *
+ * string affinity_key = 3;
+ *
+ * @return The bytes for affinityKey.
+ */
+ com.google.protobuf.ByteString getAffinityKeyBytes();
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ApiConfig.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ApiConfig.java
new file mode 100644
index 000000000000..11c8b4488b66
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ApiConfig.java
@@ -0,0 +1,1265 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+/** Protobuf type {@code grpc.gcp.ApiConfig} */
+public final class ApiConfig extends com.google.protobuf.GeneratedMessageV3
+ implements
+ // @@protoc_insertion_point(message_implements:grpc.gcp.ApiConfig)
+ ApiConfigOrBuilder {
+ private static final long serialVersionUID = 0L;
+
+ // Use ApiConfig.newBuilder() to construct.
+ private ApiConfig(com.google.protobuf.GeneratedMessageV3.Builder> builder) {
+ super(builder);
+ }
+
+ private ApiConfig() {
+ method_ = java.util.Collections.emptyList();
+ }
+
+ @java.lang.Override
+ @SuppressWarnings({"unused"})
+ protected java.lang.Object newInstance(UnusedPrivateParameter unused) {
+ return new ApiConfig();
+ }
+
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ApiConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ApiConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.ApiConfig.class,
+ com.google.cloud.grpc.proto.ApiConfig.Builder.class);
+ }
+
+ private int bitField0_;
+ public static final int CHANNEL_POOL_FIELD_NUMBER = 2;
+ private com.google.cloud.grpc.proto.ChannelPoolConfig channelPool_;
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ *
+ * @deprecated grpc.gcp.ApiConfig.channel_pool is deprecated. See
+ * google/grpc/gcp/proto/grpc_gcp.proto;l=25
+ * @return Whether the channelPool field is set.
+ */
+ @java.lang.Override
+ @java.lang.Deprecated
+ public boolean hasChannelPool() {
+ return ((bitField0_ & 0x00000001) != 0);
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ *
+ * @deprecated grpc.gcp.ApiConfig.channel_pool is deprecated. See
+ * google/grpc/gcp/proto/grpc_gcp.proto;l=25
+ * @return The channelPool.
+ */
+ @java.lang.Override
+ @java.lang.Deprecated
+ public com.google.cloud.grpc.proto.ChannelPoolConfig getChannelPool() {
+ return channelPool_ == null
+ ? com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance()
+ : channelPool_;
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Override
+ @java.lang.Deprecated
+ public com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder getChannelPoolOrBuilder() {
+ return channelPool_ == null
+ ? com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance()
+ : channelPool_;
+ }
+
+ public static final int METHOD_FIELD_NUMBER = 1001;
+
+ @SuppressWarnings("serial")
+ private java.util.List method_;
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ @java.lang.Override
+ public java.util.List getMethodList() {
+ return method_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ @java.lang.Override
+ public java.util.List extends com.google.cloud.grpc.proto.MethodConfigOrBuilder>
+ getMethodOrBuilderList() {
+ return method_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ @java.lang.Override
+ public int getMethodCount() {
+ return method_.size();
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.MethodConfig getMethod(int index) {
+ return method_.get(index);
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.MethodConfigOrBuilder getMethodOrBuilder(int index) {
+ return method_.get(index);
+ }
+
+ private byte memoizedIsInitialized = -1;
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ byte isInitialized = memoizedIsInitialized;
+ if (isInitialized == 1) return true;
+ if (isInitialized == 0) return false;
+
+ memoizedIsInitialized = 1;
+ return true;
+ }
+
+ @java.lang.Override
+ public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException {
+ if (((bitField0_ & 0x00000001) != 0)) {
+ output.writeMessage(2, getChannelPool());
+ }
+ for (int i = 0; i < method_.size(); i++) {
+ output.writeMessage(1001, method_.get(i));
+ }
+ getUnknownFields().writeTo(output);
+ }
+
+ @java.lang.Override
+ public int getSerializedSize() {
+ int size = memoizedSize;
+ if (size != -1) return size;
+
+ size = 0;
+ if (((bitField0_ & 0x00000001) != 0)) {
+ size += com.google.protobuf.CodedOutputStream.computeMessageSize(2, getChannelPool());
+ }
+ for (int i = 0; i < method_.size(); i++) {
+ size += com.google.protobuf.CodedOutputStream.computeMessageSize(1001, method_.get(i));
+ }
+ size += getUnknownFields().getSerializedSize();
+ memoizedSize = size;
+ return size;
+ }
+
+ @java.lang.Override
+ public boolean equals(final java.lang.Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof com.google.cloud.grpc.proto.ApiConfig)) {
+ return super.equals(obj);
+ }
+ com.google.cloud.grpc.proto.ApiConfig other = (com.google.cloud.grpc.proto.ApiConfig) obj;
+
+ if (hasChannelPool() != other.hasChannelPool()) return false;
+ if (hasChannelPool()) {
+ if (!getChannelPool().equals(other.getChannelPool())) return false;
+ }
+ if (!getMethodList().equals(other.getMethodList())) return false;
+ if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+ return true;
+ }
+
+ @java.lang.Override
+ public int hashCode() {
+ if (memoizedHashCode != 0) {
+ return memoizedHashCode;
+ }
+ int hash = 41;
+ hash = (19 * hash) + getDescriptor().hashCode();
+ if (hasChannelPool()) {
+ hash = (37 * hash) + CHANNEL_POOL_FIELD_NUMBER;
+ hash = (53 * hash) + getChannelPool().hashCode();
+ }
+ if (getMethodCount() > 0) {
+ hash = (37 * hash) + METHOD_FIELD_NUMBER;
+ hash = (53 * hash) + getMethodList().hashCode();
+ }
+ hash = (29 * hash) + getUnknownFields().hashCode();
+ memoizedHashCode = hash;
+ return hash;
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(java.nio.ByteBuffer data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(
+ java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(com.google.protobuf.ByteString data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(
+ com.google.protobuf.ByteString data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(byte[] data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(
+ byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseDelimitedFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseDelimitedFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(
+ com.google.protobuf.CodedInputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig parseFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ @java.lang.Override
+ public Builder newBuilderForType() {
+ return newBuilder();
+ }
+
+ public static Builder newBuilder() {
+ return DEFAULT_INSTANCE.toBuilder();
+ }
+
+ public static Builder newBuilder(com.google.cloud.grpc.proto.ApiConfig prototype) {
+ return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+ }
+
+ @java.lang.Override
+ public Builder toBuilder() {
+ return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this);
+ }
+
+ @java.lang.Override
+ protected Builder newBuilderForType(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ Builder builder = new Builder(parent);
+ return builder;
+ }
+
+ /** Protobuf type {@code grpc.gcp.ApiConfig} */
+ public static final class Builder extends com.google.protobuf.GeneratedMessageV3.Builder
+ implements
+ // @@protoc_insertion_point(builder_implements:grpc.gcp.ApiConfig)
+ com.google.cloud.grpc.proto.ApiConfigOrBuilder {
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ApiConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ApiConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.ApiConfig.class,
+ com.google.cloud.grpc.proto.ApiConfig.Builder.class);
+ }
+
+ // Construct using com.google.cloud.grpc.proto.ApiConfig.newBuilder()
+ private Builder() {
+ maybeForceBuilderInitialization();
+ }
+
+ private Builder(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ super(parent);
+ maybeForceBuilderInitialization();
+ }
+
+ private void maybeForceBuilderInitialization() {
+ if (com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders) {
+ getChannelPoolFieldBuilder();
+ getMethodFieldBuilder();
+ }
+ }
+
+ @java.lang.Override
+ public Builder clear() {
+ super.clear();
+ bitField0_ = 0;
+ channelPool_ = null;
+ if (channelPoolBuilder_ != null) {
+ channelPoolBuilder_.dispose();
+ channelPoolBuilder_ = null;
+ }
+ if (methodBuilder_ == null) {
+ method_ = java.util.Collections.emptyList();
+ } else {
+ method_ = null;
+ methodBuilder_.clear();
+ }
+ bitField0_ = (bitField0_ & ~0x00000002);
+ return this;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ApiConfig_descriptor;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ApiConfig getDefaultInstanceForType() {
+ return com.google.cloud.grpc.proto.ApiConfig.getDefaultInstance();
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ApiConfig build() {
+ com.google.cloud.grpc.proto.ApiConfig result = buildPartial();
+ if (!result.isInitialized()) {
+ throw newUninitializedMessageException(result);
+ }
+ return result;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ApiConfig buildPartial() {
+ com.google.cloud.grpc.proto.ApiConfig result =
+ new com.google.cloud.grpc.proto.ApiConfig(this);
+ buildPartialRepeatedFields(result);
+ if (bitField0_ != 0) {
+ buildPartial0(result);
+ }
+ onBuilt();
+ return result;
+ }
+
+ private void buildPartialRepeatedFields(com.google.cloud.grpc.proto.ApiConfig result) {
+ if (methodBuilder_ == null) {
+ if (((bitField0_ & 0x00000002) != 0)) {
+ method_ = java.util.Collections.unmodifiableList(method_);
+ bitField0_ = (bitField0_ & ~0x00000002);
+ }
+ result.method_ = method_;
+ } else {
+ result.method_ = methodBuilder_.build();
+ }
+ }
+
+ private void buildPartial0(com.google.cloud.grpc.proto.ApiConfig result) {
+ int from_bitField0_ = bitField0_;
+ int to_bitField0_ = 0;
+ if (((from_bitField0_ & 0x00000001) != 0)) {
+ result.channelPool_ =
+ channelPoolBuilder_ == null ? channelPool_ : channelPoolBuilder_.build();
+ to_bitField0_ |= 0x00000001;
+ }
+ result.bitField0_ |= to_bitField0_;
+ }
+
+ @java.lang.Override
+ public Builder clone() {
+ return super.clone();
+ }
+
+ @java.lang.Override
+ public Builder setField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.setField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder clearField(com.google.protobuf.Descriptors.FieldDescriptor field) {
+ return super.clearField(field);
+ }
+
+ @java.lang.Override
+ public Builder clearOneof(com.google.protobuf.Descriptors.OneofDescriptor oneof) {
+ return super.clearOneof(oneof);
+ }
+
+ @java.lang.Override
+ public Builder setRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, int index, java.lang.Object value) {
+ return super.setRepeatedField(field, index, value);
+ }
+
+ @java.lang.Override
+ public Builder addRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.addRepeatedField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(com.google.protobuf.Message other) {
+ if (other instanceof com.google.cloud.grpc.proto.ApiConfig) {
+ return mergeFrom((com.google.cloud.grpc.proto.ApiConfig) other);
+ } else {
+ super.mergeFrom(other);
+ return this;
+ }
+ }
+
+ public Builder mergeFrom(com.google.cloud.grpc.proto.ApiConfig other) {
+ if (other == com.google.cloud.grpc.proto.ApiConfig.getDefaultInstance()) return this;
+ if (other.hasChannelPool()) {
+ mergeChannelPool(other.getChannelPool());
+ }
+ if (methodBuilder_ == null) {
+ if (!other.method_.isEmpty()) {
+ if (method_.isEmpty()) {
+ method_ = other.method_;
+ bitField0_ = (bitField0_ & ~0x00000002);
+ } else {
+ ensureMethodIsMutable();
+ method_.addAll(other.method_);
+ }
+ onChanged();
+ }
+ } else {
+ if (!other.method_.isEmpty()) {
+ if (methodBuilder_.isEmpty()) {
+ methodBuilder_.dispose();
+ methodBuilder_ = null;
+ method_ = other.method_;
+ bitField0_ = (bitField0_ & ~0x00000002);
+ methodBuilder_ =
+ com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders
+ ? getMethodFieldBuilder()
+ : null;
+ } else {
+ methodBuilder_.addAllMessages(other.method_);
+ }
+ }
+ }
+ this.mergeUnknownFields(other.getUnknownFields());
+ onChanged();
+ return this;
+ }
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ return true;
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ if (extensionRegistry == null) {
+ throw new java.lang.NullPointerException();
+ }
+ try {
+ boolean done = false;
+ while (!done) {
+ int tag = input.readTag();
+ switch (tag) {
+ case 0:
+ done = true;
+ break;
+ case 18:
+ {
+ input.readMessage(getChannelPoolFieldBuilder().getBuilder(), extensionRegistry);
+ bitField0_ |= 0x00000001;
+ break;
+ } // case 18
+ case 8010:
+ {
+ com.google.cloud.grpc.proto.MethodConfig m =
+ input.readMessage(
+ com.google.cloud.grpc.proto.MethodConfig.parser(), extensionRegistry);
+ if (methodBuilder_ == null) {
+ ensureMethodIsMutable();
+ method_.add(m);
+ } else {
+ methodBuilder_.addMessage(m);
+ }
+ break;
+ } // case 8010
+ default:
+ {
+ if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+ done = true; // was an endgroup tag
+ }
+ break;
+ } // default:
+ } // switch (tag)
+ } // while (!done)
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.unwrapIOException();
+ } finally {
+ onChanged();
+ } // finally
+ return this;
+ }
+
+ private int bitField0_;
+
+ private com.google.cloud.grpc.proto.ChannelPoolConfig channelPool_;
+ private com.google.protobuf.SingleFieldBuilderV3<
+ com.google.cloud.grpc.proto.ChannelPoolConfig,
+ com.google.cloud.grpc.proto.ChannelPoolConfig.Builder,
+ com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder>
+ channelPoolBuilder_;
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ *
+ * @deprecated grpc.gcp.ApiConfig.channel_pool is deprecated. See
+ * google/grpc/gcp/proto/grpc_gcp.proto;l=25
+ * @return Whether the channelPool field is set.
+ */
+ @java.lang.Deprecated
+ public boolean hasChannelPool() {
+ return ((bitField0_ & 0x00000001) != 0);
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ *
+ * @deprecated grpc.gcp.ApiConfig.channel_pool is deprecated. See
+ * google/grpc/gcp/proto/grpc_gcp.proto;l=25
+ * @return The channelPool.
+ */
+ @java.lang.Deprecated
+ public com.google.cloud.grpc.proto.ChannelPoolConfig getChannelPool() {
+ if (channelPoolBuilder_ == null) {
+ return channelPool_ == null
+ ? com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance()
+ : channelPool_;
+ } else {
+ return channelPoolBuilder_.getMessage();
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ public Builder setChannelPool(com.google.cloud.grpc.proto.ChannelPoolConfig value) {
+ if (channelPoolBuilder_ == null) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ channelPool_ = value;
+ } else {
+ channelPoolBuilder_.setMessage(value);
+ }
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ public Builder setChannelPool(
+ com.google.cloud.grpc.proto.ChannelPoolConfig.Builder builderForValue) {
+ if (channelPoolBuilder_ == null) {
+ channelPool_ = builderForValue.build();
+ } else {
+ channelPoolBuilder_.setMessage(builderForValue.build());
+ }
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ public Builder mergeChannelPool(com.google.cloud.grpc.proto.ChannelPoolConfig value) {
+ if (channelPoolBuilder_ == null) {
+ if (((bitField0_ & 0x00000001) != 0)
+ && channelPool_ != null
+ && channelPool_ != com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance()) {
+ getChannelPoolBuilder().mergeFrom(value);
+ } else {
+ channelPool_ = value;
+ }
+ } else {
+ channelPoolBuilder_.mergeFrom(value);
+ }
+ if (channelPool_ != null) {
+ bitField0_ |= 0x00000001;
+ onChanged();
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ public Builder clearChannelPool() {
+ bitField0_ = (bitField0_ & ~0x00000001);
+ channelPool_ = null;
+ if (channelPoolBuilder_ != null) {
+ channelPoolBuilder_.dispose();
+ channelPoolBuilder_ = null;
+ }
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ public com.google.cloud.grpc.proto.ChannelPoolConfig.Builder getChannelPoolBuilder() {
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return getChannelPoolFieldBuilder().getBuilder();
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ public com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder getChannelPoolOrBuilder() {
+ if (channelPoolBuilder_ != null) {
+ return channelPoolBuilder_.getMessageOrBuilder();
+ } else {
+ return channelPool_ == null
+ ? com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance()
+ : channelPool_;
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ private com.google.protobuf.SingleFieldBuilderV3<
+ com.google.cloud.grpc.proto.ChannelPoolConfig,
+ com.google.cloud.grpc.proto.ChannelPoolConfig.Builder,
+ com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder>
+ getChannelPoolFieldBuilder() {
+ if (channelPoolBuilder_ == null) {
+ channelPoolBuilder_ =
+ new com.google.protobuf.SingleFieldBuilderV3<
+ com.google.cloud.grpc.proto.ChannelPoolConfig,
+ com.google.cloud.grpc.proto.ChannelPoolConfig.Builder,
+ com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder>(
+ getChannelPool(), getParentForChildren(), isClean());
+ channelPool_ = null;
+ }
+ return channelPoolBuilder_;
+ }
+
+ private java.util.List method_ =
+ java.util.Collections.emptyList();
+
+ private void ensureMethodIsMutable() {
+ if (!((bitField0_ & 0x00000002) != 0)) {
+ method_ = new java.util.ArrayList(method_);
+ bitField0_ |= 0x00000002;
+ }
+ }
+
+ private com.google.protobuf.RepeatedFieldBuilderV3<
+ com.google.cloud.grpc.proto.MethodConfig,
+ com.google.cloud.grpc.proto.MethodConfig.Builder,
+ com.google.cloud.grpc.proto.MethodConfigOrBuilder>
+ methodBuilder_;
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public java.util.List getMethodList() {
+ if (methodBuilder_ == null) {
+ return java.util.Collections.unmodifiableList(method_);
+ } else {
+ return methodBuilder_.getMessageList();
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public int getMethodCount() {
+ if (methodBuilder_ == null) {
+ return method_.size();
+ } else {
+ return methodBuilder_.getCount();
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public com.google.cloud.grpc.proto.MethodConfig getMethod(int index) {
+ if (methodBuilder_ == null) {
+ return method_.get(index);
+ } else {
+ return methodBuilder_.getMessage(index);
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder setMethod(int index, com.google.cloud.grpc.proto.MethodConfig value) {
+ if (methodBuilder_ == null) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ ensureMethodIsMutable();
+ method_.set(index, value);
+ onChanged();
+ } else {
+ methodBuilder_.setMessage(index, value);
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder setMethod(
+ int index, com.google.cloud.grpc.proto.MethodConfig.Builder builderForValue) {
+ if (methodBuilder_ == null) {
+ ensureMethodIsMutable();
+ method_.set(index, builderForValue.build());
+ onChanged();
+ } else {
+ methodBuilder_.setMessage(index, builderForValue.build());
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder addMethod(com.google.cloud.grpc.proto.MethodConfig value) {
+ if (methodBuilder_ == null) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ ensureMethodIsMutable();
+ method_.add(value);
+ onChanged();
+ } else {
+ methodBuilder_.addMessage(value);
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder addMethod(int index, com.google.cloud.grpc.proto.MethodConfig value) {
+ if (methodBuilder_ == null) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ ensureMethodIsMutable();
+ method_.add(index, value);
+ onChanged();
+ } else {
+ methodBuilder_.addMessage(index, value);
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder addMethod(com.google.cloud.grpc.proto.MethodConfig.Builder builderForValue) {
+ if (methodBuilder_ == null) {
+ ensureMethodIsMutable();
+ method_.add(builderForValue.build());
+ onChanged();
+ } else {
+ methodBuilder_.addMessage(builderForValue.build());
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder addMethod(
+ int index, com.google.cloud.grpc.proto.MethodConfig.Builder builderForValue) {
+ if (methodBuilder_ == null) {
+ ensureMethodIsMutable();
+ method_.add(index, builderForValue.build());
+ onChanged();
+ } else {
+ methodBuilder_.addMessage(index, builderForValue.build());
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder addAllMethod(
+ java.lang.Iterable extends com.google.cloud.grpc.proto.MethodConfig> values) {
+ if (methodBuilder_ == null) {
+ ensureMethodIsMutable();
+ com.google.protobuf.AbstractMessageLite.Builder.addAll(values, method_);
+ onChanged();
+ } else {
+ methodBuilder_.addAllMessages(values);
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder clearMethod() {
+ if (methodBuilder_ == null) {
+ method_ = java.util.Collections.emptyList();
+ bitField0_ = (bitField0_ & ~0x00000002);
+ onChanged();
+ } else {
+ methodBuilder_.clear();
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public Builder removeMethod(int index) {
+ if (methodBuilder_ == null) {
+ ensureMethodIsMutable();
+ method_.remove(index);
+ onChanged();
+ } else {
+ methodBuilder_.remove(index);
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public com.google.cloud.grpc.proto.MethodConfig.Builder getMethodBuilder(int index) {
+ return getMethodFieldBuilder().getBuilder(index);
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public com.google.cloud.grpc.proto.MethodConfigOrBuilder getMethodOrBuilder(int index) {
+ if (methodBuilder_ == null) {
+ return method_.get(index);
+ } else {
+ return methodBuilder_.getMessageOrBuilder(index);
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public java.util.List extends com.google.cloud.grpc.proto.MethodConfigOrBuilder>
+ getMethodOrBuilderList() {
+ if (methodBuilder_ != null) {
+ return methodBuilder_.getMessageOrBuilderList();
+ } else {
+ return java.util.Collections.unmodifiableList(method_);
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public com.google.cloud.grpc.proto.MethodConfig.Builder addMethodBuilder() {
+ return getMethodFieldBuilder()
+ .addBuilder(com.google.cloud.grpc.proto.MethodConfig.getDefaultInstance());
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public com.google.cloud.grpc.proto.MethodConfig.Builder addMethodBuilder(int index) {
+ return getMethodFieldBuilder()
+ .addBuilder(index, com.google.cloud.grpc.proto.MethodConfig.getDefaultInstance());
+ }
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ public java.util.List getMethodBuilderList() {
+ return getMethodFieldBuilder().getBuilderList();
+ }
+
+ private com.google.protobuf.RepeatedFieldBuilderV3<
+ com.google.cloud.grpc.proto.MethodConfig,
+ com.google.cloud.grpc.proto.MethodConfig.Builder,
+ com.google.cloud.grpc.proto.MethodConfigOrBuilder>
+ getMethodFieldBuilder() {
+ if (methodBuilder_ == null) {
+ methodBuilder_ =
+ new com.google.protobuf.RepeatedFieldBuilderV3<
+ com.google.cloud.grpc.proto.MethodConfig,
+ com.google.cloud.grpc.proto.MethodConfig.Builder,
+ com.google.cloud.grpc.proto.MethodConfigOrBuilder>(
+ method_, ((bitField0_ & 0x00000002) != 0), getParentForChildren(), isClean());
+ method_ = null;
+ }
+ return methodBuilder_;
+ }
+
+ @java.lang.Override
+ public final Builder setUnknownFields(final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.setUnknownFields(unknownFields);
+ }
+
+ @java.lang.Override
+ public final Builder mergeUnknownFields(
+ final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.mergeUnknownFields(unknownFields);
+ }
+
+ // @@protoc_insertion_point(builder_scope:grpc.gcp.ApiConfig)
+ }
+
+ // @@protoc_insertion_point(class_scope:grpc.gcp.ApiConfig)
+ private static final com.google.cloud.grpc.proto.ApiConfig DEFAULT_INSTANCE;
+
+ static {
+ DEFAULT_INSTANCE = new com.google.cloud.grpc.proto.ApiConfig();
+ }
+
+ public static com.google.cloud.grpc.proto.ApiConfig getDefaultInstance() {
+ return DEFAULT_INSTANCE;
+ }
+
+ private static final com.google.protobuf.Parser PARSER =
+ new com.google.protobuf.AbstractParser() {
+ @java.lang.Override
+ public ApiConfig parsePartialFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ Builder builder = newBuilder();
+ try {
+ builder.mergeFrom(input, extensionRegistry);
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.setUnfinishedMessage(builder.buildPartial());
+ } catch (com.google.protobuf.UninitializedMessageException e) {
+ throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+ } catch (java.io.IOException e) {
+ throw new com.google.protobuf.InvalidProtocolBufferException(e)
+ .setUnfinishedMessage(builder.buildPartial());
+ }
+ return builder.buildPartial();
+ }
+ };
+
+ public static com.google.protobuf.Parser parser() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Parser getParserForType() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ApiConfig getDefaultInstanceForType() {
+ return DEFAULT_INSTANCE;
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ApiConfigOrBuilder.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ApiConfigOrBuilder.java
new file mode 100644
index 000000000000..b076d2efa211
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ApiConfigOrBuilder.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+public interface ApiConfigOrBuilder
+ extends
+ // @@protoc_insertion_point(interface_extends:grpc.gcp.ApiConfig)
+ com.google.protobuf.MessageOrBuilder {
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ *
+ * @deprecated grpc.gcp.ApiConfig.channel_pool is deprecated. See
+ * google/grpc/gcp/proto/grpc_gcp.proto;l=25
+ * @return Whether the channelPool field is set.
+ */
+ @java.lang.Deprecated
+ boolean hasChannelPool();
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ *
+ * @deprecated grpc.gcp.ApiConfig.channel_pool is deprecated. See
+ * google/grpc/gcp/proto/grpc_gcp.proto;l=25
+ * @return The channelPool.
+ */
+ @java.lang.Deprecated
+ com.google.cloud.grpc.proto.ChannelPoolConfig getChannelPool();
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ * The channel pool configurations.
+ *
+ *
+ * .grpc.gcp.ChannelPoolConfig channel_pool = 2 [deprecated = true];
+ */
+ @java.lang.Deprecated
+ com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder getChannelPoolOrBuilder();
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ java.util.List getMethodList();
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ com.google.cloud.grpc.proto.MethodConfig getMethod(int index);
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ int getMethodCount();
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ java.util.List extends com.google.cloud.grpc.proto.MethodConfigOrBuilder>
+ getMethodOrBuilderList();
+
+ /**
+ *
+ *
+ *
+ * The method configurations.
+ *
+ *
+ * repeated .grpc.gcp.MethodConfig method = 1001;
+ */
+ com.google.cloud.grpc.proto.MethodConfigOrBuilder getMethodOrBuilder(int index);
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ChannelPoolConfig.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ChannelPoolConfig.java
new file mode 100644
index 000000000000..df6bdb2b3cc0
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ChannelPoolConfig.java
@@ -0,0 +1,755 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+/**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ *
+ *
+ * Protobuf type {@code grpc.gcp.ChannelPoolConfig}
+ */
+@java.lang.Deprecated
+public final class ChannelPoolConfig extends com.google.protobuf.GeneratedMessageV3
+ implements
+ // @@protoc_insertion_point(message_implements:grpc.gcp.ChannelPoolConfig)
+ ChannelPoolConfigOrBuilder {
+ private static final long serialVersionUID = 0L;
+
+ // Use ChannelPoolConfig.newBuilder() to construct.
+ private ChannelPoolConfig(com.google.protobuf.GeneratedMessageV3.Builder> builder) {
+ super(builder);
+ }
+
+ private ChannelPoolConfig() {}
+
+ @java.lang.Override
+ @SuppressWarnings({"unused"})
+ protected java.lang.Object newInstance(UnusedPrivateParameter unused) {
+ return new ChannelPoolConfig();
+ }
+
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ChannelPoolConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ChannelPoolConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.ChannelPoolConfig.class,
+ com.google.cloud.grpc.proto.ChannelPoolConfig.Builder.class);
+ }
+
+ public static final int MAX_SIZE_FIELD_NUMBER = 1;
+ private int maxSize_ = 0;
+
+ /**
+ *
+ *
+ *
+ * The max number of channels in the pool.
+ *
+ *
+ * uint32 max_size = 1;
+ *
+ * @return The maxSize.
+ */
+ @java.lang.Override
+ public int getMaxSize() {
+ return maxSize_;
+ }
+
+ public static final int IDLE_TIMEOUT_FIELD_NUMBER = 2;
+ private long idleTimeout_ = 0L;
+
+ /**
+ *
+ *
+ *
+ * The idle timeout (seconds) of channels without bound affinity sessions.
+ *
+ *
+ * uint64 idle_timeout = 2;
+ *
+ * @return The idleTimeout.
+ */
+ @java.lang.Override
+ public long getIdleTimeout() {
+ return idleTimeout_;
+ }
+
+ public static final int MAX_CONCURRENT_STREAMS_LOW_WATERMARK_FIELD_NUMBER = 3;
+ private int maxConcurrentStreamsLowWatermark_ = 0;
+
+ /**
+ *
+ *
+ *
+ * The low watermark of max number of concurrent streams in a channel.
+ * New channel will be created once it get hit, until we reach the max size
+ * of the channel pool.
+ * Values outside of [0..100] will be ignored. 0 = always create a new channel
+ * until max size is reached.
+ *
+ *
+ * uint32 max_concurrent_streams_low_watermark = 3;
+ *
+ * @return The maxConcurrentStreamsLowWatermark.
+ */
+ @java.lang.Override
+ public int getMaxConcurrentStreamsLowWatermark() {
+ return maxConcurrentStreamsLowWatermark_;
+ }
+
+ private byte memoizedIsInitialized = -1;
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ byte isInitialized = memoizedIsInitialized;
+ if (isInitialized == 1) return true;
+ if (isInitialized == 0) return false;
+
+ memoizedIsInitialized = 1;
+ return true;
+ }
+
+ @java.lang.Override
+ public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException {
+ if (maxSize_ != 0) {
+ output.writeUInt32(1, maxSize_);
+ }
+ if (idleTimeout_ != 0L) {
+ output.writeUInt64(2, idleTimeout_);
+ }
+ if (maxConcurrentStreamsLowWatermark_ != 0) {
+ output.writeUInt32(3, maxConcurrentStreamsLowWatermark_);
+ }
+ getUnknownFields().writeTo(output);
+ }
+
+ @java.lang.Override
+ public int getSerializedSize() {
+ int size = memoizedSize;
+ if (size != -1) return size;
+
+ size = 0;
+ if (maxSize_ != 0) {
+ size += com.google.protobuf.CodedOutputStream.computeUInt32Size(1, maxSize_);
+ }
+ if (idleTimeout_ != 0L) {
+ size += com.google.protobuf.CodedOutputStream.computeUInt64Size(2, idleTimeout_);
+ }
+ if (maxConcurrentStreamsLowWatermark_ != 0) {
+ size +=
+ com.google.protobuf.CodedOutputStream.computeUInt32Size(
+ 3, maxConcurrentStreamsLowWatermark_);
+ }
+ size += getUnknownFields().getSerializedSize();
+ memoizedSize = size;
+ return size;
+ }
+
+ @java.lang.Override
+ public boolean equals(final java.lang.Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof com.google.cloud.grpc.proto.ChannelPoolConfig)) {
+ return super.equals(obj);
+ }
+ com.google.cloud.grpc.proto.ChannelPoolConfig other =
+ (com.google.cloud.grpc.proto.ChannelPoolConfig) obj;
+
+ if (getMaxSize() != other.getMaxSize()) return false;
+ if (getIdleTimeout() != other.getIdleTimeout()) return false;
+ if (getMaxConcurrentStreamsLowWatermark() != other.getMaxConcurrentStreamsLowWatermark())
+ return false;
+ if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+ return true;
+ }
+
+ @java.lang.Override
+ public int hashCode() {
+ if (memoizedHashCode != 0) {
+ return memoizedHashCode;
+ }
+ int hash = 41;
+ hash = (19 * hash) + getDescriptor().hashCode();
+ hash = (37 * hash) + MAX_SIZE_FIELD_NUMBER;
+ hash = (53 * hash) + getMaxSize();
+ hash = (37 * hash) + IDLE_TIMEOUT_FIELD_NUMBER;
+ hash = (53 * hash) + com.google.protobuf.Internal.hashLong(getIdleTimeout());
+ hash = (37 * hash) + MAX_CONCURRENT_STREAMS_LOW_WATERMARK_FIELD_NUMBER;
+ hash = (53 * hash) + getMaxConcurrentStreamsLowWatermark();
+ hash = (29 * hash) + getUnknownFields().hashCode();
+ memoizedHashCode = hash;
+ return hash;
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(java.nio.ByteBuffer data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ com.google.protobuf.ByteString data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ com.google.protobuf.ByteString data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(byte[] data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseDelimitedFrom(
+ java.io.InputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseDelimitedFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ com.google.protobuf.CodedInputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig parseFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ @java.lang.Override
+ public Builder newBuilderForType() {
+ return newBuilder();
+ }
+
+ public static Builder newBuilder() {
+ return DEFAULT_INSTANCE.toBuilder();
+ }
+
+ public static Builder newBuilder(com.google.cloud.grpc.proto.ChannelPoolConfig prototype) {
+ return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+ }
+
+ @java.lang.Override
+ public Builder toBuilder() {
+ return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this);
+ }
+
+ @java.lang.Override
+ protected Builder newBuilderForType(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ Builder builder = new Builder(parent);
+ return builder;
+ }
+
+ /**
+ *
+ *
+ *
+ * Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ *
+ *
+ * Protobuf type {@code grpc.gcp.ChannelPoolConfig}
+ */
+ public static final class Builder extends com.google.protobuf.GeneratedMessageV3.Builder
+ implements
+ // @@protoc_insertion_point(builder_implements:grpc.gcp.ChannelPoolConfig)
+ com.google.cloud.grpc.proto.ChannelPoolConfigOrBuilder {
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ChannelPoolConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ChannelPoolConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.ChannelPoolConfig.class,
+ com.google.cloud.grpc.proto.ChannelPoolConfig.Builder.class);
+ }
+
+ // Construct using com.google.cloud.grpc.proto.ChannelPoolConfig.newBuilder()
+ private Builder() {}
+
+ private Builder(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ super(parent);
+ }
+
+ @java.lang.Override
+ public Builder clear() {
+ super.clear();
+ bitField0_ = 0;
+ maxSize_ = 0;
+ idleTimeout_ = 0L;
+ maxConcurrentStreamsLowWatermark_ = 0;
+ return this;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_ChannelPoolConfig_descriptor;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ChannelPoolConfig getDefaultInstanceForType() {
+ return com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance();
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ChannelPoolConfig build() {
+ com.google.cloud.grpc.proto.ChannelPoolConfig result = buildPartial();
+ if (!result.isInitialized()) {
+ throw newUninitializedMessageException(result);
+ }
+ return result;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ChannelPoolConfig buildPartial() {
+ com.google.cloud.grpc.proto.ChannelPoolConfig result =
+ new com.google.cloud.grpc.proto.ChannelPoolConfig(this);
+ if (bitField0_ != 0) {
+ buildPartial0(result);
+ }
+ onBuilt();
+ return result;
+ }
+
+ private void buildPartial0(com.google.cloud.grpc.proto.ChannelPoolConfig result) {
+ int from_bitField0_ = bitField0_;
+ if (((from_bitField0_ & 0x00000001) != 0)) {
+ result.maxSize_ = maxSize_;
+ }
+ if (((from_bitField0_ & 0x00000002) != 0)) {
+ result.idleTimeout_ = idleTimeout_;
+ }
+ if (((from_bitField0_ & 0x00000004) != 0)) {
+ result.maxConcurrentStreamsLowWatermark_ = maxConcurrentStreamsLowWatermark_;
+ }
+ }
+
+ @java.lang.Override
+ public Builder clone() {
+ return super.clone();
+ }
+
+ @java.lang.Override
+ public Builder setField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.setField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder clearField(com.google.protobuf.Descriptors.FieldDescriptor field) {
+ return super.clearField(field);
+ }
+
+ @java.lang.Override
+ public Builder clearOneof(com.google.protobuf.Descriptors.OneofDescriptor oneof) {
+ return super.clearOneof(oneof);
+ }
+
+ @java.lang.Override
+ public Builder setRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, int index, java.lang.Object value) {
+ return super.setRepeatedField(field, index, value);
+ }
+
+ @java.lang.Override
+ public Builder addRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.addRepeatedField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(com.google.protobuf.Message other) {
+ if (other instanceof com.google.cloud.grpc.proto.ChannelPoolConfig) {
+ return mergeFrom((com.google.cloud.grpc.proto.ChannelPoolConfig) other);
+ } else {
+ super.mergeFrom(other);
+ return this;
+ }
+ }
+
+ public Builder mergeFrom(com.google.cloud.grpc.proto.ChannelPoolConfig other) {
+ if (other == com.google.cloud.grpc.proto.ChannelPoolConfig.getDefaultInstance()) return this;
+ if (other.getMaxSize() != 0) {
+ setMaxSize(other.getMaxSize());
+ }
+ if (other.getIdleTimeout() != 0L) {
+ setIdleTimeout(other.getIdleTimeout());
+ }
+ if (other.getMaxConcurrentStreamsLowWatermark() != 0) {
+ setMaxConcurrentStreamsLowWatermark(other.getMaxConcurrentStreamsLowWatermark());
+ }
+ this.mergeUnknownFields(other.getUnknownFields());
+ onChanged();
+ return this;
+ }
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ return true;
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ if (extensionRegistry == null) {
+ throw new java.lang.NullPointerException();
+ }
+ try {
+ boolean done = false;
+ while (!done) {
+ int tag = input.readTag();
+ switch (tag) {
+ case 0:
+ done = true;
+ break;
+ case 8:
+ {
+ maxSize_ = input.readUInt32();
+ bitField0_ |= 0x00000001;
+ break;
+ } // case 8
+ case 16:
+ {
+ idleTimeout_ = input.readUInt64();
+ bitField0_ |= 0x00000002;
+ break;
+ } // case 16
+ case 24:
+ {
+ maxConcurrentStreamsLowWatermark_ = input.readUInt32();
+ bitField0_ |= 0x00000004;
+ break;
+ } // case 24
+ default:
+ {
+ if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+ done = true; // was an endgroup tag
+ }
+ break;
+ } // default:
+ } // switch (tag)
+ } // while (!done)
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.unwrapIOException();
+ } finally {
+ onChanged();
+ } // finally
+ return this;
+ }
+
+ private int bitField0_;
+
+ private int maxSize_;
+
+ /**
+ *
+ *
+ *
+ * The max number of channels in the pool.
+ *
+ *
+ * uint32 max_size = 1;
+ *
+ * @return The maxSize.
+ */
+ @java.lang.Override
+ public int getMaxSize() {
+ return maxSize_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The max number of channels in the pool.
+ *
+ *
+ * uint32 max_size = 1;
+ *
+ * @param value The maxSize to set.
+ * @return This builder for chaining.
+ */
+ public Builder setMaxSize(int value) {
+
+ maxSize_ = value;
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The max number of channels in the pool.
+ *
+ *
+ * uint32 max_size = 1;
+ *
+ * @return This builder for chaining.
+ */
+ public Builder clearMaxSize() {
+ bitField0_ = (bitField0_ & ~0x00000001);
+ maxSize_ = 0;
+ onChanged();
+ return this;
+ }
+
+ private long idleTimeout_;
+
+ /**
+ *
+ *
+ *
+ * The idle timeout (seconds) of channels without bound affinity sessions.
+ *
+ *
+ * uint64 idle_timeout = 2;
+ *
+ * @return The idleTimeout.
+ */
+ @java.lang.Override
+ public long getIdleTimeout() {
+ return idleTimeout_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The idle timeout (seconds) of channels without bound affinity sessions.
+ *
+ *
+ * uint64 idle_timeout = 2;
+ *
+ * @param value The idleTimeout to set.
+ * @return This builder for chaining.
+ */
+ public Builder setIdleTimeout(long value) {
+
+ idleTimeout_ = value;
+ bitField0_ |= 0x00000002;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The idle timeout (seconds) of channels without bound affinity sessions.
+ *
+ *
+ * uint64 idle_timeout = 2;
+ *
+ * @return This builder for chaining.
+ */
+ public Builder clearIdleTimeout() {
+ bitField0_ = (bitField0_ & ~0x00000002);
+ idleTimeout_ = 0L;
+ onChanged();
+ return this;
+ }
+
+ private int maxConcurrentStreamsLowWatermark_;
+
+ /**
+ *
+ *
+ *
+ * The low watermark of max number of concurrent streams in a channel.
+ * New channel will be created once it get hit, until we reach the max size
+ * of the channel pool.
+ * Values outside of [0..100] will be ignored. 0 = always create a new channel
+ * until max size is reached.
+ *
+ *
+ * uint32 max_concurrent_streams_low_watermark = 3;
+ *
+ * @return The maxConcurrentStreamsLowWatermark.
+ */
+ @java.lang.Override
+ public int getMaxConcurrentStreamsLowWatermark() {
+ return maxConcurrentStreamsLowWatermark_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The low watermark of max number of concurrent streams in a channel.
+ * New channel will be created once it get hit, until we reach the max size
+ * of the channel pool.
+ * Values outside of [0..100] will be ignored. 0 = always create a new channel
+ * until max size is reached.
+ *
+ *
+ * uint32 max_concurrent_streams_low_watermark = 3;
+ *
+ * @param value The maxConcurrentStreamsLowWatermark to set.
+ * @return This builder for chaining.
+ */
+ public Builder setMaxConcurrentStreamsLowWatermark(int value) {
+
+ maxConcurrentStreamsLowWatermark_ = value;
+ bitField0_ |= 0x00000004;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The low watermark of max number of concurrent streams in a channel.
+ * New channel will be created once it get hit, until we reach the max size
+ * of the channel pool.
+ * Values outside of [0..100] will be ignored. 0 = always create a new channel
+ * until max size is reached.
+ *
+ *
+ * uint32 max_concurrent_streams_low_watermark = 3;
+ *
+ * @return This builder for chaining.
+ */
+ public Builder clearMaxConcurrentStreamsLowWatermark() {
+ bitField0_ = (bitField0_ & ~0x00000004);
+ maxConcurrentStreamsLowWatermark_ = 0;
+ onChanged();
+ return this;
+ }
+
+ @java.lang.Override
+ public final Builder setUnknownFields(final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.setUnknownFields(unknownFields);
+ }
+
+ @java.lang.Override
+ public final Builder mergeUnknownFields(
+ final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.mergeUnknownFields(unknownFields);
+ }
+
+ // @@protoc_insertion_point(builder_scope:grpc.gcp.ChannelPoolConfig)
+ }
+
+ // @@protoc_insertion_point(class_scope:grpc.gcp.ChannelPoolConfig)
+ private static final com.google.cloud.grpc.proto.ChannelPoolConfig DEFAULT_INSTANCE;
+
+ static {
+ DEFAULT_INSTANCE = new com.google.cloud.grpc.proto.ChannelPoolConfig();
+ }
+
+ public static com.google.cloud.grpc.proto.ChannelPoolConfig getDefaultInstance() {
+ return DEFAULT_INSTANCE;
+ }
+
+ private static final com.google.protobuf.Parser PARSER =
+ new com.google.protobuf.AbstractParser() {
+ @java.lang.Override
+ public ChannelPoolConfig parsePartialFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ Builder builder = newBuilder();
+ try {
+ builder.mergeFrom(input, extensionRegistry);
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.setUnfinishedMessage(builder.buildPartial());
+ } catch (com.google.protobuf.UninitializedMessageException e) {
+ throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+ } catch (java.io.IOException e) {
+ throw new com.google.protobuf.InvalidProtocolBufferException(e)
+ .setUnfinishedMessage(builder.buildPartial());
+ }
+ return builder.buildPartial();
+ }
+ };
+
+ public static com.google.protobuf.Parser parser() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Parser getParserForType() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.ChannelPoolConfig getDefaultInstanceForType() {
+ return DEFAULT_INSTANCE;
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ChannelPoolConfigOrBuilder.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ChannelPoolConfigOrBuilder.java
new file mode 100644
index 000000000000..2202e2243e87
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/ChannelPoolConfigOrBuilder.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+@java.lang.Deprecated
+public interface ChannelPoolConfigOrBuilder
+ extends
+ // @@protoc_insertion_point(interface_extends:grpc.gcp.ChannelPoolConfig)
+ com.google.protobuf.MessageOrBuilder {
+
+ /**
+ *
+ *
+ *
+ * The max number of channels in the pool.
+ *
+ *
+ * uint32 max_size = 1;
+ *
+ * @return The maxSize.
+ */
+ int getMaxSize();
+
+ /**
+ *
+ *
+ *
+ * The idle timeout (seconds) of channels without bound affinity sessions.
+ *
+ *
+ * uint64 idle_timeout = 2;
+ *
+ * @return The idleTimeout.
+ */
+ long getIdleTimeout();
+
+ /**
+ *
+ *
+ *
+ * The low watermark of max number of concurrent streams in a channel.
+ * New channel will be created once it get hit, until we reach the max size
+ * of the channel pool.
+ * Values outside of [0..100] will be ignored. 0 = always create a new channel
+ * until max size is reached.
+ *
+ *
+ * uint32 max_concurrent_streams_low_watermark = 3;
+ *
+ * @return The maxConcurrentStreamsLowWatermark.
+ */
+ int getMaxConcurrentStreamsLowWatermark();
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/GcpExtensionProto.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/GcpExtensionProto.java
new file mode 100644
index 000000000000..c07ae7d2ae13
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/GcpExtensionProto.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+public final class GcpExtensionProto {
+ private GcpExtensionProto() {}
+
+ public static void registerAllExtensions(com.google.protobuf.ExtensionRegistryLite registry) {}
+
+ public static void registerAllExtensions(com.google.protobuf.ExtensionRegistry registry) {
+ registerAllExtensions((com.google.protobuf.ExtensionRegistryLite) registry);
+ }
+
+ static final com.google.protobuf.Descriptors.Descriptor
+ internal_static_grpc_gcp_ApiConfig_descriptor;
+ static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internal_static_grpc_gcp_ApiConfig_fieldAccessorTable;
+ static final com.google.protobuf.Descriptors.Descriptor
+ internal_static_grpc_gcp_ChannelPoolConfig_descriptor;
+ static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internal_static_grpc_gcp_ChannelPoolConfig_fieldAccessorTable;
+ static final com.google.protobuf.Descriptors.Descriptor
+ internal_static_grpc_gcp_MethodConfig_descriptor;
+ static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internal_static_grpc_gcp_MethodConfig_fieldAccessorTable;
+ static final com.google.protobuf.Descriptors.Descriptor
+ internal_static_grpc_gcp_AffinityConfig_descriptor;
+ static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internal_static_grpc_gcp_AffinityConfig_fieldAccessorTable;
+
+ public static com.google.protobuf.Descriptors.FileDescriptor getDescriptor() {
+ return descriptor;
+ }
+
+ private static com.google.protobuf.Descriptors.FileDescriptor descriptor;
+
+ static {
+ java.lang.String[] descriptorData = {
+ "\n$google/grpc/gcp/proto/grpc_gcp.proto\022\010"
+ + "grpc.gcp\"k\n\tApiConfig\0225\n\014channel_pool\030\002 "
+ + "\001(\0132\033.grpc.gcp.ChannelPoolConfigB\002\030\001\022\'\n\006"
+ + "method\030\351\007 \003(\0132\026.grpc.gcp.MethodConfig\"m\n"
+ + "\021ChannelPoolConfig\022\020\n\010max_size\030\001 \001(\r\022\024\n\014"
+ + "idle_timeout\030\002 \001(\004\022,\n$max_concurrent_str"
+ + "eams_low_watermark\030\003 \001(\r:\002\030\001\"I\n\014MethodCo"
+ + "nfig\022\014\n\004name\030\001 \003(\t\022+\n\010affinity\030\351\007 \001(\0132\030."
+ + "grpc.gcp.AffinityConfig\"\205\001\n\016AffinityConf"
+ + "ig\0221\n\007command\030\002 \001(\0162 .grpc.gcp.AffinityC"
+ + "onfig.Command\022\024\n\014affinity_key\030\003 \001(\t\"*\n\007C"
+ + "ommand\022\t\n\005BOUND\020\000\022\010\n\004BIND\020\001\022\n\n\006UNBIND\020\002B"
+ + "2\n\033com.google.cloud.grpc.protoB\021GcpExten"
+ + "sionProtoP\001b\006proto3"
+ };
+ descriptor =
+ com.google.protobuf.Descriptors.FileDescriptor.internalBuildGeneratedFileFrom(
+ descriptorData, new com.google.protobuf.Descriptors.FileDescriptor[] {});
+ internal_static_grpc_gcp_ApiConfig_descriptor = getDescriptor().getMessageTypes().get(0);
+ internal_static_grpc_gcp_ApiConfig_fieldAccessorTable =
+ new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable(
+ internal_static_grpc_gcp_ApiConfig_descriptor,
+ new java.lang.String[] {
+ "ChannelPool", "Method",
+ });
+ internal_static_grpc_gcp_ChannelPoolConfig_descriptor =
+ getDescriptor().getMessageTypes().get(1);
+ internal_static_grpc_gcp_ChannelPoolConfig_fieldAccessorTable =
+ new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable(
+ internal_static_grpc_gcp_ChannelPoolConfig_descriptor,
+ new java.lang.String[] {
+ "MaxSize", "IdleTimeout", "MaxConcurrentStreamsLowWatermark",
+ });
+ internal_static_grpc_gcp_MethodConfig_descriptor = getDescriptor().getMessageTypes().get(2);
+ internal_static_grpc_gcp_MethodConfig_fieldAccessorTable =
+ new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable(
+ internal_static_grpc_gcp_MethodConfig_descriptor,
+ new java.lang.String[] {
+ "Name", "Affinity",
+ });
+ internal_static_grpc_gcp_AffinityConfig_descriptor = getDescriptor().getMessageTypes().get(3);
+ internal_static_grpc_gcp_AffinityConfig_fieldAccessorTable =
+ new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable(
+ internal_static_grpc_gcp_AffinityConfig_descriptor,
+ new java.lang.String[] {
+ "Command", "AffinityKey",
+ });
+ }
+
+ // @@protoc_insertion_point(outer_class_scope)
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/MethodConfig.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/MethodConfig.java
new file mode 100644
index 000000000000..d82d621c13db
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/MethodConfig.java
@@ -0,0 +1,1035 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+/** Protobuf type {@code grpc.gcp.MethodConfig} */
+public final class MethodConfig extends com.google.protobuf.GeneratedMessageV3
+ implements
+ // @@protoc_insertion_point(message_implements:grpc.gcp.MethodConfig)
+ MethodConfigOrBuilder {
+ private static final long serialVersionUID = 0L;
+
+ // Use MethodConfig.newBuilder() to construct.
+ private MethodConfig(com.google.protobuf.GeneratedMessageV3.Builder> builder) {
+ super(builder);
+ }
+
+ private MethodConfig() {
+ name_ = com.google.protobuf.LazyStringArrayList.emptyList();
+ }
+
+ @java.lang.Override
+ @SuppressWarnings({"unused"})
+ protected java.lang.Object newInstance(UnusedPrivateParameter unused) {
+ return new MethodConfig();
+ }
+
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_MethodConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_MethodConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.MethodConfig.class,
+ com.google.cloud.grpc.proto.MethodConfig.Builder.class);
+ }
+
+ private int bitField0_;
+ public static final int NAME_FIELD_NUMBER = 1;
+
+ @SuppressWarnings("serial")
+ private com.google.protobuf.LazyStringArrayList name_ =
+ com.google.protobuf.LazyStringArrayList.emptyList();
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return A list containing the name.
+ */
+ public com.google.protobuf.ProtocolStringList getNameList() {
+ return name_;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return The count of name.
+ */
+ public int getNameCount() {
+ return name_.size();
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index of the element to return.
+ * @return The name at the given index.
+ */
+ public java.lang.String getName(int index) {
+ return name_.get(index);
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index of the value to return.
+ * @return The bytes of the name at the given index.
+ */
+ public com.google.protobuf.ByteString getNameBytes(int index) {
+ return name_.getByteString(index);
+ }
+
+ public static final int AFFINITY_FIELD_NUMBER = 1001;
+ private com.google.cloud.grpc.proto.AffinityConfig affinity_;
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ *
+ * @return Whether the affinity field is set.
+ */
+ @java.lang.Override
+ public boolean hasAffinity() {
+ return ((bitField0_ & 0x00000001) != 0);
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ *
+ * @return The affinity.
+ */
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfig getAffinity() {
+ return affinity_ == null
+ ? com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance()
+ : affinity_;
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.AffinityConfigOrBuilder getAffinityOrBuilder() {
+ return affinity_ == null
+ ? com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance()
+ : affinity_;
+ }
+
+ private byte memoizedIsInitialized = -1;
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ byte isInitialized = memoizedIsInitialized;
+ if (isInitialized == 1) return true;
+ if (isInitialized == 0) return false;
+
+ memoizedIsInitialized = 1;
+ return true;
+ }
+
+ @java.lang.Override
+ public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException {
+ for (int i = 0; i < name_.size(); i++) {
+ com.google.protobuf.GeneratedMessageV3.writeString(output, 1, name_.getRaw(i));
+ }
+ if (((bitField0_ & 0x00000001) != 0)) {
+ output.writeMessage(1001, getAffinity());
+ }
+ getUnknownFields().writeTo(output);
+ }
+
+ @java.lang.Override
+ public int getSerializedSize() {
+ int size = memoizedSize;
+ if (size != -1) return size;
+
+ size = 0;
+ {
+ int dataSize = 0;
+ for (int i = 0; i < name_.size(); i++) {
+ dataSize += computeStringSizeNoTag(name_.getRaw(i));
+ }
+ size += dataSize;
+ size += 1 * getNameList().size();
+ }
+ if (((bitField0_ & 0x00000001) != 0)) {
+ size += com.google.protobuf.CodedOutputStream.computeMessageSize(1001, getAffinity());
+ }
+ size += getUnknownFields().getSerializedSize();
+ memoizedSize = size;
+ return size;
+ }
+
+ @java.lang.Override
+ public boolean equals(final java.lang.Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof com.google.cloud.grpc.proto.MethodConfig)) {
+ return super.equals(obj);
+ }
+ com.google.cloud.grpc.proto.MethodConfig other = (com.google.cloud.grpc.proto.MethodConfig) obj;
+
+ if (!getNameList().equals(other.getNameList())) return false;
+ if (hasAffinity() != other.hasAffinity()) return false;
+ if (hasAffinity()) {
+ if (!getAffinity().equals(other.getAffinity())) return false;
+ }
+ if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+ return true;
+ }
+
+ @java.lang.Override
+ public int hashCode() {
+ if (memoizedHashCode != 0) {
+ return memoizedHashCode;
+ }
+ int hash = 41;
+ hash = (19 * hash) + getDescriptor().hashCode();
+ if (getNameCount() > 0) {
+ hash = (37 * hash) + NAME_FIELD_NUMBER;
+ hash = (53 * hash) + getNameList().hashCode();
+ }
+ if (hasAffinity()) {
+ hash = (37 * hash) + AFFINITY_FIELD_NUMBER;
+ hash = (53 * hash) + getAffinity().hashCode();
+ }
+ hash = (29 * hash) + getUnknownFields().hashCode();
+ memoizedHashCode = hash;
+ return hash;
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(java.nio.ByteBuffer data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ com.google.protobuf.ByteString data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ com.google.protobuf.ByteString data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(byte[] data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseDelimitedFrom(
+ java.io.InputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseDelimitedFrom(
+ java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseDelimitedWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ com.google.protobuf.CodedInputStream input) throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(PARSER, input);
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig parseFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3.parseWithIOException(
+ PARSER, input, extensionRegistry);
+ }
+
+ @java.lang.Override
+ public Builder newBuilderForType() {
+ return newBuilder();
+ }
+
+ public static Builder newBuilder() {
+ return DEFAULT_INSTANCE.toBuilder();
+ }
+
+ public static Builder newBuilder(com.google.cloud.grpc.proto.MethodConfig prototype) {
+ return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+ }
+
+ @java.lang.Override
+ public Builder toBuilder() {
+ return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this);
+ }
+
+ @java.lang.Override
+ protected Builder newBuilderForType(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ Builder builder = new Builder(parent);
+ return builder;
+ }
+
+ /** Protobuf type {@code grpc.gcp.MethodConfig} */
+ public static final class Builder extends com.google.protobuf.GeneratedMessageV3.Builder
+ implements
+ // @@protoc_insertion_point(builder_implements:grpc.gcp.MethodConfig)
+ com.google.cloud.grpc.proto.MethodConfigOrBuilder {
+ public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_MethodConfig_descriptor;
+ }
+
+ @java.lang.Override
+ protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_MethodConfig_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ com.google.cloud.grpc.proto.MethodConfig.class,
+ com.google.cloud.grpc.proto.MethodConfig.Builder.class);
+ }
+
+ // Construct using com.google.cloud.grpc.proto.MethodConfig.newBuilder()
+ private Builder() {
+ maybeForceBuilderInitialization();
+ }
+
+ private Builder(com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+ super(parent);
+ maybeForceBuilderInitialization();
+ }
+
+ private void maybeForceBuilderInitialization() {
+ if (com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders) {
+ getAffinityFieldBuilder();
+ }
+ }
+
+ @java.lang.Override
+ public Builder clear() {
+ super.clear();
+ bitField0_ = 0;
+ name_ = com.google.protobuf.LazyStringArrayList.emptyList();
+ affinity_ = null;
+ if (affinityBuilder_ != null) {
+ affinityBuilder_.dispose();
+ affinityBuilder_ = null;
+ }
+ return this;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() {
+ return com.google.cloud.grpc.proto.GcpExtensionProto
+ .internal_static_grpc_gcp_MethodConfig_descriptor;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.MethodConfig getDefaultInstanceForType() {
+ return com.google.cloud.grpc.proto.MethodConfig.getDefaultInstance();
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.MethodConfig build() {
+ com.google.cloud.grpc.proto.MethodConfig result = buildPartial();
+ if (!result.isInitialized()) {
+ throw newUninitializedMessageException(result);
+ }
+ return result;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.MethodConfig buildPartial() {
+ com.google.cloud.grpc.proto.MethodConfig result =
+ new com.google.cloud.grpc.proto.MethodConfig(this);
+ if (bitField0_ != 0) {
+ buildPartial0(result);
+ }
+ onBuilt();
+ return result;
+ }
+
+ private void buildPartial0(com.google.cloud.grpc.proto.MethodConfig result) {
+ int from_bitField0_ = bitField0_;
+ if (((from_bitField0_ & 0x00000001) != 0)) {
+ name_.makeImmutable();
+ result.name_ = name_;
+ }
+ int to_bitField0_ = 0;
+ if (((from_bitField0_ & 0x00000002) != 0)) {
+ result.affinity_ = affinityBuilder_ == null ? affinity_ : affinityBuilder_.build();
+ to_bitField0_ |= 0x00000001;
+ }
+ result.bitField0_ |= to_bitField0_;
+ }
+
+ @java.lang.Override
+ public Builder clone() {
+ return super.clone();
+ }
+
+ @java.lang.Override
+ public Builder setField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.setField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder clearField(com.google.protobuf.Descriptors.FieldDescriptor field) {
+ return super.clearField(field);
+ }
+
+ @java.lang.Override
+ public Builder clearOneof(com.google.protobuf.Descriptors.OneofDescriptor oneof) {
+ return super.clearOneof(oneof);
+ }
+
+ @java.lang.Override
+ public Builder setRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, int index, java.lang.Object value) {
+ return super.setRepeatedField(field, index, value);
+ }
+
+ @java.lang.Override
+ public Builder addRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field, java.lang.Object value) {
+ return super.addRepeatedField(field, value);
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(com.google.protobuf.Message other) {
+ if (other instanceof com.google.cloud.grpc.proto.MethodConfig) {
+ return mergeFrom((com.google.cloud.grpc.proto.MethodConfig) other);
+ } else {
+ super.mergeFrom(other);
+ return this;
+ }
+ }
+
+ public Builder mergeFrom(com.google.cloud.grpc.proto.MethodConfig other) {
+ if (other == com.google.cloud.grpc.proto.MethodConfig.getDefaultInstance()) return this;
+ if (!other.name_.isEmpty()) {
+ if (name_.isEmpty()) {
+ name_ = other.name_;
+ bitField0_ |= 0x00000001;
+ } else {
+ ensureNameIsMutable();
+ name_.addAll(other.name_);
+ }
+ onChanged();
+ }
+ if (other.hasAffinity()) {
+ mergeAffinity(other.getAffinity());
+ }
+ this.mergeUnknownFields(other.getUnknownFields());
+ onChanged();
+ return this;
+ }
+
+ @java.lang.Override
+ public final boolean isInitialized() {
+ return true;
+ }
+
+ @java.lang.Override
+ public Builder mergeFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ if (extensionRegistry == null) {
+ throw new java.lang.NullPointerException();
+ }
+ try {
+ boolean done = false;
+ while (!done) {
+ int tag = input.readTag();
+ switch (tag) {
+ case 0:
+ done = true;
+ break;
+ case 10:
+ {
+ java.lang.String s = input.readStringRequireUtf8();
+ ensureNameIsMutable();
+ name_.add(s);
+ break;
+ } // case 10
+ case 8010:
+ {
+ input.readMessage(getAffinityFieldBuilder().getBuilder(), extensionRegistry);
+ bitField0_ |= 0x00000002;
+ break;
+ } // case 8010
+ default:
+ {
+ if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+ done = true; // was an endgroup tag
+ }
+ break;
+ } // default:
+ } // switch (tag)
+ } // while (!done)
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.unwrapIOException();
+ } finally {
+ onChanged();
+ } // finally
+ return this;
+ }
+
+ private int bitField0_;
+
+ private com.google.protobuf.LazyStringArrayList name_ =
+ com.google.protobuf.LazyStringArrayList.emptyList();
+
+ private void ensureNameIsMutable() {
+ if (!name_.isModifiable()) {
+ name_ = new com.google.protobuf.LazyStringArrayList(name_);
+ }
+ bitField0_ |= 0x00000001;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return A list containing the name.
+ */
+ public com.google.protobuf.ProtocolStringList getNameList() {
+ name_.makeImmutable();
+ return name_;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return The count of name.
+ */
+ public int getNameCount() {
+ return name_.size();
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index of the element to return.
+ * @return The name at the given index.
+ */
+ public java.lang.String getName(int index) {
+ return name_.get(index);
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index of the value to return.
+ * @return The bytes of the name at the given index.
+ */
+ public com.google.protobuf.ByteString getNameBytes(int index) {
+ return name_.getByteString(index);
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index to set the value at.
+ * @param value The name to set.
+ * @return This builder for chaining.
+ */
+ public Builder setName(int index, java.lang.String value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ ensureNameIsMutable();
+ name_.set(index, value);
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param value The name to add.
+ * @return This builder for chaining.
+ */
+ public Builder addName(java.lang.String value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ ensureNameIsMutable();
+ name_.add(value);
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param values The name to add.
+ * @return This builder for chaining.
+ */
+ public Builder addAllName(java.lang.Iterable values) {
+ ensureNameIsMutable();
+ com.google.protobuf.AbstractMessageLite.Builder.addAll(values, name_);
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return This builder for chaining.
+ */
+ public Builder clearName() {
+ name_ = com.google.protobuf.LazyStringArrayList.emptyList();
+ bitField0_ = (bitField0_ & ~0x00000001);
+ ;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param value The bytes of the name to add.
+ * @return This builder for chaining.
+ */
+ public Builder addNameBytes(com.google.protobuf.ByteString value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ checkByteStringIsUtf8(value);
+ ensureNameIsMutable();
+ name_.add(value);
+ bitField0_ |= 0x00000001;
+ onChanged();
+ return this;
+ }
+
+ private com.google.cloud.grpc.proto.AffinityConfig affinity_;
+ private com.google.protobuf.SingleFieldBuilderV3<
+ com.google.cloud.grpc.proto.AffinityConfig,
+ com.google.cloud.grpc.proto.AffinityConfig.Builder,
+ com.google.cloud.grpc.proto.AffinityConfigOrBuilder>
+ affinityBuilder_;
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ *
+ * @return Whether the affinity field is set.
+ */
+ public boolean hasAffinity() {
+ return ((bitField0_ & 0x00000002) != 0);
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ *
+ * @return The affinity.
+ */
+ public com.google.cloud.grpc.proto.AffinityConfig getAffinity() {
+ if (affinityBuilder_ == null) {
+ return affinity_ == null
+ ? com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance()
+ : affinity_;
+ } else {
+ return affinityBuilder_.getMessage();
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ public Builder setAffinity(com.google.cloud.grpc.proto.AffinityConfig value) {
+ if (affinityBuilder_ == null) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ affinity_ = value;
+ } else {
+ affinityBuilder_.setMessage(value);
+ }
+ bitField0_ |= 0x00000002;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ public Builder setAffinity(com.google.cloud.grpc.proto.AffinityConfig.Builder builderForValue) {
+ if (affinityBuilder_ == null) {
+ affinity_ = builderForValue.build();
+ } else {
+ affinityBuilder_.setMessage(builderForValue.build());
+ }
+ bitField0_ |= 0x00000002;
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ public Builder mergeAffinity(com.google.cloud.grpc.proto.AffinityConfig value) {
+ if (affinityBuilder_ == null) {
+ if (((bitField0_ & 0x00000002) != 0)
+ && affinity_ != null
+ && affinity_ != com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance()) {
+ getAffinityBuilder().mergeFrom(value);
+ } else {
+ affinity_ = value;
+ }
+ } else {
+ affinityBuilder_.mergeFrom(value);
+ }
+ if (affinity_ != null) {
+ bitField0_ |= 0x00000002;
+ onChanged();
+ }
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ public Builder clearAffinity() {
+ bitField0_ = (bitField0_ & ~0x00000002);
+ affinity_ = null;
+ if (affinityBuilder_ != null) {
+ affinityBuilder_.dispose();
+ affinityBuilder_ = null;
+ }
+ onChanged();
+ return this;
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ public com.google.cloud.grpc.proto.AffinityConfig.Builder getAffinityBuilder() {
+ bitField0_ |= 0x00000002;
+ onChanged();
+ return getAffinityFieldBuilder().getBuilder();
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ public com.google.cloud.grpc.proto.AffinityConfigOrBuilder getAffinityOrBuilder() {
+ if (affinityBuilder_ != null) {
+ return affinityBuilder_.getMessageOrBuilder();
+ } else {
+ return affinity_ == null
+ ? com.google.cloud.grpc.proto.AffinityConfig.getDefaultInstance()
+ : affinity_;
+ }
+ }
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ private com.google.protobuf.SingleFieldBuilderV3<
+ com.google.cloud.grpc.proto.AffinityConfig,
+ com.google.cloud.grpc.proto.AffinityConfig.Builder,
+ com.google.cloud.grpc.proto.AffinityConfigOrBuilder>
+ getAffinityFieldBuilder() {
+ if (affinityBuilder_ == null) {
+ affinityBuilder_ =
+ new com.google.protobuf.SingleFieldBuilderV3<
+ com.google.cloud.grpc.proto.AffinityConfig,
+ com.google.cloud.grpc.proto.AffinityConfig.Builder,
+ com.google.cloud.grpc.proto.AffinityConfigOrBuilder>(
+ getAffinity(), getParentForChildren(), isClean());
+ affinity_ = null;
+ }
+ return affinityBuilder_;
+ }
+
+ @java.lang.Override
+ public final Builder setUnknownFields(final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.setUnknownFields(unknownFields);
+ }
+
+ @java.lang.Override
+ public final Builder mergeUnknownFields(
+ final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.mergeUnknownFields(unknownFields);
+ }
+
+ // @@protoc_insertion_point(builder_scope:grpc.gcp.MethodConfig)
+ }
+
+ // @@protoc_insertion_point(class_scope:grpc.gcp.MethodConfig)
+ private static final com.google.cloud.grpc.proto.MethodConfig DEFAULT_INSTANCE;
+
+ static {
+ DEFAULT_INSTANCE = new com.google.cloud.grpc.proto.MethodConfig();
+ }
+
+ public static com.google.cloud.grpc.proto.MethodConfig getDefaultInstance() {
+ return DEFAULT_INSTANCE;
+ }
+
+ private static final com.google.protobuf.Parser PARSER =
+ new com.google.protobuf.AbstractParser() {
+ @java.lang.Override
+ public MethodConfig parsePartialFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ Builder builder = newBuilder();
+ try {
+ builder.mergeFrom(input, extensionRegistry);
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.setUnfinishedMessage(builder.buildPartial());
+ } catch (com.google.protobuf.UninitializedMessageException e) {
+ throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+ } catch (java.io.IOException e) {
+ throw new com.google.protobuf.InvalidProtocolBufferException(e)
+ .setUnfinishedMessage(builder.buildPartial());
+ }
+ return builder.buildPartial();
+ }
+ };
+
+ public static com.google.protobuf.Parser parser() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.protobuf.Parser getParserForType() {
+ return PARSER;
+ }
+
+ @java.lang.Override
+ public com.google.cloud.grpc.proto.MethodConfig getDefaultInstanceForType() {
+ return DEFAULT_INSTANCE;
+ }
+}
diff --git a/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/MethodConfigOrBuilder.java b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/MethodConfigOrBuilder.java
new file mode 100644
index 000000000000..899b76c1d947
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/java/com/google/cloud/grpc/proto/MethodConfigOrBuilder.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: google/grpc/gcp/proto/grpc_gcp.proto
+
+// Protobuf Java Version: 3.25.5
+package com.google.cloud.grpc.proto;
+
+public interface MethodConfigOrBuilder
+ extends
+ // @@protoc_insertion_point(interface_extends:grpc.gcp.MethodConfig)
+ com.google.protobuf.MessageOrBuilder {
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return A list containing the name.
+ */
+ java.util.List getNameList();
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @return The count of name.
+ */
+ int getNameCount();
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index of the element to return.
+ * @return The name at the given index.
+ */
+ java.lang.String getName(int index);
+
+ /**
+ *
+ *
+ *
+ * A fully qualified name of a gRPC method, or a wildcard pattern ending
+ * with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ * sequentially, and the first one takes precedence.
+ *
+ *
+ * repeated string name = 1;
+ *
+ * @param index The index of the value to return.
+ * @return The bytes of the name at the given index.
+ */
+ com.google.protobuf.ByteString getNameBytes(int index);
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ *
+ * @return Whether the affinity field is set.
+ */
+ boolean hasAffinity();
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ *
+ * @return The affinity.
+ */
+ com.google.cloud.grpc.proto.AffinityConfig getAffinity();
+
+ /**
+ *
+ *
+ *
+ * The channel affinity configurations.
+ *
+ *
+ * .grpc.gcp.AffinityConfig affinity = 1001;
+ */
+ com.google.cloud.grpc.proto.AffinityConfigOrBuilder getAffinityOrBuilder();
+}
diff --git a/java-spanner/grpc-gcp/src/main/resources/google/grpc/gcp/proto/grpc_gcp.proto b/java-spanner/grpc-gcp/src/main/resources/google/grpc/gcp/proto/grpc_gcp.proto
new file mode 100644
index 000000000000..81987f19d289
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/main/resources/google/grpc/gcp/proto/grpc_gcp.proto
@@ -0,0 +1,81 @@
+// Copyright 2019 gRPC authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package grpc.gcp;
+
+option java_multiple_files = true;
+option java_outer_classname = "GcpExtensionProto";
+option java_package = "com.google.cloud.grpc.proto";
+
+message ApiConfig {
+ // Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+ // The channel pool configurations.
+ ChannelPoolConfig channel_pool = 2 [deprecated = true];
+
+ // The method configurations.
+ repeated MethodConfig method = 1001;
+}
+
+
+// Deprecated. Use GcpManagedChannelOptions.GcpChannelPoolOptions class.
+message ChannelPoolConfig {
+ option deprecated = true;
+
+ // The max number of channels in the pool.
+ uint32 max_size = 1;
+ // The idle timeout (seconds) of channels without bound affinity sessions.
+ uint64 idle_timeout = 2;
+ // The low watermark of max number of concurrent streams in a channel.
+ // New channel will be created once it get hit, until we reach the max size
+ // of the channel pool.
+ // Values outside of [0..100] will be ignored. 0 = always create a new channel
+ // until max size is reached.
+ uint32 max_concurrent_streams_low_watermark = 3;
+}
+
+message MethodConfig {
+ // A fully qualified name of a gRPC method, or a wildcard pattern ending
+ // with .*, such as foo.bar.A, foo.bar.*. Method configs are evaluated
+ // sequentially, and the first one takes precedence.
+ repeated string name = 1;
+
+ // The channel affinity configurations.
+ AffinityConfig affinity = 1001;
+}
+
+message AffinityConfig {
+ enum Command {
+ // The annotated method will be required to be bound to an existing session
+ // to execute the RPC. The corresponding will be
+ // used to find the affinity key from the request message.
+ BOUND = 0;
+ // The annotated method will establish the channel affinity with the channel
+ // which is used to execute the RPC. The corresponding
+ // will be used to find the affinity key from the
+ // response message.
+ BIND = 1;
+ // The annotated method will remove the channel affinity with the channel
+ // which is used to execute the RPC. The corresponding
+ // will be used to find the affinity key from the
+ // request message.
+ UNBIND = 2;
+ }
+ // The affinity command applies on the selected gRPC methods.
+ Command command = 2;
+ // The field path of the affinity key in the request/response message.
+ // For example: "f.a", "f.b.d", etc.
+ string affinity_key = 3;
+}
diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/ChannelIdPropagationTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/ChannelIdPropagationTest.java
new file mode 100644
index 000000000000..a4530257fc1c
--- /dev/null
+++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/ChannelIdPropagationTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.grpc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.grpc.GcpManagedChannelOptions.GcpChannelPoolOptions;
+import io.grpc.CallOptions;
+import io.grpc.Channel;
+import io.grpc.ClientCall;
+import io.grpc.ClientInterceptor;
+import io.grpc.ForwardingClientCall;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import java.io.InputStream;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ChannelIdPropagationTest {
+
+ private static class FakeMarshaller