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: + * + *

    + *
  1. Fallback to an alternative endpoint (host:port) of a gRPC service when the original + * endpoint is completely unavailable. + *
  2. 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: + * + *

+ * + *

Example configuration: + * + *

+ * + *

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. + * + *

+ * + *

Endpoints are matched by the endpoint address (usually in the form of address:port). + * + *

+ */ + 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 + 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 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 + 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 + 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 implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(T value) { + return null; + } + + @Override + public T parse(InputStream stream) { + return null; + } + } + + @Test + public void testChannelIdKeySetWithoutAffinity() { + ManagedChannelBuilder builder = ManagedChannelBuilder.forAddress("localhost", 443); + final AtomicInteger channelId = new AtomicInteger(-1); + + ClientInterceptor channelIdInterceptor = + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + if (callOptions.getOption(GcpManagedChannel.CHANNEL_ID_KEY) != null) { + channelId.set(callOptions.getOption(GcpManagedChannel.CHANNEL_ID_KEY)); + } + super.start(responseListener, headers); + } + }; + } + }; + + // Add interceptor to the delegate builder so it runs on the underlying channels. + builder.intercept(channelIdInterceptor); + + // Creating a pool. + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder().setMinSize(3).setMaxSize(3).build()) + .build()) + .build(); + + MethodDescriptor methodDescriptor = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test/method") + .setRequestMarshaller(new FakeMarshaller<>()) + .setResponseMarshaller(new FakeMarshaller<>()) + .build(); + + // Use the pool directly (interceptor is already inside) + ClientCall newCall = pool.newCall(methodDescriptor, CallOptions.DEFAULT); + Metadata headers = new Metadata(); + + // First call (should initialize channel and correct ID) + newCall.start( + new ForwardingClientCall.SimpleForwardingClientCall.Listener() {}, headers); + + // Should be one of the possible 3 ids: 0, 1, 2. + assertThat(channelId.get()).isAnyOf(0, 1, 2); + + // Attempt 2 + newCall = pool.newCall(methodDescriptor, CallOptions.DEFAULT); + newCall.start( + new ForwardingClientCall.SimpleForwardingClientCall.Listener() {}, headers); + + // Should be one of the possible 3 ids: 0, 1, 2. + assertThat(channelId.get()).isAnyOf(0, 1, 2); + + pool.shutdownNow(); + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpClientCallTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpClientCallTest.java new file mode 100644 index 000000000000..ce113e95c457 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpClientCallTest.java @@ -0,0 +1,165 @@ +/* + * 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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ConnectivityState; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import java.io.InputStream; +import java.util.Collections; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public final class GcpClientCallTest { + + private static final class FakeMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(T value) { + return null; + } + + @Override + public T parse(InputStream stream) { + return null; + } + } + + private static final MethodDescriptor METHOD_DESCRIPTOR = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test/method") + .setRequestMarshaller(new FakeMarshaller<>()) + .setResponseMarshaller(new FakeMarshaller<>()) + .build(); + + @Mock private ManagedChannel delegateChannel; + @Mock private ClientCall delegateCall; + + private GcpManagedChannel gcpChannel; + private GcpManagedChannel.ChannelRef channelRef; + + @Before + public void setUp() { + ManagedChannelBuilder builder = ManagedChannelBuilder.forAddress("localhost", 443); + gcpChannel = (GcpManagedChannel) GcpManagedChannelBuilder.forDelegateBuilder(builder).build(); + + when(delegateChannel.getState(anyBoolean())).thenReturn(ConnectivityState.IDLE); + when(delegateChannel.newCall(eq(METHOD_DESCRIPTOR), any(CallOptions.class))) + .thenReturn(delegateCall); + + channelRef = gcpChannel.new ChannelRef(delegateChannel); + } + + @After + public void tearDown() { + gcpChannel.shutdownNow(); + } + + @SuppressWarnings("unchecked") + @Test + public void simpleCallUnbindsAffinityKeyOnCloseWhenRequested() { + String affinityKey = "txn-1"; + gcpChannel.bind(channelRef, Collections.singletonList(affinityKey)); + + GcpClientCall.SimpleGcpClientCall call = + new GcpClientCall.SimpleGcpClientCall<>( + gcpChannel, + channelRef, + METHOD_DESCRIPTOR, + CallOptions.DEFAULT + .withOption(GcpManagedChannel.AFFINITY_KEY, affinityKey) + .withOption(GcpManagedChannel.UNBIND_AFFINITY_KEY, true)); + + call.start(new ClientCall.Listener() {}, new Metadata()); + + ArgumentCaptor> listenerCaptor = + (ArgumentCaptor>) + (ArgumentCaptor) ArgumentCaptor.forClass(ClientCall.Listener.class); + verify(delegateCall).start(listenerCaptor.capture(), any(Metadata.class)); + + assertThat(gcpChannel.affinityKeyToChannelRef).containsKey(affinityKey); + + listenerCaptor.getValue().onClose(Status.OK, new Metadata()); + + assertThat(gcpChannel.affinityKeyToChannelRef).doesNotContainKey(affinityKey); + assertThat(channelRef.getAffinityCount()).isEqualTo(0); + } + + @SuppressWarnings("unchecked") + @Test + public void simpleCallKeepsAffinityKeyOnCloseWhenUnbindNotRequested() { + String affinityKey = "txn-2"; + gcpChannel.bind(channelRef, Collections.singletonList(affinityKey)); + + GcpClientCall.SimpleGcpClientCall call = + new GcpClientCall.SimpleGcpClientCall<>( + gcpChannel, + channelRef, + METHOD_DESCRIPTOR, + CallOptions.DEFAULT.withOption(GcpManagedChannel.AFFINITY_KEY, affinityKey)); + + call.start(new ClientCall.Listener() {}, new Metadata()); + + ArgumentCaptor> listenerCaptor = + (ArgumentCaptor>) + (ArgumentCaptor) ArgumentCaptor.forClass(ClientCall.Listener.class); + verify(delegateCall).start(listenerCaptor.capture(), any(Metadata.class)); + + listenerCaptor.getValue().onClose(Status.OK, new Metadata()); + + assertThat(gcpChannel.affinityKeyToChannelRef).containsEntry(affinityKey, channelRef); + assertThat(channelRef.getAffinityCount()).isEqualTo(1); + } + + @Test + public void simpleCallUnbindsAffinityKeyOnCancel() { + String affinityKey = "txn-3"; + gcpChannel.bind(channelRef, Collections.singletonList(affinityKey)); + + GcpClientCall.SimpleGcpClientCall call = + new GcpClientCall.SimpleGcpClientCall<>( + gcpChannel, + channelRef, + METHOD_DESCRIPTOR, + CallOptions.DEFAULT.withOption(GcpManagedChannel.AFFINITY_KEY, affinityKey)); + + call.start(new ClientCall.Listener() {}, new Metadata()); + call.cancel("cancelled", null); + + assertThat(gcpChannel.affinityKeyToChannelRef).doesNotContainKey(affinityKey); + assertThat(channelRef.getAffinityCount()).isEqualTo(0); + verify(delegateCall).cancel("cancelled", null); + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelOptionsTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelOptionsTest.java new file mode 100644 index 000000000000..7dc407ff1381 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelOptionsTest.java @@ -0,0 +1,251 @@ +/* + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.grpc.GcpManagedChannelOptions.GcpChannelPoolOptions; +import com.google.cloud.grpc.GcpManagedChannelOptions.GcpMetricsOptions; +import com.google.cloud.grpc.GcpManagedChannelOptions.GcpResiliencyOptions; +import io.opencensus.metrics.LabelKey; +import io.opencensus.metrics.LabelValue; +import io.opencensus.metrics.MetricRegistry; +import io.opencensus.metrics.Metrics; +import java.time.Duration; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for GcpManagedChannelOptionsTest. */ +@RunWith(JUnit4.class) +public final class GcpManagedChannelOptionsTest { + private static final String namePrefix = "name-prefix"; + private static final String labelName = "label_name"; + private static final String labelDescription = "Label description"; + private static final String labelValue = "label-value"; + private static final MetricRegistry metricRegistry = Metrics.getMetricRegistry(); + private static final int unresponsiveMs = 100; + private static final int unresponsiveDroppedCount = 3; + + @Rule public ExpectedException exceptionRule = ExpectedException.none(); + + private GcpManagedChannelOptions buildOptions() { + return GcpManagedChannelOptions.newBuilder() + .withMetricsOptions( + GcpMetricsOptions.newBuilder() + .withNamePrefix(namePrefix) + .withLabels( + Collections.singletonList(LabelKey.create(labelName, labelDescription)), + Collections.singletonList(LabelValue.create(labelValue))) + .withMetricRegistry(metricRegistry) + .build()) + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder() + .setNotReadyFallback(true) + .withUnresponsiveConnectionDetection(unresponsiveMs, unresponsiveDroppedCount) + .build()) + .build(); + } + + @Test + public void testOptionsBuilders() { + final GcpManagedChannelOptions opts = buildOptions(); + + assertNotNull(opts.getMetricsOptions()); + assertNotNull(opts.getResiliencyOptions()); + GcpMetricsOptions metricsOpts = opts.getMetricsOptions(); + GcpResiliencyOptions resOpts = opts.getResiliencyOptions(); + assertEquals(metricRegistry, metricsOpts.getMetricRegistry()); + assertEquals(namePrefix, metricsOpts.getNamePrefix()); + assertEquals(1, metricsOpts.getLabelKeys().size()); + assertEquals(1, metricsOpts.getLabelValues().size()); + assertEquals(labelName, metricsOpts.getLabelKeys().get(0).getKey()); + assertEquals(labelDescription, metricsOpts.getLabelKeys().get(0).getDescription()); + assertEquals(labelValue, metricsOpts.getLabelValues().get(0).getValue()); + assertTrue(resOpts.isNotReadyFallbackEnabled()); + assertTrue(resOpts.isUnresponsiveDetectionEnabled()); + assertEquals(unresponsiveMs, resOpts.getUnresponsiveDetectionMs()); + assertEquals(unresponsiveDroppedCount, resOpts.getUnresponsiveDetectionDroppedCount()); + } + + @Test + public void testUnresponsiveDetectionMsPreconditions() { + exceptionRule.expect(IllegalArgumentException.class); + exceptionRule.expectMessage("ms should be > 0, got 0"); + + GcpManagedChannelOptions opts = + GcpManagedChannelOptions.newBuilder() + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder().withUnresponsiveConnectionDetection(0, 0).build()) + .build(); + } + + @Test + public void testUnresponsiveDetectionDroppedCountPreconditions() { + exceptionRule.expect(IllegalArgumentException.class); + exceptionRule.expectMessage("numDroppedRequests should be > 0, got -1"); + + GcpManagedChannelOptions opts = + GcpManagedChannelOptions.newBuilder() + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder() + .withUnresponsiveConnectionDetection(100, -1) + .build()) + .build(); + } + + @Test + public void testOptionsReBuild() { + final GcpManagedChannelOptions opts = buildOptions(); + + final String updatedLabelValue = "updated-label-value"; + + GcpManagedChannelOptions updatedOptions = + GcpManagedChannelOptions.newBuilder(opts) + .withMetricsOptions( + GcpMetricsOptions.newBuilder(opts.getMetricsOptions()) + .withLabels( + Collections.singletonList(LabelKey.create(labelName, labelDescription)), + Collections.singletonList(LabelValue.create(updatedLabelValue))) + .build()) + .build(); + + assertNotNull(updatedOptions.getMetricsOptions()); + assertNotNull(updatedOptions.getResiliencyOptions()); + GcpMetricsOptions metricsOpts = updatedOptions.getMetricsOptions(); + GcpResiliencyOptions resOpts = updatedOptions.getResiliencyOptions(); + assertEquals(metricRegistry, metricsOpts.getMetricRegistry()); + assertEquals(namePrefix, metricsOpts.getNamePrefix()); + assertEquals(1, metricsOpts.getLabelKeys().size()); + assertEquals(1, metricsOpts.getLabelValues().size()); + assertEquals(labelName, metricsOpts.getLabelKeys().get(0).getKey()); + assertEquals(labelDescription, metricsOpts.getLabelKeys().get(0).getDescription()); + assertEquals(updatedLabelValue, metricsOpts.getLabelValues().get(0).getValue()); + assertTrue(resOpts.isNotReadyFallbackEnabled()); + assertTrue(resOpts.isUnresponsiveDetectionEnabled()); + assertEquals(unresponsiveMs, resOpts.getUnresponsiveDetectionMs()); + assertEquals(unresponsiveDroppedCount, resOpts.getUnresponsiveDetectionDroppedCount()); + + updatedOptions = + GcpManagedChannelOptions.newBuilder(opts) + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder(opts.getResiliencyOptions()) + .setNotReadyFallback(false) + .build()) + .build(); + + assertNotNull(updatedOptions.getMetricsOptions()); + assertNotNull(updatedOptions.getResiliencyOptions()); + metricsOpts = updatedOptions.getMetricsOptions(); + resOpts = updatedOptions.getResiliencyOptions(); + assertEquals(metricRegistry, metricsOpts.getMetricRegistry()); + assertEquals(namePrefix, metricsOpts.getNamePrefix()); + assertEquals(1, metricsOpts.getLabelKeys().size()); + assertEquals(1, metricsOpts.getLabelValues().size()); + assertEquals(labelName, metricsOpts.getLabelKeys().get(0).getKey()); + assertEquals(labelDescription, metricsOpts.getLabelKeys().get(0).getDescription()); + assertEquals(labelValue, metricsOpts.getLabelValues().get(0).getValue()); + assertFalse(resOpts.isNotReadyFallbackEnabled()); + assertTrue(resOpts.isUnresponsiveDetectionEnabled()); + assertEquals(unresponsiveMs, resOpts.getUnresponsiveDetectionMs()); + assertEquals(unresponsiveDroppedCount, resOpts.getUnresponsiveDetectionDroppedCount()); + } + + @Test + public void testPoolOptions() { + final GcpManagedChannelOptions opts = + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setMaxSize(5) + .setMinSize(2) + .setConcurrentStreamsLowWatermark(10) + .setUseRoundRobinOnBind(true) + .setAffinityKeyLifetime(Duration.ofSeconds(3600)) + .setCleanupInterval(Duration.ofSeconds(30)) + .build()) + .build(); + + GcpChannelPoolOptions channelPoolOptions = opts.getChannelPoolOptions(); + assertThat(channelPoolOptions).isNotNull(); + assertThat(channelPoolOptions.getMaxSize()).isEqualTo(5); + assertThat(channelPoolOptions.getMinSize()).isEqualTo(2); + assertThat(channelPoolOptions.getConcurrentStreamsLowWatermark()).isEqualTo(10); + assertThat(channelPoolOptions.isUseRoundRobinOnBind()).isTrue(); + assertThat(channelPoolOptions.getAffinityKeyLifetime()).isEqualTo(Duration.ofSeconds(3600)); + assertThat(channelPoolOptions.getCleanupInterval()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + public void testAffinityKeysCleanupZeroByDefault() { + final GcpManagedChannelOptions opts = + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions(GcpChannelPoolOptions.newBuilder().build()) + .build(); + final GcpChannelPoolOptions channelPoolOptions = opts.getChannelPoolOptions(); + assertThat(channelPoolOptions.getAffinityKeyLifetime()).isEqualTo(Duration.ZERO); + assertThat(channelPoolOptions.getCleanupInterval()).isEqualTo(Duration.ZERO); + } + + @Test + public void testCleanupDefault() { + GcpManagedChannelOptions opts = + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setAffinityKeyLifetime(Duration.ofSeconds(3600)) + .build()) + .build(); + GcpChannelPoolOptions channelPoolOptions = opts.getChannelPoolOptions(); + assertThat(channelPoolOptions.getAffinityKeyLifetime()).isEqualTo(Duration.ofSeconds(3600)); + assertThat(channelPoolOptions.getCleanupInterval()).isEqualTo(Duration.ofSeconds(360)); + + opts = + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setCleanupInterval(Duration.ZERO) + .setAffinityKeyLifetime(Duration.ofSeconds(3600)) + .build()) + .build(); + channelPoolOptions = opts.getChannelPoolOptions(); + assertThat(channelPoolOptions.getAffinityKeyLifetime()).isEqualTo(Duration.ofSeconds(3600)); + assertThat(channelPoolOptions.getCleanupInterval()).isEqualTo(Duration.ofSeconds(360)); + } + + @Test + public void testCleanupMustNotBeZero() { + exceptionRule.expect(IllegalArgumentException.class); + exceptionRule.expectMessage( + "Cleanup interval must not be zero when affinity key interval is above zero."); + + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setAffinityKeyLifetime(Duration.ofSeconds(3600)) + .setCleanupInterval(Duration.ZERO) + .build()) + .build(); + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelOtelMetricsTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelOtelMetricsTest.java new file mode 100644 index 000000000000..c7eb432ce281 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelOtelMetricsTest.java @@ -0,0 +1,102 @@ +/* + * 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 org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GcpManagedChannelOtelMetricsTest { + + private ManagedChannel channelToShutdown; + + @After + public void tearDown() { + if (channelToShutdown != null) { + channelToShutdown.shutdownNow(); + try { + channelToShutdown.awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + } + } + + @Test + public void emitsOtelMetricsWhenMeterProvided() { + InMemoryMetricReader inMemoryMetricReader = InMemoryMetricReader.create(); + SdkMeterProvider meterProvider = + SdkMeterProvider.builder().registerMetricReader(inMemoryMetricReader).build(); + OpenTelemetry openTelemetry = + OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); + + GcpManagedChannelOptions.GcpMetricsOptions metricsOptions = + GcpManagedChannelOptions.GcpMetricsOptions.newBuilder() + .withOpenTelemetryMeter(openTelemetry.getMeter("grpc-gcp-test")) + .withNamePrefix("test/grpc-gcp/") + .build(); + + GcpManagedChannelOptions options = + GcpManagedChannelOptions.newBuilder() + .withMetricsOptions(metricsOptions) + .withChannelPoolOptions( + GcpManagedChannelOptions.GcpChannelPoolOptions.newBuilder() + .setMaxSize(1) + .setMinSize(1) + .build()) + .build(); + + ManagedChannelBuilder builder = + ManagedChannelBuilder.forAddress("localhost", 0).usePlaintext(); + GcpManagedChannel gcpChannel = new GcpManagedChannel(builder, /* apiConfig= */ null, options); + this.channelToShutdown = gcpChannel; + + // Trigger metrics collection callback at least once. + inMemoryMetricReader.collectAllMetrics(); + + Collection metrics = inMemoryMetricReader.collectAllMetrics(); + assertNotNull(metrics); + List names = metrics.stream().map(MetricData::getName).collect(Collectors.toList()); + + assertTrue( + names.stream() + .anyMatch(n -> n.equals("test/grpc-gcp/" + GcpMetricsConstants.METRIC_NUM_CHANNELS))); + assertTrue( + names.stream() + .anyMatch( + n -> n.equals("test/grpc-gcp/" + GcpMetricsConstants.METRIC_MIN_READY_CHANNELS))); + assertTrue( + names.stream() + .anyMatch( + n -> n.equals("test/grpc-gcp/" + GcpMetricsConstants.METRIC_MAX_READY_CHANNELS))); + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelTest.java new file mode 100644 index 000000000000..e80ece92107c --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpManagedChannelTest.java @@ -0,0 +1,2515 @@ +/* + * 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.GcpManagedChannel.getKeysFromMessage; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.google.cloud.grpc.GcpManagedChannel.ChannelRef; +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.MetricRegistryTestUtils.FakeMetricRegistry; +import com.google.cloud.grpc.MetricRegistryTestUtils.MetricsRecord; +import com.google.cloud.grpc.MetricRegistryTestUtils.PointWithFunction; +import com.google.cloud.grpc.proto.AffinityConfig; +import com.google.cloud.grpc.proto.ApiConfig; +import com.google.cloud.grpc.proto.ChannelPoolConfig; +import com.google.cloud.grpc.proto.MethodConfig; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.spanner.v1.PartitionReadRequest; +import com.google.spanner.v1.TransactionSelector; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.CompressorRegistry; +import io.grpc.ConnectivityState; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.MethodDescriptor; +import io.grpc.NameResolver.Factory; +import io.grpc.Status; +import io.grpc.Status.Code; +import io.opencensus.metrics.LabelKey; +import io.opencensus.metrics.LabelValue; +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for GcpManagedChannel. */ +@RunWith(JUnit4.class) +public final class GcpManagedChannelTest { + + private static final String TARGET = "localhost"; + private static final String API_FILE = "apiconfig.json"; + private static final String EMPTY_METHOD_FILE = "empty_method.json"; + private static final String EMPTY_CHANNEL_FILE = "empty_channel.json"; + + private static final int MAX_CHANNEL = 10; + private static final int MAX_STREAM = 100; + private static final Logger testLogger = Logger.getLogger(GcpManagedChannel.class.getName()); + + private final List logRecords = new LinkedList<>(); + + private String lastLogMessage() { + return lastLogMessage(1); + } + + private String lastLogMessage(int nthFromLast) { + return logRecords.get(logRecords.size() - nthFromLast).getMessage(); + } + + private Level lastLogLevel() { + return lastLogLevel(1); + } + + private Level lastLogLevel(int nthFromLast) { + return logRecords.get(logRecords.size() - nthFromLast).getLevel(); + } + + private final Handler testLogHandler = + new Handler() { + @Override + public synchronized void publish(LogRecord record) { + logRecords.add(record); + } + + @Override + public void flush() {} + + @Override + public void close() throws SecurityException {} + }; + + private GcpManagedChannel gcpChannel; + private ManagedChannelBuilder builder; + + /** Close and delete all the channelRefs inside a gcpchannel. */ + private void resetGcpChannel() { + gcpChannel.shutdownNow(); + gcpChannel.channelRefs.clear(); + } + + @Before + public void setUpChannel() { + testLogger.addHandler(testLogHandler); + builder = ManagedChannelBuilder.forAddress(TARGET, 443); + gcpChannel = (GcpManagedChannel) GcpManagedChannelBuilder.forDelegateBuilder(builder).build(); + } + + @After + public void shutdown() { + gcpChannel.shutdownNow(); + testLogger.removeHandler(testLogHandler); + testLogger.setLevel(Level.INFO); + logRecords.clear(); + } + + @Test + public void testLoadApiConfigFile() { + resetGcpChannel(); + final URL resource = GcpManagedChannelTest.class.getClassLoader().getResource(API_FILE); + assertNotNull(resource); + File configFile = new File(resource.getFile()); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfigJsonFile(configFile) + .build(); + assertEquals(0, gcpChannel.channelRefs.size()); + assertEquals(3, gcpChannel.getMaxSize()); + assertEquals(2, gcpChannel.getStreamsLowWatermark()); + assertEquals(3, gcpChannel.methodToAffinity.size()); + } + + @Test + public void testLoadApiConfigString() throws Exception { + resetGcpChannel(); + InputStream inputStream = + GcpManagedChannelTest.class.getClassLoader().getResourceAsStream(API_FILE); + StringBuilder sb = new StringBuilder(); + + assertNotNull(inputStream); + for (int ch; (ch = inputStream.read()) != -1; ) { + sb.append((char) ch); + } + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfigJsonString(sb.toString()) + .build(); + assertEquals(0, gcpChannel.channelRefs.size()); + assertEquals(3, gcpChannel.getMaxSize()); + assertEquals(2, gcpChannel.getStreamsLowWatermark()); + assertEquals(3, gcpChannel.methodToAffinity.size()); + } + + @Test + public void testUsesPoolOptions() { + resetGcpChannel(); + GcpChannelPoolOptions poolOptions = + GcpChannelPoolOptions.newBuilder() + .setMaxSize(5) + .setMinSize(2) + .setConcurrentStreamsLowWatermark(50) + .build(); + GcpManagedChannelOptions options = + GcpManagedChannelOptions.newBuilder().withChannelPoolOptions(poolOptions).build(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder).withOptions(options).build(); + assertEquals(2, gcpChannel.channelRefs.size()); + assertEquals(5, gcpChannel.getMaxSize()); + assertEquals(2, gcpChannel.getMinSize()); + assertEquals(50, gcpChannel.getStreamsLowWatermark()); + } + + @Test + public void testPoolOptionsOverrideApiConfig() { + resetGcpChannel(); + final URL resource = GcpManagedChannelTest.class.getClassLoader().getResource(API_FILE); + assertNotNull(resource); + File configFile = new File(resource.getFile()); + GcpChannelPoolOptions poolOptions = + GcpChannelPoolOptions.newBuilder() + .setMaxSize(5) + .setConcurrentStreamsLowWatermark(50) + .build(); + GcpManagedChannelOptions options = + GcpManagedChannelOptions.newBuilder().withChannelPoolOptions(poolOptions).build(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfigJsonFile(configFile) + .withOptions(options) + .build(); + assertEquals(0, gcpChannel.channelRefs.size()); + assertEquals(5, gcpChannel.getMaxSize()); + assertEquals(50, gcpChannel.getStreamsLowWatermark()); + assertEquals(3, gcpChannel.methodToAffinity.size()); + } + + @Test + public void testGetChannelRefInitialization() { + // Watch debug messages. + testLogger.setLevel(Level.FINER); + + final int currentIndex = GcpManagedChannel.channelPoolIndex.get(); + final String poolIndex = String.format("pool-%d", currentIndex); + + // Initial log messages count. + int logCount = logRecords.size(); + + // Should not have a managedchannel by default. + assertEquals(0, gcpChannel.channelRefs.size()); + // But once requested it's there. + assertEquals(0, gcpChannel.getChannelRef(null).getAffinityCount()); + + assertThat(logRecords.size()).isEqualTo(logCount + 2); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Channel 0 created."); + assertThat(lastLogLevel()).isEqualTo(Level.FINER); + assertThat(logRecords.get(logRecords.size() - 2).getMessage()) + .isEqualTo(poolIndex + ": Channel 0 state change detected: null -> IDLE"); + assertThat(logRecords.get(logRecords.size() - 2).getLevel()).isEqualTo(Level.FINER); + + // The state of this channel is idle. + assertEquals(ConnectivityState.IDLE, gcpChannel.getState(false)); + assertEquals(1, gcpChannel.channelRefs.size()); + } + + @Test + public void testGetChannelRefInitializationWithMinSize() throws InterruptedException { + resetGcpChannel(); + GcpChannelPoolOptions poolOptions = + GcpChannelPoolOptions.newBuilder().setMaxSize(5).setMinSize(2).build(); + GcpManagedChannelOptions options = + GcpManagedChannelOptions.newBuilder().withChannelPoolOptions(poolOptions).build(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder).withOptions(options).build(); + // Should have 2 channels since the beginning. + assertThat(gcpChannel.channelRefs.size()).isEqualTo(2); + TimeUnit.MILLISECONDS.sleep(50); + // The connection establishment must have been started on these two channels. + assertThat(gcpChannel.getState(false)) + .isAnyOf( + ConnectivityState.CONNECTING, + ConnectivityState.READY, + ConnectivityState.TRANSIENT_FAILURE); + assertThat(gcpChannel.channelRefs.get(0).getChannel().getState(false)) + .isAnyOf( + ConnectivityState.CONNECTING, + ConnectivityState.READY, + ConnectivityState.TRANSIENT_FAILURE); + assertThat(gcpChannel.channelRefs.get(1).getChannel().getState(false)) + .isAnyOf( + ConnectivityState.CONNECTING, + ConnectivityState.READY, + ConnectivityState.TRANSIENT_FAILURE); + } + + @Test + public void testGetChannelRefPickUpSmallest() { + // This test verifies deterministic smallest-stream selection (LINEAR_SCAN behavior). + resetGcpChannel(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setChannelPickStrategy( + GcpManagedChannelOptions.ChannelPickStrategy.LINEAR_SCAN) + .build()) + .build()) + .build(); + for (int i = 0; i < 5; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, MAX_STREAM)); + } + assertEquals(5, gcpChannel.channelRefs.size()); + assertEquals(0, gcpChannel.getChannelRef(null).getAffinityCount()); + assertEquals(6, gcpChannel.channelRefs.size()); + + // Add more channels, the smallest stream value is -1 with idx 6. + int[] streams = new int[] {-1, 5, 7, 1}; + for (int i = 6; i < 10; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, streams[i - 6])); + } + assertEquals(10, gcpChannel.channelRefs.size()); + assertEquals(6, gcpChannel.getChannelRef(null).getAffinityCount()); + } + + /** + * Proves that when all channels have the same stream count (e.g., all zero during a burst), + * pickLeastBusyChannel distributes requests across channels rather than always picking channel 0. + * + *

Without power-of-two random choices, the deterministic scan always picks the first channel + * in the list when there's a tie, causing a thundering herd on channel 0. + */ + @Test + public void testPickLeastBusyDistributesAcrossChannelsOnTie() { + resetGcpChannel(); + final int numChannels = 10; + // Create channels all with 0 active streams (simulates burst where no stream counts + // have been incremented yet). + for (int i = 0; i < numChannels; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, 0)); + } + assertEquals(numChannels, gcpChannel.channelRefs.size()); + + // Simulate a burst: pick a channel many times without incrementing stream counts in between + // (the TOCTOU race window). Track which channels get picked. + final int numPicks = 1000; + int[] pickCounts = new int[numChannels]; + for (int i = 0; i < numPicks; i++) { + ChannelRef picked = gcpChannel.getChannelRef(null); + // Find the index of the picked channel in channelRefs. + for (int j = 0; j < numChannels; j++) { + if (gcpChannel.channelRefs.get(j) == picked) { + pickCounts[j]++; + break; + } + } + } + + // With proper load distribution, no single channel should get ALL the picks. + // Without the fix, channel 0 gets 100% of picks (1000/1000). + // With power-of-two, the distribution should be roughly uniform. + // Assert that no channel gets more than 30% of picks (generous threshold for randomness). + int maxPicks = 0; + for (int count : pickCounts) { + maxPicks = Math.max(maxPicks, count); + } + assertThat(maxPicks).isLessThan(numPicks * 30 / 100); + + // Assert that at least half the channels were used (with 10 channels and 1000 picks, + // power-of-two should use all of them). + int usedChannels = 0; + for (int count : pickCounts) { + if (count > 0) { + usedChannels++; + } + } + assertThat(usedChannels).isAtLeast(numChannels / 2); + } + + /** + * Verifies that when channels have different stream counts, pickLeastBusyChannel still + * consistently picks the less busy channel (power-of-two doesn't break correctness). + */ + @Test + public void testPickLeastBusyStillPrefersLessBusyChannels() { + resetGcpChannel(); + // Channel 0: 50 streams (busy), Channels 1-9: 0 streams (idle). + ManagedChannel busyChannel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(busyChannel, 0, 50)); + for (int i = 1; i < 10; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, 0)); + } + + // Pick 100 times. Channel 0 (50 streams) should almost never be picked because + // any random pair that includes an idle channel will prefer the idle one. + int busyPicks = 0; + for (int i = 0; i < 100; i++) { + ChannelRef picked = gcpChannel.getChannelRef(null); + if (gcpChannel.channelRefs.get(0) == picked) { + busyPicks++; + } + } + // Power-of-two guarantees distinct indices, so channel 0 (50 streams) is always + // paired with an idle channel (0 streams) and can never win. + assertEquals(0, busyPicks); + } + + /** + * Verifies that power-of-two works correctly with dynamic channel pool scale-up. When the pool + * scales up, the new channel should participate in the random selection. + */ + @Test + public void testPickLeastBusyWithDynamicScaleUp() throws InterruptedException { + final int minSize = 2; + final int maxSize = 6; + final int minRpcPerChannel = 2; + final int maxRpcPerChannel = 5; + final Duration scaleDownInterval = Duration.ofMillis(50); + final ExecutorService executorService = Executors.newSingleThreadExecutor(); + + FakeManagedChannelBuilder fmcb = + new FakeManagedChannelBuilder(() -> new FakeManagedChannel(executorService)); + + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(fmcb) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setMinSize(minSize) + .setMaxSize(maxSize) + .setDynamicScaling( + minRpcPerChannel, maxRpcPerChannel, scaleDownInterval) + .build()) + .build()) + .build(); + + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Mark channels as READY. + for (ChannelRef channelRef : pool.channelRefs) { + ((FakeManagedChannel) channelRef.getChannel()).setState(ConnectivityState.READY); + } + + // Load up channels to trigger scale-up. + for (int i = 0; i < minSize * maxRpcPerChannel; i++) { + pool.getChannelRef(null).activeStreamsCountIncr(); + } + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // One more call triggers scale-up. + pool.getChannelRef(null).activeStreamsCountIncr(); + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize + 1); + + // Mark the new channel as READY. + ((FakeManagedChannel) pool.channelRefs.get(minSize).getChannel()) + .setState(ConnectivityState.READY); + + // Now pick many times without incrementing. The new (less busy) channel should be favored, + // but picks should still be distributed across channels. + int[] pickCounts = new int[pool.getNumberOfChannels()]; + final int numPicks = 300; + for (int i = 0; i < numPicks; i++) { + ChannelRef picked = pool.getChannelRef(null); + for (int j = 0; j < pool.channelRefs.size(); j++) { + if (pool.channelRefs.get(j) == picked) { + pickCounts[j]++; + break; + } + } + } + + // The new channel (index 2) with 0 streams should get the most picks, but the key thing + // is that it doesn't monopolize ALL picks — demonstrating randomness works with scale-up. + assertThat(pickCounts[minSize]).isGreaterThan(0); + + // Also, at least one of the original channels should occasionally be picked when + // both random indices happen to land on the original channels. + int originalPicks = 0; + for (int i = 0; i < minSize; i++) { + originalPicks += pickCounts[i]; + } + // This can be 0 in rare cases with only 2 loaded + 1 empty, but with 300 picks it's + // very unlikely. The loaded channels have equal load so when both randoms hit them, either + // works. + assertThat(originalPicks).isGreaterThan(0); + + pool.shutdownNow(); + executorService.shutdownNow(); + } + + /** With only 1 channel in the pool, power-of-two must always return that channel. */ + @Test + public void testPickLeastBusySingleChannel() { + resetGcpChannel(); + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, 0, 5)); + + for (int i = 0; i < 100; i++) { + ChannelRef picked = gcpChannel.getChannelRef(null); + assertThat(picked).isEqualTo(gcpChannel.channelRefs.get(0)); + } + } + + /** + * With only 2 channels, power-of-two degenerates to comparing both — should always pick the less + * busy one. + */ + @Test + public void testPickLeastBusyTwoChannels() { + resetGcpChannel(); + ManagedChannel ch0 = builder.build(); + ManagedChannel ch1 = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(ch0, 0, 10)); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(ch1, 1, 3)); + + // With 2 channels, both are always selected, so the one with fewer streams always wins. + for (int i = 0; i < 100; i++) { + ChannelRef picked = gcpChannel.getChannelRef(null); + assertThat(picked).isEqualTo(gcpChannel.channelRefs.get(1)); + } + } + + /** + * Verifies that LINEAR_SCAN strategy preserves the legacy behavior: always picks channel 0 on tie + * (thundering herd). This is the opt-in escape hatch for users who prefer deterministic + * selection. + */ + @Test + public void testLinearScanStrategyAlwaysPicksFirstOnTie() { + resetGcpChannel(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setChannelPickStrategy( + GcpManagedChannelOptions.ChannelPickStrategy.LINEAR_SCAN) + .build()) + .build()) + .build(); + + final int numChannels = 5; + for (int i = 0; i < numChannels; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, 0)); + } + + // With LINEAR_SCAN and all channels at 0 streams, channel 0 should win every time. + for (int i = 0; i < 100; i++) { + ChannelRef picked = gcpChannel.getChannelRef(null); + assertThat(picked).isEqualTo(gcpChannel.channelRefs.get(0)); + } + } + + /** + * Verifies that under low traffic with POWER_OF_TWO, the warm channel (most recently active) is + * preferred when stream counts are tied. This preserves connection warmth without the thundering + * herd problem. + */ + @Test + public void testPowerOfTwoPrefersWarmChannelOnTie() throws Exception { + resetGcpChannel(); + // Use a fake clock to deterministically control lastResponseNanos. + final AtomicLong fakeNanos = new AtomicLong(1_000_000_000L); + gcpChannel.setNanoClock(fakeNanos::get); + + final int numChannels = 10; + for (int i = 0; i < numChannels; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, 0)); + } + + // Advance the clock, then simulate channel 5 receiving a message. + // This gives channel 5 a clearly more recent lastResponseNanos than the others. + fakeNanos.set(2_000_000_000L); + ChannelRef warmChannel = gcpChannel.channelRefs.get(5); + warmChannel.messageReceived(); + + // Pick many times. The warm channel should be picked more often than average because + // whenever it appears in a random pair with another 0-stream channel, it wins the tie. + int warmPicks = 0; + final int numPicks = 1000; + for (int i = 0; i < numPicks; i++) { + ChannelRef picked = gcpChannel.getChannelRef(null); + if (picked == warmChannel) { + warmPicks++; + } + } + + // Without warmth bias, channel 5 would get ~10% (100/1000) picks. + // With warmth bias, it should get significantly more because it wins every tie. + // P(channel 5 in sample of 2) = 1 - (9/10)*(8/9) -- wait, it's 1-(9/10)^2 = 19%. + // It wins tie with any other cold channel, so ~19% of picks. Allow some variance. + assertThat(warmPicks).isGreaterThan(numPicks * 14 / 100); + } + + private void assertFallbacksMetric( + FakeMetricRegistry fakeRegistry, long successes, long failures) { + MetricsRecord record = fakeRegistry.pollRecord(); + List> metric = + record.getMetrics().get(GcpMetricsConstants.METRIC_NUM_FALLBACKS); + assertThat(metric.size()).isEqualTo(2); + assertThat(metric.get(0).value()).isEqualTo(successes); + assertThat(metric.get(0).values().get(0)) + .isEqualTo(LabelValue.create(GcpMetricsConstants.RESULT_SUCCESS)); + assertThat(metric.get(1).value()).isEqualTo(failures); + assertThat(metric.get(1).values().get(0)) + .isEqualTo(LabelValue.create(GcpMetricsConstants.RESULT_ERROR)); + } + + @Test + public void testGetChannelRefWithFallback() { + // Watch debug messages. + testLogger.setLevel(Level.FINEST); + + final FakeMetricRegistry fakeRegistry = new FakeMetricRegistry(); + + final int maxSize = 3; + final int lowWatermark = 2; + + // Creating a pool with fallback, max size and low watermark above. + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfig( + ApiConfig.newBuilder() + .setChannelPool( + ChannelPoolConfig.newBuilder() + .setMaxSize(maxSize) + .setMaxConcurrentStreamsLowWatermark(lowWatermark) + .build()) + .build()) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder().setNotReadyFallback(true).build()) + .withMetricsOptions( + GcpMetricsOptions.newBuilder().withMetricRegistry(fakeRegistry).build()) + .build()) + .build(); + + final int currentIndex = GcpManagedChannel.channelPoolIndex.get(); + final String poolIndex = String.format("pool-%d", currentIndex); + + // Creates the first channel with 0 id. + assertEquals(0, pool.getNumberOfChannels()); + ChannelRef chRef = pool.getChannelRef(null); + assertEquals(0, chRef.getId()); + assertEquals(1, pool.getNumberOfChannels()); + + // The 0 channel is ready by default, so the subsequent request for a channel should return + // the 0 channel again if its active streams (currently 0) are less than the low watermark. + chRef = pool.getChannelRef(null); + assertEquals(0, chRef.getId()); + assertEquals(1, pool.getNumberOfChannels()); + + // Let's simulate the non-ready state for the 0 channel. + pool.processChannelStateChange(0, ConnectivityState.CONNECTING); + int logCount = logRecords.size(); + // Now request for a channel should return a newly created channel because our current channel + // is not ready, and we haven't reached the pool's max size. + chRef = pool.getChannelRef(null); + assertEquals(1, chRef.getId()); + assertEquals(2, pool.getNumberOfChannels()); + // This was a fallback from non-ready channel 0 to the newly created channel 1. + assertThat(logRecords.size()).isEqualTo(logCount + 3); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Fallback to newly created channel 1"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 1, 0); + + // Adding one active stream to channel 1. + pool.channelRefs.get(1).activeStreamsCountIncr(); + logCount = logRecords.size(); + // Having 0 active streams on channel 0 and 1 active streams on channel one with the default + // settings would return channel 0 for the next channel request. But having fallback enabled and + // channel 0 not ready it should return channel 1 instead. + chRef = pool.getChannelRef(null); + assertEquals(1, chRef.getId()); + assertEquals(2, pool.getNumberOfChannels()); + // This was the second fallback from non-ready channel 0 to the channel 1. + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Picking fallback channel: 0 -> 1"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 2, 0); + + // Now let's have channel 0 still as not ready but bring channel 1 streams to low watermark. + for (int i = 0; i < lowWatermark - 1; i++) { + pool.channelRefs.get(1).activeStreamsCountIncr(); + } + // Having one non-ready channel and another channel reached the low watermark should create a + // new channel for the next channel request if we haven't reached max size. + chRef = pool.getChannelRef(null); + assertEquals(2, chRef.getId()); + assertEquals(3, pool.getNumberOfChannels()); + + // Now we reached max pool size. Let's bring channel 2 to the low watermark and channel 1 to the + // low watermark + 1 streams. + for (int i = 0; i < lowWatermark; i++) { + pool.channelRefs.get(2).activeStreamsCountIncr(); + } + pool.channelRefs.get(1).activeStreamsCountIncr(); + // As we reached max size and cannot create new channels and having ready channels with low + // watermark and low watermark + 1 streams, the best channel for the next channel request with + // the fallback enabled is the channel 2 with low watermark streams because it's the least busy + // ready channel. + assertEquals(lowWatermark + 1, pool.channelRefs.get(1).getActiveStreamsCount()); + assertEquals(lowWatermark, pool.channelRefs.get(2).getActiveStreamsCount()); + chRef = pool.getChannelRef(null); + assertEquals(2, chRef.getId()); + assertEquals(3, pool.getNumberOfChannels()); + // This was the third fallback from non-ready channel 0 to the channel 2. + assertFallbacksMetric(fakeRegistry, 3, 0); + + // Let's bring channel 1 to max streams and mark channel 2 as not ready. + for (int i = 0; i < MAX_STREAM - lowWatermark; i++) { + pool.channelRefs.get(2).activeStreamsCountIncr(); + } + pool.processChannelStateChange(1, ConnectivityState.CONNECTING); + assertEquals(MAX_STREAM, pool.channelRefs.get(2).getActiveStreamsCount()); + + // Now we have two non-ready channels and one overloaded. + // Even when fallback enabled there is no good candidate at this time, the next channel request + // should return a channel with the lowest streams count regardless of its readiness state. + // In our case it is channel 0. + logCount = logRecords.size(); + chRef = pool.getChannelRef(null); + assertEquals(0, chRef.getId()); + assertEquals(3, pool.getNumberOfChannels()); + // This will also count as a failed fallback because we couldn't find a ready and non-overloaded + // channel. + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Failed to find fallback for channel 0"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 3, 1); + + // Let's have an affinity key and bind it to channel 0. + final String key = "ABC"; + pool.bind(pool.channelRefs.get(0), Collections.singletonList(key)); + logCount = logRecords.size(); + + // Channel 0 is not ready currently and the fallback enabled should look for a fallback but we + // still don't have a good channel because channel 1 is not ready and channel 2 is overloaded. + // The getChannelRef should return the original channel 0 and report a failed fallback. + chRef = pool.getChannelRef(key); + assertEquals(0, chRef.getId()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Failed to find fallback for channel 0"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 3, 2); + + // Let's return channel 1 to a ready state. + pool.processChannelStateChange(1, ConnectivityState.READY); + logCount = logRecords.size(); + // Now we have a fallback candidate. + // The getChannelRef should return the channel 1 and report a successful fallback. + chRef = pool.getChannelRef(key); + assertEquals(1, chRef.getId()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Setting fallback channel: 0 -> 1"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 4, 2); + + // Let's briefly bring channel 2 to ready state. + pool.processChannelStateChange(2, ConnectivityState.READY); + logCount = logRecords.size(); + // Now we have a better fallback candidate (fewer streams on channel 2). But this time we + // already used channel 1 as a fallback, and we should stick to it instead of returning the + // original channel. + // The getChannelRef should return the channel 1 and report a successful fallback. + chRef = pool.getChannelRef(key); + assertEquals(1, chRef.getId()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Using fallback channel: 0 -> 1"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 5, 2); + pool.processChannelStateChange(2, ConnectivityState.CONNECTING); + + // Let's bring channel 1 back to connecting state. + pool.processChannelStateChange(1, ConnectivityState.CONNECTING); + logCount = logRecords.size(); + // Now we don't have a good fallback candidate again. But this time we already used channel 1 + // as a fallback and we should stick to it instead of returning the original channel. + // The getChannelRef should return the channel 1 and report a failed fallback. + chRef = pool.getChannelRef(key); + assertEquals(1, chRef.getId()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Failed to find fallback for channel 0"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + assertFallbacksMetric(fakeRegistry, 5, 3); + + // Finally, we bring both channel 1 and channel 0 to the ready state and we should get the + // original channel 0 for the key without any fallbacks happening. + pool.processChannelStateChange(1, ConnectivityState.READY); + pool.processChannelStateChange(0, ConnectivityState.READY); + logCount = logRecords.size(); + chRef = pool.getChannelRef(key); + assertEquals(0, chRef.getId()); + assertThat(logRecords.size()).isEqualTo(logCount); + assertFallbacksMetric(fakeRegistry, 5, 3); + } + + @Test + public void testGetChannelRefMaxSize() { + resetGcpChannel(); + for (int i = 0; i < MAX_CHANNEL; i++) { + ManagedChannel channel = builder.build(); + gcpChannel.channelRefs.add(gcpChannel.new ChannelRef(channel, i, MAX_STREAM)); + } + assertEquals(MAX_CHANNEL, gcpChannel.channelRefs.size()); + assertEquals(MAX_STREAM, gcpChannel.getChannelRef(null).getActiveStreamsCount()); + assertEquals(MAX_CHANNEL, gcpChannel.channelRefs.size()); + } + + @Test + public void testBindUnbindKey() { + // Watch debug messages. + testLogger.setLevel(Level.FINEST); + + final int currentIndex = GcpManagedChannel.channelPoolIndex.get(); + final String poolIndex = String.format("pool-%d", currentIndex); + + // Initialize the channel and bind the key, check the affinity count. + gcpChannel.nextChannelId.set(1); + ChannelRef cf1 = gcpChannel.new ChannelRef(builder.build(), 0, 5); + ChannelRef cf2 = gcpChannel.new ChannelRef(builder.build(), 0, 4); + gcpChannel.channelRefs.add(cf1); + gcpChannel.channelRefs.add(cf2); + + gcpChannel.bind(cf1, Collections.singletonList("key1")); + + // Initial log messages count. + int logCount = logRecords.size(); + + gcpChannel.bind(cf2, Collections.singletonList("key2")); + + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Binding 1 key(s) to channel 2: [key2]"); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + + gcpChannel.bind(cf2, Collections.singletonList("key3")); + // Binding the same key to the same channel should not increase affinity count. + gcpChannel.bind(cf1, Collections.singletonList("key1")); + assertEquals(1, gcpChannel.channelRefs.get(0).getAffinityCount()); + assertEquals(2, gcpChannel.channelRefs.get(1).getAffinityCount()); + assertEquals(3, gcpChannel.affinityKeyToChannelRef.size()); + // Binding the same key to a different channel should alter affinity counts accordingly. + gcpChannel.bind(cf1, Collections.singletonList("key3")); + assertEquals(2, gcpChannel.channelRefs.get(0).getAffinityCount()); + assertEquals(1, gcpChannel.channelRefs.get(1).getAffinityCount()); + assertEquals(3, gcpChannel.affinityKeyToChannelRef.size()); + + logCount = logRecords.size(); + + // Unbind the affinity key. + gcpChannel.unbind(Collections.singletonList("key1")); + assertEquals(1, gcpChannel.channelRefs.get(0).getAffinityCount()); + assertEquals(1, gcpChannel.channelRefs.get(1).getAffinityCount()); + assertEquals(2, gcpChannel.affinityKeyToChannelRef.size()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Unbinding key key1 from channel 1."); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + gcpChannel.unbind(Collections.singletonList("key1")); + assertEquals(1, gcpChannel.channelRefs.get(0).getAffinityCount()); + assertEquals(1, gcpChannel.channelRefs.get(1).getAffinityCount()); + assertEquals(2, gcpChannel.affinityKeyToChannelRef.size()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Unbinding key key1 but it wasn't bound."); + assertThat(lastLogLevel()).isEqualTo(Level.FINEST); + gcpChannel.unbind(Collections.singletonList("key2")); + assertEquals(1, gcpChannel.channelRefs.get(0).getAffinityCount()); + assertEquals(0, gcpChannel.channelRefs.get(1).getAffinityCount()); + assertEquals(1, gcpChannel.affinityKeyToChannelRef.size()); + gcpChannel.unbind(Collections.singletonList("key3")); + assertEquals(0, gcpChannel.channelRefs.get(0).getAffinityCount()); + assertEquals(0, gcpChannel.channelRefs.get(1).getAffinityCount()); + assertEquals(0, gcpChannel.affinityKeyToChannelRef.size()); + } + + @Test + public void testUsingKeyWithoutBinding() { + // Initialize the channel and bind the key, check the affinity count. + gcpChannel.nextChannelId.set(1); + ChannelRef cf1 = gcpChannel.new ChannelRef(builder.build(), 0, 5); + ChannelRef cf2 = gcpChannel.new ChannelRef(builder.build(), 0, 4); + gcpChannel.channelRefs.add(cf1); + gcpChannel.channelRefs.add(cf2); + + final String key = "non-binded-key"; + ChannelRef channelRef = gcpChannel.getChannelRef(key); + // Should bind on the fly to the least busy channel, which is 2. + assertThat(channelRef.getId()).isEqualTo(2); + + cf1.activeStreamsCountDecr(System.nanoTime(), Status.OK, true); + cf1.activeStreamsCountDecr(System.nanoTime(), Status.OK, true); + channelRef = gcpChannel.getChannelRef(key); + // Even after channel 1 now has less active streams (3) the channel 2 is still mapped for the + // same key. + assertThat(channelRef.getId()).isEqualTo(2); + } + + @Test + public void testGetKeysFromRequest() { + String expected = "thisisaname"; + TransactionSelector selector = TransactionSelector.getDefaultInstance(); + PartitionReadRequest req = + PartitionReadRequest.newBuilder() + .setSession(expected) + .setTable("jenny") + .setTransaction(selector) + .addColumns("users") + .build(); + List result = getKeysFromMessage(req, "session"); + assertEquals(expected, result.get(0)); + result = getKeysFromMessage(req, "fakesession"); + assertEquals(0, result.size()); + } + + @Test + public void testParseGoodJsonFile() { + final URL resource = GcpManagedChannelTest.class.getClassLoader().getResource(API_FILE); + assertNotNull(resource); + File configFile = new File(resource.getFile()); + ApiConfig apiconfig = + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfigJsonFile(configFile) + .apiConfig; + ChannelPoolConfig expectedChannel = + ChannelPoolConfig.newBuilder().setMaxSize(3).setMaxConcurrentStreamsLowWatermark(2).build(); + Assert.assertEquals(expectedChannel, apiconfig.getChannelPool()); + + assertEquals(3, apiconfig.getMethodCount()); + MethodConfig.Builder expectedMethod1 = MethodConfig.newBuilder(); + expectedMethod1.addName("google.spanner.v1.Spanner/CreateSession"); + expectedMethod1.setAffinity( + AffinityConfig.newBuilder() + .setAffinityKey("name") + .setCommand(AffinityConfig.Command.BIND) + .build()); + assertEquals(expectedMethod1.build(), apiconfig.getMethod(0)); + MethodConfig.Builder expectedMethod2 = MethodConfig.newBuilder(); + expectedMethod2.addName("google.spanner.v1.Spanner/GetSession"); + expectedMethod2.setAffinity( + AffinityConfig.newBuilder() + .setAffinityKey("name") + .setCommand(AffinityConfig.Command.BOUND) + .build()); + assertEquals(expectedMethod2.build(), apiconfig.getMethod(1)); + MethodConfig.Builder expectedMethod3 = MethodConfig.newBuilder(); + expectedMethod3.addName("google.spanner.v1.Spanner/DeleteSession"); + expectedMethod3.setAffinity( + AffinityConfig.newBuilder() + .setAffinityKey("name") + .setCommand(AffinityConfig.Command.UNBIND) + .build()); + assertEquals(expectedMethod3.build(), apiconfig.getMethod(2)); + } + + @Test + public void testParseEmptyMethodJsonFile() { + final URL resource = + GcpManagedChannelTest.class.getClassLoader().getResource(EMPTY_METHOD_FILE); + assertNotNull(resource); + File configFile = new File(resource.getFile()); + ApiConfig apiconfig = + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfigJsonFile(configFile) + .apiConfig; + ChannelPoolConfig expectedChannel = + ChannelPoolConfig.newBuilder() + .setMaxSize(5) + .setIdleTimeout(1000) + .setMaxConcurrentStreamsLowWatermark(5) + .build(); + Assert.assertEquals(expectedChannel, apiconfig.getChannelPool()); + + assertEquals(0, apiconfig.getMethodCount()); + } + + @Test + public void testParseEmptyChannelJsonFile() { + final URL resource = + GcpManagedChannelTest.class.getClassLoader().getResource(EMPTY_CHANNEL_FILE); + assertNotNull(resource); + File configFile = new File(resource.getFile()); + ApiConfig apiconfig = + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfigJsonFile(configFile) + .apiConfig; + Assert.assertEquals(ChannelPoolConfig.getDefaultInstance(), apiconfig.getChannelPool()); + + assertEquals(3, apiconfig.getMethodCount()); + MethodConfig.Builder expectedMethod1 = MethodConfig.newBuilder(); + expectedMethod1.addName("/google.spanner.v1.Spanner/CreateSession"); + expectedMethod1.setAffinity( + AffinityConfig.newBuilder() + .setAffinityKey("name") + .setCommand(AffinityConfig.Command.BIND) + .build()); + assertEquals(expectedMethod1.build(), apiconfig.getMethod(0)); + MethodConfig.Builder expectedMethod2 = MethodConfig.newBuilder(); + expectedMethod2.addName("/google.spanner.v1.Spanner/GetSession").addName("additional name"); + expectedMethod2.setAffinity( + AffinityConfig.newBuilder() + .setAffinityKey("name") + .setCommand(AffinityConfig.Command.BOUND) + .build()); + assertEquals(expectedMethod2.build(), apiconfig.getMethod(1)); + assertEquals(MethodConfig.getDefaultInstance(), apiconfig.getMethod(2)); + } + + @Test + public void testMetrics() { + // Watch debug messages. + testLogger.setLevel(Level.FINE); + final FakeMetricRegistry fakeRegistry = new FakeMetricRegistry(); + final String prefix = "some/prefix/"; + final List labelKeys = + Arrays.asList(LabelKey.create("key_a", ""), LabelKey.create("key_b", "")); + final List labelValues = + Arrays.asList(LabelValue.create("val_a"), LabelValue.create("val_b")); + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withApiConfig( + ApiConfig.newBuilder() + .setChannelPool( + ChannelPoolConfig.newBuilder() + .setMaxConcurrentStreamsLowWatermark(1) + .build()) + .build()) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withMetricsOptions( + GcpMetricsOptions.newBuilder() + .withMetricRegistry(fakeRegistry) + .withNamePrefix(prefix) + .withLabels(labelKeys, labelValues) + .build()) + .build()) + .build(); + + final int currentIndex = GcpManagedChannel.channelPoolIndex.get(); + final String poolIndex = String.format("pool-%d", currentIndex); + + // Logs metrics options. + assertThat(logRecords.get(logRecords.size() - 2).getLevel()).isEqualTo(Level.FINE); + assertThat(logRecords.get(logRecords.size() - 2).getMessage()) + .startsWith( + poolIndex + + ": Metrics options: {namePrefix: \"some/prefix/\", labels: " + + "[key_a: \"val_a\", key_b: \"val_b\"],"); + + assertThat(lastLogLevel()).isEqualTo(Level.INFO); + assertThat(lastLogMessage()).isEqualTo(poolIndex + ": Metrics enabled (OpenCensus)."); + + List expectedLabelKeys = new ArrayList<>(labelKeys); + expectedLabelKeys.add( + LabelKey.create(GcpMetricsConstants.POOL_INDEX_LABEL, GcpMetricsConstants.POOL_INDEX_DESC)); + List expectedLabelValues = new ArrayList<>(labelValues); + expectedLabelValues.add(LabelValue.create(poolIndex)); + + try { + // Let's fill five channels with some fake streams. + int[] streams = new int[] {3, 2, 5, 7, 1}; + for (int count : streams) { + ChannelRef ref = pool.getChannelRef(null); + for (int j = 0; j < count; j++) { + ref.activeStreamsCountIncr(); + } + } + + MetricsRecord record = fakeRegistry.pollRecord(); + assertThat(record.getMetrics().size()).isEqualTo(28); + + // Initial log messages count. + int logCount = logRecords.size(); + + List> minChannels = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_MIN_CHANNELS); + assertThat(minChannels.size()).isEqualTo(1); + assertThat(minChannels.get(0).value()).isEqualTo(0L); + assertThat(minChannels.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(minChannels.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo(poolIndex + ": stat: " + GcpMetricsConstants.METRIC_MIN_CHANNELS + " = 0"); + + List> maxChannels = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_MAX_CHANNELS); + assertThat(maxChannels.size()).isEqualTo(1); + assertThat(maxChannels.get(0).value()).isEqualTo(5L); + assertThat(maxChannels.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(maxChannels.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo(poolIndex + ": stat: " + GcpMetricsConstants.METRIC_MAX_CHANNELS + " = 5"); + + List> numChannels = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_NUM_CHANNELS); + assertThat(numChannels.size()).isEqualTo(1); + assertThat(numChannels.get(0).value()).isEqualTo(5L); + assertThat(numChannels.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(numChannels.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo(poolIndex + ": stat: " + GcpMetricsConstants.METRIC_NUM_CHANNELS + " = 5"); + + List> maxAllowedChannels = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_MAX_ALLOWED_CHANNELS); + assertThat(maxAllowedChannels.size()).isEqualTo(1); + assertThat(maxAllowedChannels.get(0).value()).isEqualTo(MAX_CHANNEL); + assertThat(maxAllowedChannels.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(maxAllowedChannels.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + ": stat: " + GcpMetricsConstants.METRIC_MAX_ALLOWED_CHANNELS + " = 10"); + + List> minActiveStreams = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_MIN_ACTIVE_STREAMS); + assertThat(minActiveStreams.size()).isEqualTo(1); + assertThat(minActiveStreams.get(0).value()).isEqualTo(0L); + assertThat(minActiveStreams.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(minActiveStreams.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + ": stat: " + GcpMetricsConstants.METRIC_MIN_ACTIVE_STREAMS + " = 0"); + + List> maxActiveStreams = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_MAX_ACTIVE_STREAMS); + assertThat(maxActiveStreams.size()).isEqualTo(1); + assertThat(maxActiveStreams.get(0).value()).isEqualTo(7L); + assertThat(maxActiveStreams.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(maxActiveStreams.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + ": stat: " + GcpMetricsConstants.METRIC_MAX_ACTIVE_STREAMS + " = 7"); + + List> totalActiveStreams = + record.getMetrics().get(prefix + GcpMetricsConstants.METRIC_MAX_TOTAL_ACTIVE_STREAMS); + assertThat(totalActiveStreams.size()).isEqualTo(1); + long totalStreamsExpected = Arrays.stream(streams).asLongStream().sum(); + assertThat(totalActiveStreams.get(0).value()).isEqualTo(totalStreamsExpected); + assertThat(totalActiveStreams.get(0).keys()).isEqualTo(expectedLabelKeys); + assertThat(totalActiveStreams.get(0).values()).isEqualTo(expectedLabelValues); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_MAX_TOTAL_ACTIVE_STREAMS + + " = " + + totalStreamsExpected); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void testLogMetrics() throws InterruptedException { + // Watch debug messages. + testLogger.setLevel(Level.FINE); + + int[] streams = new int[] {3, 2, 5, 7, 1}; + int[] keyCount = new int[] {2, 3, 1, 1, 4}; + int[] okCalls = new int[] {2, 2, 8, 2, 3}; + int[] errCalls = new int[] {1, 1, 2, 2, 1}; + List channels = new ArrayList<>(); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + + for (int i = 0; i < streams.length; i++) { + FakeManagedChannel channel = new FakeManagedChannel(executorService); + channels.add(channel); + } + + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(new FakeManagedChannelBuilder(channels)) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setMaxSize(5) + .setConcurrentStreamsLowWatermark(3) + .build()) + .withMetricsOptions( + GcpMetricsOptions.newBuilder().withNamePrefix("prefix").build()) + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder() + .setNotReadyFallback(true) + .withUnresponsiveConnectionDetection(100, 2) + .build()) + .build()) + .build(); + + try { + final int currentIndex = GcpManagedChannel.channelPoolIndex.get(); + final String poolIndex = String.format("pool-%d", currentIndex); + + for (int i = 0; i < streams.length; i++) { + ChannelRef ref = pool.createNewChannel(); + + // Simulate channel connecting. + channels.get(i).setState(ConnectivityState.CONNECTING); + TimeUnit.MILLISECONDS.sleep(10); + + // For the last one... + if (i == streams.length - 1) { + // This will be a couple of successful fallbacks. + pool.getChannelRef(null); + pool.getChannelRef(null); + // Bring down all other channels. + for (int j = 0; j < i; j++) { + channels.get(j).setState(ConnectivityState.CONNECTING); + } + TimeUnit.MILLISECONDS.sleep(100); + // And this will be a failed fallback (no ready channels). + pool.getChannelRef(null); + + // Simulate unresponsive connection. + long startNanos = System.nanoTime(); + final Status deStatus = Status.fromCode(Code.DEADLINE_EXCEEDED); + ref.activeStreamsCountIncr(); + ref.activeStreamsCountDecr(startNanos, deStatus, false); + ref.activeStreamsCountIncr(); + ref.activeStreamsCountDecr(startNanos, deStatus, false); + + // Simulate unresponsive connection with more dropped calls. + startNanos = System.nanoTime(); + ref.activeStreamsCountIncr(); + ref.activeStreamsCountDecr(startNanos, deStatus, false); + ref.activeStreamsCountIncr(); + ref.activeStreamsCountDecr(startNanos, deStatus, false); + TimeUnit.MILLISECONDS.sleep(110); + ref.activeStreamsCountIncr(); + ref.activeStreamsCountDecr(startNanos, deStatus, false); + } + + channels.get(i).setState(ConnectivityState.READY); + + for (int j = 0; j < streams[i]; j++) { + ref.activeStreamsCountIncr(); + } + // Bind affinity keys. + final List keys = new ArrayList<>(); + for (int j = 0; j < keyCount[i]; j++) { + keys.add("key-" + i + "-" + j); + } + pool.bind(ref, keys); + // Simulate successful calls. + for (int j = 0; j < okCalls[i]; j++) { + ref.activeStreamsCountDecr(0, Status.OK, false); + ref.activeStreamsCountIncr(); + } + // Simulate failed calls. + for (int j = 0; j < errCalls[i]; j++) { + ref.activeStreamsCountDecr(0, Status.UNAVAILABLE, false); + ref.activeStreamsCountIncr(); + } + } + + logRecords.clear(); + + pool.logMetrics(); + + List messages = + Arrays.asList(logRecords.stream().map(LogRecord::getMessage).toArray()); + + assertThat(messages).contains(poolIndex + ": Active streams counts: [3, 2, 5, 7, 1]"); + assertThat(messages).contains(poolIndex + ": Affinity counts: [2, 3, 1, 1, 4]"); + assertThat(messages).contains(poolIndex + ": Removed channels active streams counts: []"); + + assertThat(messages).contains(poolIndex + ": stat: min_ready_channels = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_ready_channels = 4"); + assertThat(messages).contains(poolIndex + ": stat: min_channels = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: max_allowed_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: num_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: num_channel_disconnect = 4"); + assertThat(messages).contains(poolIndex + ": stat: num_channel_connect = 5"); + assertThat( + messages.stream() + .filter( + o -> + o.toString() + .matches(poolIndex + ": stat: min_channel_readiness_time = \\d\\d+")) + .count()) + .isEqualTo(1); + assertThat( + messages.stream() + .filter( + o -> + o.toString() + .matches(poolIndex + ": stat: avg_channel_readiness_time = \\d\\d+")) + .count()) + .isEqualTo(1); + assertThat( + messages.stream() + .filter( + o -> + o.toString() + .matches(poolIndex + ": stat: max_channel_readiness_time = \\d\\d+")) + .count()) + .isEqualTo(1); + assertThat(messages).contains(poolIndex + ": stat: min_active_streams_per_channel = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_active_streams_per_channel = 7"); + assertThat(messages).contains(poolIndex + ": stat: min_total_active_streams = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_total_active_streams = 18"); + assertThat(messages).contains(poolIndex + ": stat: min_affinity_per_channel = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_affinity_per_channel = 4"); + assertThat(messages).contains(poolIndex + ": stat: num_affinity = 11"); + assertThat(messages).contains(poolIndex + ": Ok calls: [2, 2, 8, 2, 3]"); + assertThat(messages).contains(poolIndex + ": Failed calls: [1, 1, 2, 2, 6]"); + assertThat(messages).contains(poolIndex + ": stat: min_calls_per_channel_ok = 2"); + assertThat(messages).contains(poolIndex + ": stat: min_calls_per_channel_err = 1"); + assertThat(messages).contains(poolIndex + ": stat: max_calls_per_channel_ok = 8"); + assertThat(messages).contains(poolIndex + ": stat: max_calls_per_channel_err = 6"); + assertThat(messages).contains(poolIndex + ": stat: num_calls_completed_ok = 17"); + assertThat(messages).contains(poolIndex + ": stat: num_calls_completed_err = 12"); + assertThat(messages).contains(poolIndex + ": stat: num_fallbacks_ok = 2"); + assertThat(messages).contains(poolIndex + ": stat: num_fallbacks_fail = 1"); + assertThat(messages).contains(poolIndex + ": stat: num_unresponsive_detections = 2"); + assertThat( + messages.stream() + .filter( + o -> + o.toString() + .matches( + poolIndex + ": stat: min_unresponsive_detection_time = 1\\d\\d")) + .count()) + .isEqualTo(1); + assertThat( + messages.stream() + .filter( + o -> + o.toString() + .matches( + poolIndex + ": stat: max_unresponsive_detection_time = 1\\d\\d")) + .count()) + .isEqualTo(1); + assertThat(messages).contains(poolIndex + ": stat: min_unresponsive_dropped_calls = 2"); + assertThat(messages).contains(poolIndex + ": stat: max_unresponsive_dropped_calls = 3"); + assertThat(messages).contains(poolIndex + ": stat: channel_pool_scaling_up = 0"); + assertThat(messages).contains(poolIndex + ": stat: channel_pool_scaling_down = 0"); + + assertThat(logRecords.size()).isEqualTo(39); + logRecords.forEach(logRecord -> assertThat(logRecord.getLevel()).isEqualTo(Level.FINE)); + + logRecords.clear(); + + // Next call should update minimums that was 0 previously (e.g., min_ready_channels, + // min_active_streams_per_channel, min_total_active_streams...). + pool.logMetrics(); + + messages = Arrays.asList(logRecords.stream().map(LogRecord::getMessage).toArray()); + + assertThat(messages).contains(poolIndex + ": Active streams counts: [3, 2, 5, 7, 1]"); + assertThat(messages).contains(poolIndex + ": Affinity counts: [2, 3, 1, 1, 4]"); + assertThat(messages).contains(poolIndex + ": Removed channels active streams counts: []"); + + assertThat(messages).contains(poolIndex + ": stat: min_ready_channels = 1"); + assertThat(messages).contains(poolIndex + ": stat: max_ready_channels = 1"); + assertThat(messages).contains(poolIndex + ": stat: min_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: max_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: max_allowed_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: num_channels = 5"); + assertThat(messages).contains(poolIndex + ": stat: num_channel_disconnect = 0"); + assertThat(messages).contains(poolIndex + ": stat: num_channel_connect = 0"); + assertThat(messages).contains(poolIndex + ": stat: min_channel_readiness_time = 0"); + assertThat(messages).contains(poolIndex + ": stat: avg_channel_readiness_time = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_channel_readiness_time = 0"); + assertThat(messages).contains(poolIndex + ": stat: min_active_streams_per_channel = 1"); + assertThat(messages).contains(poolIndex + ": stat: max_active_streams_per_channel = 7"); + assertThat(messages).contains(poolIndex + ": stat: min_total_active_streams = 18"); + assertThat(messages).contains(poolIndex + ": stat: max_total_active_streams = 18"); + assertThat(messages).contains(poolIndex + ": stat: min_affinity_per_channel = 1"); + assertThat(messages).contains(poolIndex + ": stat: max_affinity_per_channel = 4"); + assertThat(messages).contains(poolIndex + ": stat: num_affinity = 11"); + assertThat(messages).contains(poolIndex + ": Ok calls: [0, 0, 0, 0, 0]"); + assertThat(messages).contains(poolIndex + ": Failed calls: [0, 0, 0, 0, 0]"); + assertThat(messages).contains(poolIndex + ": stat: min_calls_per_channel_ok = 0"); + assertThat(messages).contains(poolIndex + ": stat: min_calls_per_channel_err = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_calls_per_channel_ok = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_calls_per_channel_err = 0"); + assertThat(messages).contains(poolIndex + ": stat: num_calls_completed_ok = 0"); + assertThat(messages).contains(poolIndex + ": stat: num_calls_completed_err = 0"); + assertThat(messages).contains(poolIndex + ": stat: num_fallbacks_ok = 0"); + assertThat(messages).contains(poolIndex + ": stat: num_fallbacks_fail = 0"); + assertThat(messages).contains(poolIndex + ": stat: num_unresponsive_detections = 0"); + assertThat(messages).contains(poolIndex + ": stat: min_unresponsive_detection_time = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_unresponsive_detection_time = 0"); + assertThat(messages).contains(poolIndex + ": stat: min_unresponsive_dropped_calls = 0"); + assertThat(messages).contains(poolIndex + ": stat: max_unresponsive_dropped_calls = 0"); + assertThat(messages).contains(poolIndex + ": stat: channel_pool_scaling_up = 0"); + assertThat(messages).contains(poolIndex + ": stat: channel_pool_scaling_down = 0"); + + assertThat(logRecords.size()).isEqualTo(39); + + } finally { + pool.shutdownNow(); + executorService.shutdownNow(); + } + } + + @Test + public void testUnresponsiveDetection() throws InterruptedException { + // Watch debug messages. + testLogger.setLevel(Level.FINER); + final FakeMetricRegistry fakeRegistry = new FakeMetricRegistry(); + // Creating a pool with unresponsive connection detection for 100 ms, 3 dropped requests. + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withResiliencyOptions( + GcpResiliencyOptions.newBuilder() + .withUnresponsiveConnectionDetection(100, 3) + .build()) + .withMetricsOptions( + GcpMetricsOptions.newBuilder().withMetricRegistry(fakeRegistry).build()) + .build()) + .build(); + int currentIndex = GcpManagedChannel.channelPoolIndex.get(); + String poolIndex = String.format("pool-%d", currentIndex); + final AtomicInteger idleCounter = new AtomicInteger(); + ManagedChannel channel = new FakeIdleCountingManagedChannel(idleCounter); + ChannelRef chRef = pool.new ChannelRef(channel); + assertEquals(0, idleCounter.get()); + + TimeUnit.MILLISECONDS.sleep(105); + + // Report 3 deadline exceeded errors after 100 ms. + long startNanos = System.nanoTime(); + final Status deStatus = Status.fromCode(Code.DEADLINE_EXCEEDED); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(0, idleCounter.get()); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(0, idleCounter.get()); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + // Reconnected after 3rd deadline exceeded. + assertEquals(1, idleCounter.get()); + + // Initial log messages count. + int logCount = logRecords.size(); + + MetricsRecord record = fakeRegistry.pollRecord(); + List> metric = + record.getMetrics().get(GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isEqualTo(1L); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS + + " = 1"); + + metric = record.getMetrics().get(GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DROPPED_CALLS); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isEqualTo(3L); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DROPPED_CALLS + + " = 3"); + + metric = record.getMetrics().get(GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DROPPED_CALLS); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isEqualTo(3L); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DROPPED_CALLS + + " = 3"); + + metric = record.getMetrics().get(GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DETECTION_TIME); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isAtLeast(100L); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .matches( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_MIN_UNRESPONSIVE_DETECTION_TIME + + " = 1\\d\\d"); + + metric = record.getMetrics().get(GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DETECTION_TIME); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isAtLeast(100L); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + assertThat(lastLogMessage()) + .matches( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_MAX_UNRESPONSIVE_DETECTION_TIME + + " = 1\\d\\d"); + + // Any message from the server must reset the dropped requests count and timestamp. + TimeUnit.MILLISECONDS.sleep(105); + startNanos = System.nanoTime(); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(1, idleCounter.get()); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(1, idleCounter.get()); + // A message received from the server. + chRef.messageReceived(); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + // No idle increment expected because dropped requests count and timestamp were reset. + assertEquals(1, idleCounter.get()); + + // Any non-deadline exceeded response must reset the dropped requests count and timestamp. + TimeUnit.MILLISECONDS.sleep(105); + startNanos = System.nanoTime(); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(1, idleCounter.get()); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(1, idleCounter.get()); + // Response with UNAVAILABLE status received from the server. + final Status unavailableStatus = Status.fromCode(Code.UNAVAILABLE); + chRef.activeStreamsCountDecr(startNanos, unavailableStatus, false); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + // No idle increment expected because dropped requests count and timestamp were reset. + assertEquals(1, idleCounter.get()); + + // Even if dropped requests count is reached, it must also respect 100 ms configured. + startNanos = System.nanoTime(); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(1, idleCounter.get()); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(1, idleCounter.get()); + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + // Even it's third deadline exceeded no idle increment is expected because 100ms has not pass. + assertEquals(1, idleCounter.get()); + + TimeUnit.MILLISECONDS.sleep(105); + // Any subsequent deadline exceeded after 100ms must trigger the reconnection. + chRef.activeStreamsCountDecr(startNanos, deStatus, false); + assertEquals(2, idleCounter.get()); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogMessage()) + .matches( + poolIndex + + ": Channel 0 connection is unresponsive for 1\\d\\d ms and 4 deadline " + + "exceeded calls. Forcing channel to idle state."); + assertThat(lastLogLevel()).isEqualTo(Level.FINER); + + // The cumulative num_unresponsive_detections metric must become 2. + metric = record.getMetrics().get(GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isEqualTo(2L); + assertThat(logRecords.size()).isEqualTo(++logCount); + // But the log metric count the detections since previous report for num_unresponsive_detections + // in the logs. It is always delta in the logs, not cumulative. + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS + + " = 1"); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + // If we log it again the cumulative metric value must remain unchanged. + metric = record.getMetrics().get(GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS); + assertThat(metric.size()).isEqualTo(1); + assertThat(metric.get(0).value()).isEqualTo(2L); + assertThat(logRecords.size()).isEqualTo(++logCount); + assertThat(lastLogLevel()).isEqualTo(Level.FINE); + // But in the log it must post 0. + assertThat(lastLogMessage()) + .isEqualTo( + poolIndex + + ": stat: " + + GcpMetricsConstants.METRIC_NUM_UNRESPONSIVE_DETECTIONS + + " = 0"); + } + + @Test + public void testStateNotifications() throws InterruptedException { + final AtomicBoolean immediateCallbackCalled = new AtomicBoolean(); + // Test callback is called when state doesn't match. + gcpChannel.notifyWhenStateChanged( + ConnectivityState.SHUTDOWN, () -> immediateCallbackCalled.set(true)); + + TimeUnit.MILLISECONDS.sleep(2); + + assertThat(immediateCallbackCalled.get()).isTrue(); + + // Subscribe for notification when leaving IDLE state. + final AtomicReference newState = new AtomicReference<>(); + + final Runnable callback = + new Runnable() { + @Override + public void run() { + ConnectivityState state = gcpChannel.getState(false); + newState.set(state); + if (state.equals(ConnectivityState.IDLE)) { + gcpChannel.notifyWhenStateChanged(ConnectivityState.IDLE, this); + } + } + }; + + gcpChannel.notifyWhenStateChanged(ConnectivityState.IDLE, callback); + + // Init connection to move out of the IDLE state. + ConnectivityState currentState = gcpChannel.getState(true); + // Make sure it was IDLE; + assertThat(currentState).isEqualTo(ConnectivityState.IDLE); + + TimeUnit.MILLISECONDS.sleep(25); + + assertThat(newState.get()) + .isAnyOf(ConnectivityState.CONNECTING, ConnectivityState.TRANSIENT_FAILURE); + } + + @Test + public void testParallelStateNotifications() throws InterruptedException { + AtomicReference exception = new AtomicReference<>(); + + ExecutorService grpcExecutor = + Executors.newCachedThreadPool( + new ThreadFactoryBuilder() + .setUncaughtExceptionHandler((t, e) -> exception.set(e)) + .build()); + + ManagedChannelBuilder builder = ManagedChannelBuilder.forAddress(TARGET, 443); + GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .executor(grpcExecutor) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder().setMaxSize(1).build()) + .build()) + .build(); + + // Pre-populate with a fake channel to control state changes. + FakeManagedChannel channel = new FakeManagedChannel(grpcExecutor); + ChannelRef ref = pool.new ChannelRef(channel); + pool.channelRefs.add(ref); + + // Always re-subscribe for notification to have constant callbacks flowing. + final Runnable callback = + new Runnable() { + @Override + public void run() { + ConnectivityState state = pool.getState(false); + pool.notifyWhenStateChanged(state, this); + } + }; + + // Update channels state and subscribe for pool state changes in parallel. + final ExecutorService executor = + Executors.newCachedThreadPool( + new ThreadFactoryBuilder().setNameFormat("gcp-mc-test-%d").build()); + + for (int i = 0; i < 300; i++) { + executor.execute( + () -> { + ConnectivityState currentState = pool.getState(true); + pool.notifyWhenStateChanged(currentState, callback); + }); + executor.execute( + () -> { + channel.setState(ConnectivityState.IDLE); + channel.setState(ConnectivityState.CONNECTING); + }); + } + + executor.shutdown(); + // noinspection StatementWithEmptyBody + while (!executor.awaitTermination(10, TimeUnit.MILLISECONDS)) {} + + channel.setState(ConnectivityState.SHUTDOWN); + pool.shutdownNow(); + + // Make sure no exceptions were raised in callbacks. + assertThat(exception.get()).isNull(); + + grpcExecutor.shutdown(); + } + + @Test + public void testParallelGetChannelRefWontExceedMaxSize() throws InterruptedException { + resetGcpChannel(); + GcpChannelPoolOptions poolOptions = + GcpChannelPoolOptions.newBuilder() + .setMaxSize(2) + .setConcurrentStreamsLowWatermark(0) + .build(); + GcpManagedChannelOptions options = + GcpManagedChannelOptions.newBuilder().withChannelPoolOptions(poolOptions).build(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder).withOptions(options).build(); + + assertThat(gcpChannel.getNumberOfChannels()).isEqualTo(0); + assertThat(gcpChannel.getStreamsLowWatermark()).isEqualTo(0); + + for (int i = 0; i < gcpChannel.getMaxSize() - 1; i++) { + gcpChannel.getChannelRef(null); + } + + assertThat(gcpChannel.getNumberOfChannels()).isEqualTo(gcpChannel.getMaxSize() - 1); + + Runnable requestChannel = () -> gcpChannel.getChannelRef(null); + + int requestCount = gcpChannel.getMaxSize() * 3; + ExecutorService exec = Executors.newFixedThreadPool(requestCount); + for (int i = 0; i < requestCount; i++) { + exec.execute(requestChannel); + } + exec.shutdown(); + exec.awaitTermination(100, TimeUnit.MILLISECONDS); + + assertThat(gcpChannel.getNumberOfChannels()).isEqualTo(gcpChannel.getMaxSize()); + } + + @Test + public void testParallelGetChannelRefWontExceedMaxSizeFromTheStart() throws InterruptedException { + resetGcpChannel(); + GcpChannelPoolOptions poolOptions = + GcpChannelPoolOptions.newBuilder() + .setMaxSize(2) + .setConcurrentStreamsLowWatermark(0) + .build(); + GcpManagedChannelOptions options = + GcpManagedChannelOptions.newBuilder().withChannelPoolOptions(poolOptions).build(); + gcpChannel = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder).withOptions(options).build(); + + assertThat(gcpChannel.getNumberOfChannels()).isEqualTo(0); + assertThat(gcpChannel.getStreamsLowWatermark()).isEqualTo(0); + + Runnable requestChannel = () -> gcpChannel.getChannelRef(null); + + int requestCount = gcpChannel.getMaxSize() * 3; + ExecutorService exec = Executors.newFixedThreadPool(requestCount); + for (int i = 0; i < requestCount; i++) { + exec.execute(requestChannel); + } + exec.shutdown(); + exec.awaitTermination(100, TimeUnit.MILLISECONDS); + + assertThat(gcpChannel.getNumberOfChannels()).isEqualTo(gcpChannel.getMaxSize()); + } + + @Test + public void testAffinityKeysCleanup() throws InterruptedException { + // Creating a pool with affinity keys cleanup options. + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(builder) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setMinSize(3) + .setMaxSize(3) + .setAffinityKeyLifetime(Duration.ofMillis(200)) + .setChannelPickStrategy( + GcpManagedChannelOptions.ChannelPickStrategy.LINEAR_SCAN) + .build()) + .build()) + .build(); + + final String liveKey = "live-key"; + ChannelRef ch0 = pool.getChannelRef(liveKey); + assertThat(ch0.getId()).isEqualTo(0); + ch0.activeStreamsCountIncr(); + ch0.activeStreamsCountIncr(); + + ChannelRef ch1 = pool.getChannelRef(null); + assertThat(ch1.getId()).isEqualTo(1); + ch1.activeStreamsCountIncr(); + + final String expKey = "expiring-key"; + ChannelRef ch2 = pool.getChannelRef(expKey); + // Should bind on the fly to the least busy channel, which is 2. + assertThat(ch2.getId()).isEqualTo(2); + ch2.activeStreamsCountIncr(); + ch2.activeStreamsCountIncr(); + ch2.activeStreamsCountIncr(); + + assertThat(pool.affinityKeyToChannelRef.keySet().size()).isEqualTo(2); + assertThat(pool.affinityKeyToChannelRef.get(liveKey)).isEqualTo(ch0); + assertThat(pool.affinityKeyToChannelRef.get(expKey)).isEqualTo(ch2); + + // Still picks channel 2 because of affinity even though it is the busiest. + assertThat(pool.getChannelRef(expKey).getId()).isEqualTo(2); + + // Halfway through affinity lifetime we use the live key again. + TimeUnit.MILLISECONDS.sleep(100); + ch0 = pool.getChannelRef(liveKey); + // Make sure affinity still works. + assertThat(ch0.getId()).isEqualTo(0); + + // Wait the remaining time and check that there is still affinity for the live key + // but no affinity for the expired key. + + TimeUnit.MILLISECONDS.sleep(150); + + assertThat(pool.affinityKeyToChannelRef.keySet().size()).isEqualTo(1); + assertThat(pool.affinityKeyToChannelRef.get(liveKey)).isEqualTo(ch0); + assertThat(pool.affinityKeyLastUsed.get(expKey)).isNull(); + + // Should pick channel 1 as least busy because the affinity key was cleaned up. + assertThat(pool.getChannelRef(expKey).getId()).isEqualTo(1); + + // Make sure affinity key gets cleaned up on unbind. + pool.unbind(Collections.singletonList(expKey)); + assertThat(pool.affinityKeyLastUsed.size()).isEqualTo(1); + assertThat(pool.affinityKeyLastUsed.get(expKey)).isNull(); + } + + @Test + public void testDynamicChannelPool() throws InterruptedException { + + final int minSize = 2; + final int maxSize = 4; + final int minRpcPerChannel = 2; + final int maxRpcPerChannel = 5; + final Duration scaleDownInterval = Duration.ofMillis(50); + // Must catch 2 check scale down invocations + some time to avoid race with channel movement. + final long intervalWaitMs = 2 * scaleDownInterval.toMillis() + 10; + final ExecutorService executorService = Executors.newSingleThreadExecutor(); + + FakeManagedChannelBuilder fmcb = + new FakeManagedChannelBuilder(() -> new FakeManagedChannel(executorService)); + + // Creating a pool with dynamic sizing and LINEAR_SCAN for deterministic assertions. + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(fmcb) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setMinSize(minSize) + .setMaxSize(maxSize) + .setDynamicScaling( + minRpcPerChannel, maxRpcPerChannel, scaleDownInterval) + .setChannelPickStrategy( + GcpManagedChannelOptions.ChannelPickStrategy.LINEAR_SCAN) + .build()) + .build()) + .build(); + + // Starts with minSize. + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Mark connected in random order. + List shuffled = new ArrayList<>(pool.channelRefs); + Collections.shuffle(shuffled); + for (ChannelRef channelRef : shuffled) { + ((FakeManagedChannel) channelRef.getChannel()).setState(ConnectivityState.READY); + } + + long startTime = System.nanoTime(); + + // Simulate starting 10 calls which should be within the limit (2 channels x 5 + // maxRpcPerChannel). + for (int i = 0; i < minSize * maxRpcPerChannel; i++) { + pool.getChannelRef(null).activeStreamsCountIncr(); + } + + // As we are still within threshold of maxRpcPerChannel the pool must not scale yet. + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Adding 11th call should trigger scaling up immediately. + pool.getChannelRef(null).activeStreamsCountIncr(); + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize + 1); + + // Mark newly created channel connected. + ((FakeManagedChannel) pool.channelRefs.get(minSize).getChannel()) + .setState(ConnectivityState.READY); + + // Continue adding calls to verify the pool respects the maxSize value. + for (int i = 0; i < maxSize * maxRpcPerChannel - minSize * maxRpcPerChannel; i++) { + pool.getChannelRef(null).activeStreamsCountIncr(); + } + + // Now we have 21 calls in-flight which should bring us to 5 channels because + // of maxRpcPerChannel is 5, but the max size of the pool is 4, so there should be 4 channels. + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Threshold for scaling down is minRpcPerChannel * number of channels. 2 * 3 in our case. + // Going down 21 -> 7. + for (ChannelRef channelRef : pool.channelRefs) { + for (int i = 0; i < maxRpcPerChannel - minRpcPerChannel; i++) { + channelRef.activeStreamsCountDecr(startTime, Status.OK, false); + } + } + for (int i = 0; i < minRpcPerChannel; i++) { + pool.channelRefs.get(i).activeStreamsCountDecr(startTime, Status.OK, false); + } + + // Should not downscale yet. + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Should not downscale even after scale down check is passed. + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Set all except last channel ready. + for (int i = 0; i < pool.getNumberOfChannels(); i++) { + if (i == pool.getNumberOfChannels() - 1) { + continue; + } + ((FakeManagedChannel) pool.channelRefs.get(i).getChannel()).setState(ConnectivityState.READY); + } + + // Remember not connected channel or oldest connected channel. In our case the last one (not + // connected yet). + final ChannelRef disconnectedRef = + pool.channelRefs.stream() + .min( + Comparator.comparing( + (GcpManagedChannel.ChannelRef chRef) -> chRef.getConnectedSinceNanos())) + .get(); + + // Removing one more stream should trigger scale down after the interval. + pool.channelRefs.get(0).activeStreamsCountDecr(startTime, Status.OK, false); + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize - 1); + + // Make sure the oldest connected channel is removed. + assertThat(pool.channelRefs.stream().anyMatch((chRef) -> (chRef == disconnectedRef))).isFalse(); + + Set prevChannels = new HashSet<>(pool.channelRefs); + + // Scale up again to make sure not connected channels are not reused. + for (int i = 0; i < 2 * maxRpcPerChannel + disconnectedRef.getActiveStreamsCount(); i++) { + pool.getChannelRef(null).activeStreamsCountIncr(); + } + + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Find newly created channel. + ChannelRef newChannel = null; + for (ChannelRef channelRef : pool.channelRefs) { + if (!prevChannels.contains(channelRef)) { + newChannel = channelRef; + break; + } + } + assertThat(newChannel).isNotNull(); + // Mark ready. + ((FakeManagedChannel) newChannel.getChannel()).setState(ConnectivityState.READY); + + // Make sure disconnectedRef is not reused. + assertThat(newChannel == disconnectedRef).isFalse(); + + // Make sure previously removed channel is not shutted down as it still has a couple of calls. + assertThat(disconnectedRef.getState()).isNotEqualTo(ConnectivityState.SHUTDOWN); + + // Cancel the calls and make sure the channel shutdown. + while (disconnectedRef.getActiveStreamsCount() > 0) { + disconnectedRef.activeStreamsCountDecr(startTime, Status.CANCELLED, true); + } + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(disconnectedRef.getChannel().getState(false)).isEqualTo(ConnectivityState.SHUTDOWN); + + // Find the oldest connected channel. + ChannelRef oldestConnected = + pool.channelRefs.stream() + .sorted( + Comparator.comparing( + (GcpManagedChannel.ChannelRef chRef) -> chRef.getConnectedSinceNanos())) + .findFirst() + .get(); + + // Scale down. Desired state: minRpcPerChannel on every channel, then closing minRpcPerChannel + // streams cycling through channels. + for (ChannelRef channelRef : pool.channelRefs) { + while (channelRef.getActiveStreamsCount() != minRpcPerChannel) { + if (channelRef.getActiveStreamsCount() > minRpcPerChannel) { + channelRef.activeStreamsCountDecr(startTime, Status.OK, false); + } else { + channelRef.activeStreamsCountIncr(); + } + } + } + for (int i = 0; i < minRpcPerChannel; i++) { + pool.channelRefs.get(i).activeStreamsCountDecr(startTime, Status.OK, false); + } + // Remember its streams count. + int oldestStreamsCount = oldestConnected.getActiveStreamsCount(); + + // Should scale down after interval. + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize - 1); + + // Make sure it is removed. + assertThat(pool.channelRefs.stream().anyMatch(chRef -> chRef == oldestConnected)).isFalse(); + + // The active streams should still be there. + assertThat(oldestConnected.getActiveStreamsCount()).isEqualTo(oldestStreamsCount); + + // The removed oldest connected channel must still be ready. + assertThat(oldestConnected.getState()).isEqualTo(ConnectivityState.READY); + + // Scale up. + for (int i = 0; i < 2 * maxRpcPerChannel + oldestConnected.getActiveStreamsCount(); i++) { + pool.getChannelRef(null).activeStreamsCountIncr(); + } + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Make sure it is reused. + assertThat(pool.channelRefs.stream().anyMatch(chRef -> chRef == oldestConnected)).isTrue(); + + // Remember maxSize-minSize oldest connected channels. + List oldestConnectedChannels = + pool.channelRefs.stream() + .sorted( + Comparator.comparing( + (GcpManagedChannel.ChannelRef chRef) -> chRef.getConnectedSinceNanos())) + .collect(Collectors.toList()) + .subList(0, maxSize - minSize); + + // Remove all streams so that channel pool downscales to minSize. + for (ChannelRef channelRef : pool.channelRefs) { + while (channelRef.getActiveStreamsCount() > 0) { + channelRef.activeStreamsCountDecr(startTime, Status.OK, false); + } + } + + // Make sure channel pool scaled down to minSize after the interval. + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Make sure the oldest connected channels were removed. + assertThat(pool.channelRefs.stream().anyMatch(chRef -> oldestConnectedChannels.contains(chRef))) + .isFalse(); + + // Make sure the removed channels are shutted down. + assertThat( + oldestConnectedChannels.stream() + .allMatch(chRef -> chRef.getState() == ConnectivityState.SHUTDOWN)) + .isTrue(); + + pool.shutdown(); + } + + @Test + public void testDynamicChannelPoolWithAffinity() throws InterruptedException { + final String keyFormat = "abc-%d"; + final int minSize = 2; + final int maxSize = 4; + final int minRpcPerChannel = 2; + final int maxRpcPerChannel = 5; + final Duration scaleDownInterval = Duration.ofMillis(50); + // Must catch 2 check scale down invocations + some time to avoid race with channel movement. + final long intervalWaitMs = 2 * scaleDownInterval.toMillis() + 10; + final ExecutorService executorService = Executors.newSingleThreadExecutor(); + + FakeManagedChannelBuilder fmcb = + new FakeManagedChannelBuilder(() -> new FakeManagedChannel(executorService)); + + // Creating a pool with dynamic sizing and LINEAR_SCAN for deterministic assertions. + final GcpManagedChannel pool = + (GcpManagedChannel) + GcpManagedChannelBuilder.forDelegateBuilder(fmcb) + .withOptions( + GcpManagedChannelOptions.newBuilder() + .withChannelPoolOptions( + GcpChannelPoolOptions.newBuilder() + .setMinSize(minSize) + .setMaxSize(maxSize) + .setDynamicScaling( + minRpcPerChannel, maxRpcPerChannel, scaleDownInterval) + .setChannelPickStrategy( + GcpManagedChannelOptions.ChannelPickStrategy.LINEAR_SCAN) + .build()) + .build()) + .build(); + + // Starts with minSize. + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Mark connected in random order. + List shuffled = new ArrayList<>(pool.channelRefs); + Collections.shuffle(shuffled); + for (ChannelRef channelRef : shuffled) { + ((FakeManagedChannel) channelRef.getChannel()).setState(ConnectivityState.READY); + } + + long startTime = System.nanoTime(); + int keyIndex = 0; + + // Simulate starting 10 calls which should be within the limit (2 channels x 5 + // maxRpcPerChannel). + for (int i = 0; i < minSize * maxRpcPerChannel; i++) { + pool.getChannelRef(String.format(keyFormat, keyIndex++)).activeStreamsCountIncr(); + } + + // As we are still within threshold of maxRpcPerChannel the pool must not scale yet. + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Adding 11th call should trigger scaling up immediately. + pool.getChannelRef(String.format(keyFormat, keyIndex++)).activeStreamsCountIncr(); + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize + 1); + + // Mark newly created channel connected. + ((FakeManagedChannel) pool.channelRefs.get(minSize).getChannel()) + .setState(ConnectivityState.READY); + + // Continue adding calls to verify the pool respects the maxSize value. + for (int i = 0; i < maxSize * maxRpcPerChannel - minSize * maxRpcPerChannel; i++) { + pool.getChannelRef(String.format(keyFormat, keyIndex++)).activeStreamsCountIncr(); + } + + // Now we have 21 calls in-flight which should bring us to 5 channels because + // of maxRpcPerChannel is 5, but the max size of the pool is 4, so there should be 4 channels. + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Threshold for scaling down is minRpcPerChannel * number of channels. 2 * 3 in our case. + // Going down 21 -> 7. + int totalStreamCount = + pool.channelRefs.stream().mapToInt(ChannelRef::getActiveStreamsCount).sum(); + while (totalStreamCount > 7) { + for (ChannelRef channelRef : pool.channelRefs) { + if (channelRef.getActiveStreamsCount() > 0 && totalStreamCount > 7) { + channelRef.activeStreamsCountDecr(startTime, Status.OK, false); + totalStreamCount--; + } + } + } + + // Should not downscale yet. + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Should not downscale even after scale down check is passed. + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Remember not connected channel or oldest connected channel. In our case the last one (not + // connected yet). + final ChannelRef disconnectedRef = + pool.channelRefs.stream() + .min( + Comparator.comparing( + (GcpManagedChannel.ChannelRef chRef) -> chRef.getConnectedSinceNanos())) + .get(); + + // Removing one more stream should trigger scale down after the interval. + pool.channelRefs.get(0).activeStreamsCountDecr(startTime, Status.OK, false); + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize - 1); + + // Make sure the oldest connected channel is removed. + assertThat(pool.channelRefs.stream().anyMatch((chRef) -> (chRef == disconnectedRef))).isFalse(); + + Set prevChannels = new HashSet<>(pool.channelRefs); + + // Scale up again to make sure not connected channels are not reused. + for (int i = 0; i < 2 * maxRpcPerChannel + disconnectedRef.getActiveStreamsCount(); i++) { + pool.getChannelRef(String.format(keyFormat, keyIndex++)).activeStreamsCountIncr(); + } + + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Find newly created channel. + ChannelRef newChannel = null; + for (ChannelRef channelRef : pool.channelRefs) { + if (!prevChannels.contains(channelRef)) { + newChannel = channelRef; + break; + } + } + assertThat(newChannel).isNotNull(); + // Mark ready. + ((FakeManagedChannel) newChannel.getChannel()).setState(ConnectivityState.READY); + + // Make sure disconnectedRef is not reused. + assertThat(newChannel == disconnectedRef).isFalse(); + + // Make sure previously removed channel is not shutted down as it still has a couple of calls. + assertThat(disconnectedRef.getState()).isNotEqualTo(ConnectivityState.SHUTDOWN); + + // Cancel the calls and make sure the channel shutdown. + while (disconnectedRef.getActiveStreamsCount() > 0) { + disconnectedRef.activeStreamsCountDecr(startTime, Status.CANCELLED, true); + } + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(disconnectedRef.getChannel().getState(false)).isEqualTo(ConnectivityState.SHUTDOWN); + + // Find the oldest connected channel. + ChannelRef oldestConnected = + pool.channelRefs.stream() + .sorted( + Comparator.comparing( + (GcpManagedChannel.ChannelRef chRef) -> chRef.getConnectedSinceNanos())) + .findFirst() + .get(); + + // Scale down. Desired state: minRpcPerChannel on every channel, then closing minRpcPerChannel + // streams cycling through channels. + for (ChannelRef channelRef : pool.channelRefs) { + while (channelRef.getActiveStreamsCount() != minRpcPerChannel) { + if (channelRef.getActiveStreamsCount() > minRpcPerChannel) { + channelRef.activeStreamsCountDecr(startTime, Status.OK, false); + } else { + channelRef.activeStreamsCountIncr(); + } + } + } + for (int i = 0; i < minRpcPerChannel; i++) { + pool.channelRefs.get(i).activeStreamsCountDecr(startTime, Status.OK, false); + } + // Remember its streams count. + int oldestStreamsCount = oldestConnected.getActiveStreamsCount(); + + // Should scale down after interval. + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize - 1); + + // Make sure it is removed. + assertThat(pool.channelRefs.stream().anyMatch(chRef -> chRef == oldestConnected)).isFalse(); + + // The active streams should still be there. + assertThat(oldestConnected.getActiveStreamsCount()).isEqualTo(oldestStreamsCount); + + // The removed oldest connected channel must still be ready. + assertThat(oldestConnected.getState()).isEqualTo(ConnectivityState.READY); + + // Scale up. + for (int i = 0; i < 2 * maxRpcPerChannel + oldestConnected.getActiveStreamsCount(); i++) { + pool.getChannelRef(String.format(keyFormat, keyIndex++)).activeStreamsCountIncr(); + } + assertThat(pool.getNumberOfChannels()).isEqualTo(maxSize); + + // Make sure it is reused. + assertThat(pool.channelRefs.stream().anyMatch(chRef -> chRef == oldestConnected)).isTrue(); + + // Remember maxSize-minSize oldest connected channels. + List oldestConnectedChannels = + pool.channelRefs.stream() + .sorted( + Comparator.comparing( + (GcpManagedChannel.ChannelRef chRef) -> chRef.getConnectedSinceNanos())) + .collect(Collectors.toList()) + .subList(0, maxSize - minSize); + + // Remove all streams so that channel pool downscales to minSize. + for (ChannelRef channelRef : pool.channelRefs) { + while (channelRef.getActiveStreamsCount() > 0) { + channelRef.activeStreamsCountDecr(startTime, Status.OK, false); + } + } + + // Make sure channel pool scaled down to minSize after the interval. + TimeUnit.MILLISECONDS.sleep(intervalWaitMs); + assertThat(pool.getNumberOfChannels()).isEqualTo(minSize); + + // Make sure the oldest connected channels were removed. + assertThat(pool.channelRefs.stream().anyMatch(chRef -> oldestConnectedChannels.contains(chRef))) + .isFalse(); + + // Make sure the removed channels are shutted down. + assertThat( + oldestConnectedChannels.stream() + .allMatch(chRef -> chRef.getState() == ConnectivityState.SHUTDOWN)) + .isTrue(); + + pool.shutdown(); + } + + static class FakeManagedChannelBuilder extends ManagedChannelBuilder { + private final List channels; + private final Supplier channelFactory; + private final AtomicInteger next = new AtomicInteger(); + + FakeManagedChannelBuilder(List channels) { + this.channels = channels; + this.channelFactory = null; + } + + FakeManagedChannelBuilder(Supplier channelFactory) { + this.channelFactory = channelFactory; + this.channels = null; + } + + @Override + public FakeManagedChannelBuilder directExecutor() { + return this; + } + + @Override + public FakeManagedChannelBuilder executor(Executor executor) { + return this; + } + + @Override + public FakeManagedChannelBuilder intercept(List interceptors) { + return this; + } + + @Override + public FakeManagedChannelBuilder intercept(ClientInterceptor... interceptors) { + return this; + } + + @Override + public FakeManagedChannelBuilder userAgent(String userAgent) { + return this; + } + + @Override + public FakeManagedChannelBuilder overrideAuthority(String authority) { + return this; + } + + @Override + public FakeManagedChannelBuilder nameResolverFactory(Factory resolverFactory) { + return this; + } + + @Override + public FakeManagedChannelBuilder decompressorRegistry(DecompressorRegistry registry) { + return this; + } + + @Override + public FakeManagedChannelBuilder compressorRegistry(CompressorRegistry registry) { + return this; + } + + @Override + public FakeManagedChannelBuilder idleTimeout(long value, TimeUnit unit) { + return this; + } + + @Override + public ManagedChannel build() { + if (channels != null) { + return channels.get(next.getAndIncrement()); + } + + return channelFactory.get(); + } + } + + static class FakeManagedChannel extends ManagedChannel { + private ConnectivityState state = ConnectivityState.IDLE; + private Runnable stateCallback; + private final ExecutorService exec; + + FakeManagedChannel(ExecutorService exec) { + this.exec = exec; + } + + @Override + public void enterIdle() {} + + @Override + public ConnectivityState getState(boolean requestConnection) { + return state; + } + + public void setState(ConnectivityState state) { + if (state.equals(this.state)) { + return; + } + this.state = state; + if (stateCallback != null) { + exec.execute(stateCallback); + stateCallback = null; + } + } + + @Override + public void notifyWhenStateChanged(ConnectivityState source, Runnable callback) { + if (!source.equals(state)) { + exec.execute(callback); + return; + } + stateCallback = callback; + } + + @Override + public ManagedChannel shutdown() { + this.setState(ConnectivityState.SHUTDOWN); + return null; + } + + @Override + public boolean isShutdown() { + return this.state == ConnectivityState.SHUTDOWN; + } + + @Override + public boolean isTerminated() { + return this.state == ConnectivityState.SHUTDOWN; + } + + @Override + public ManagedChannel shutdownNow() { + this.setState(ConnectivityState.SHUTDOWN); + return null; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + if (this.state == ConnectivityState.SHUTDOWN) { + return true; + } + try { + unit.sleep(timeout); + } catch (InterruptedException e) { + } + return this.state == ConnectivityState.SHUTDOWN; + } + + @Override + public ClientCall newCall( + MethodDescriptor methodDescriptor, CallOptions callOptions) { + return null; + } + + @Override + public String authority() { + return null; + } + } + + static class FakeIdleCountingManagedChannel extends ManagedChannel { + private final AtomicInteger idleCounter; + + FakeIdleCountingManagedChannel(AtomicInteger idleCounter) { + this.idleCounter = idleCounter; + } + + @Override + public void enterIdle() { + idleCounter.incrementAndGet(); + } + + @Override + public ConnectivityState getState(boolean requestConnection) { + return ConnectivityState.IDLE; + } + + @Override + public void notifyWhenStateChanged(ConnectivityState source, Runnable callback) {} + + @Override + public ManagedChannel shutdown() { + return null; + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public ManagedChannel shutdownNow() { + return null; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return false; + } + + @Override + public ClientCall newCall( + MethodDescriptor methodDescriptor, CallOptions callOptions) { + return null; + } + + @Override + public String authority() { + return null; + } + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpMultiEndpointChannelOtelMetricsTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpMultiEndpointChannelOtelMetricsTest.java new file mode 100644 index 000000000000..61942fbf7c77 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/GcpMultiEndpointChannelOtelMetricsTest.java @@ -0,0 +1,96 @@ +/* + * 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 org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.grpc.GcpManagedChannelOptions.GcpMetricsOptions; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GcpMultiEndpointChannelOtelMetricsTest { + + private GcpMultiEndpointChannel channel; + + @After + public void tearDown() { + if (channel != null) { + channel.shutdown(); + } + } + + @Test + public void emitsOtelEndpointSwitchMetric() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider meterProvider = + SdkMeterProvider.builder().registerMetricReader(reader).build(); + OpenTelemetry otel = OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); + + GcpMetricsOptions metricsOptions = + GcpMetricsOptions.newBuilder() + .withOpenTelemetryMeter(otel.getMeter("grpc-gcp-me-test")) + .withNamePrefix("test/me/") + .build(); + + GcpManagedChannelOptions gcpOptions = + GcpManagedChannelOptions.newBuilder().withMetricsOptions(metricsOptions).build(); + + List endpoints = Arrays.asList("localhost:12345", "localhost:23456"); + GcpMultiEndpointOptions meOpts = + GcpMultiEndpointOptions.newBuilder(endpoints).withName("default").build(); + + channel = new GcpMultiEndpointChannel(Arrays.asList(meOpts), /* apiConfig= */ null, gcpOptions); + + Collection metrics = reader.collectAllMetrics(); + assertNotNull(metrics); + List endpointSwitch = + metrics.stream() + .filter( + m -> m.getName().equals("test/me/" + GcpMetricsConstants.METRIC_ENDPOINT_SWITCH)) + .collect(Collectors.toList()); + assertTrue(!endpointSwitch.isEmpty()); + + MetricData m = endpointSwitch.get(0); + boolean hasFallback = + m.getLongGaugeData().getPoints().stream() + .anyMatch( + p -> + p.getAttributes().asMap().values().contains(GcpMetricsConstants.TYPE_FALLBACK)); + boolean hasRecover = + m.getLongGaugeData().getPoints().stream() + .anyMatch( + p -> p.getAttributes().asMap().values().contains(GcpMetricsConstants.TYPE_RECOVER)); + boolean hasReplace = + m.getLongGaugeData().getPoints().stream() + .anyMatch( + p -> p.getAttributes().asMap().values().contains(GcpMetricsConstants.TYPE_REPLACE)); + assertTrue(hasFallback && hasRecover && hasReplace); + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/MetricRegistryTestUtils.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/MetricRegistryTestUtils.java new file mode 100644 index 000000000000..e9c94e5840e2 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/MetricRegistryTestUtils.java @@ -0,0 +1,197 @@ +/* + * 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.collect.Maps; +import io.opencensus.common.ToLongFunction; +import io.opencensus.metrics.DerivedDoubleCumulative; +import io.opencensus.metrics.DerivedDoubleGauge; +import io.opencensus.metrics.DerivedLongCumulative; +import io.opencensus.metrics.DerivedLongGauge; +import io.opencensus.metrics.DoubleCumulative; +import io.opencensus.metrics.DoubleGauge; +import io.opencensus.metrics.LabelKey; +import io.opencensus.metrics.LabelValue; +import io.opencensus.metrics.LongCumulative; +import io.opencensus.metrics.LongGauge; +import io.opencensus.metrics.MetricOptions; +import io.opencensus.metrics.MetricRegistry; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class MetricRegistryTestUtils { + + static class PointWithFunction { + private final T ref; + private final ToLongFunction function; + private final List key; + private final List values; + + PointWithFunction( + T obj, ToLongFunction function, List keys, List values) { + this.ref = obj; + this.function = function; + this.key = keys; + this.values = values; + } + + long value() { + return function.applyAsLong(ref); + } + + List keys() { + return key; + } + + List values() { + return values; + } + } + + static class MetricsRecord { + private final Map>> metrics; + + private MetricsRecord() { + this.metrics = Maps.newHashMap(); + } + + Map>> getMetrics() { + return metrics; + } + } + + public static final class FakeDerivedLongGauge extends DerivedLongGauge { + private final MetricsRecord record; + private final String name; + private final List labelKeys; + + private FakeDerivedLongGauge( + FakeMetricRegistry metricRegistry, String name, List labelKeys) { + this.record = metricRegistry.record; + this.labelKeys = labelKeys; + this.name = name; + } + + @Override + public void createTimeSeries( + List labelValues, T t, ToLongFunction toLongFunction) { + if (!this.record.metrics.containsKey(this.name)) { + this.record.metrics.put(this.name, new ArrayList<>()); + } + this.record + .metrics + .get(this.name) + .add(new PointWithFunction<>(t, toLongFunction, labelKeys, labelValues)); + } + + @Override + public void removeTimeSeries(List list) {} + + @Override + public void clear() {} + } + + public static final class FakeDerivedLongCumulative extends DerivedLongCumulative { + private final MetricsRecord record; + private final String name; + private final List labelKeys; + + private FakeDerivedLongCumulative( + FakeMetricRegistry metricRegistry, String name, List labelKeys) { + this.record = metricRegistry.record; + this.labelKeys = labelKeys; + this.name = name; + } + + @Override + public void createTimeSeries( + List labelValues, T t, ToLongFunction toLongFunction) { + if (!this.record.metrics.containsKey(this.name)) { + this.record.metrics.put(this.name, new ArrayList<>()); + } + this.record + .metrics + .get(this.name) + .add(new PointWithFunction<>(t, toLongFunction, labelKeys, labelValues)); + } + + @Override + public void removeTimeSeries(List list) {} + + @Override + public void clear() {} + } + + /** + * A {@link MetricRegistry} implementation that saves metrics records to be accessible from {@link + * #pollRecord()}. + */ + public static final class FakeMetricRegistry extends MetricRegistry { + + private final MetricsRecord record; + + FakeMetricRegistry() { + record = new MetricsRecord(); + } + + MetricsRecord pollRecord() { + return record; + } + + @Override + public DerivedLongGauge addDerivedLongGauge(String s, MetricOptions metricOptions) { + return new FakeDerivedLongGauge(this, s, metricOptions.getLabelKeys()); + } + + @Override + public LongGauge addLongGauge(String s, MetricOptions metricOptions) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleGauge addDoubleGauge(String s, MetricOptions metricOptions) { + throw new UnsupportedOperationException(); + } + + @Override + public DerivedDoubleGauge addDerivedDoubleGauge(String s, MetricOptions metricOptions) { + throw new UnsupportedOperationException(); + } + + @Override + public LongCumulative addLongCumulative(String s, MetricOptions metricOptions) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleCumulative addDoubleCumulative(String s, MetricOptions metricOptions) { + throw new UnsupportedOperationException(); + } + + @Override + public DerivedLongCumulative addDerivedLongCumulative(String s, MetricOptions metricOptions) { + return new FakeDerivedLongCumulative(this, s, metricOptions.getLabelKeys()); + } + + @Override + public DerivedDoubleCumulative addDerivedDoubleCumulative( + String s, MetricOptions metricOptions) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/fallback/GcpFallbackChannelTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/fallback/GcpFallbackChannelTest.java new file mode 100644 index 000000000000..5c45b2cb29de --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/fallback/GcpFallbackChannelTest.java @@ -0,0 +1,1283 @@ +/* + * 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.GcpFallbackChannel.INIT_FAILURE_REASON; +import static com.google.cloud.grpc.fallback.GcpFallbackOpenTelemetry.CALL_STATUS_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.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 static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.io.ByteStreams; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.MethodDescriptor.Marshaller; +import io.grpc.Status; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.DoublePointData; +import io.opentelemetry.sdk.metrics.data.LongPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import javax.annotation.Nonnull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class GcpFallbackChannelTest { + + static class DummyMarshaller implements Marshaller { + @Override + public InputStream stream(Object value) { + return new ByteArrayInputStream(value.toString().getBytes()); + } + + @Override + public Object parse(InputStream stream) { + try { + return new String(ByteStreams.toByteArray(stream), UTF_8); + } catch (IOException e) { + return new Object(); + } + } + } + + private final DummyMarshaller dummyMarshaller = new DummyMarshaller<>(); + @Mock private ManagedChannel mockPrimaryDelegateChannel; + @Mock private ManagedChannel mockFallbackDelegateChannel; + private final MethodDescriptor methodDescriptor = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("testMethod") + .setRequestMarshaller(dummyMarshaller) + .setResponseMarshaller(dummyMarshaller) + .build(); + + private final CallOptions callOptions = CallOptions.DEFAULT; + @Mock private ClientCall mockPrimaryClientCall; + @Mock private ClientCall mockFallbackClientCall; + @Mock private ScheduledExecutorService mockScheduledExecutorService; + @Mock private ManagedChannelBuilder mockPrimaryBuilder; + @Mock private ManagedChannelBuilder mockPrimaryInvalidBuilder; + @Mock private ManagedChannelBuilder mockFallbackBuilder; + @Mock private ManagedChannelBuilder mockFallbackInvalidBuilder; + + private GcpFallbackChannel gcpFallbackChannel; + + private final String primaryAuthority = "primary.authority.com"; + private final String fallbackAuthority = "fallback.authority.com"; + + @Captor private ArgumentCaptor checkErrorRatesTaskCaptor; + @Captor private ArgumentCaptor primaryProbingTaskCaptor; + @Captor private ArgumentCaptor fallbackProbingTaskCaptor; + + private Runnable checkErrorRatesTask; + private Runnable primaryProbingTask; + private Runnable fallbackProbingTask; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + // Mock delegate channel behaviors. + when(mockPrimaryDelegateChannel.newCall(any(MethodDescriptor.class), any(CallOptions.class))) + .thenReturn(mockPrimaryClientCall); + when(mockFallbackDelegateChannel.newCall(any(MethodDescriptor.class), any(CallOptions.class))) + .thenReturn(mockFallbackClientCall); + when(mockPrimaryDelegateChannel.authority()).thenReturn(primaryAuthority); + when(mockFallbackDelegateChannel.authority()).thenReturn(fallbackAuthority); + + // For constructor with builders. + when(mockPrimaryBuilder.build()).thenReturn(mockPrimaryDelegateChannel); + when(mockPrimaryInvalidBuilder.build()) + .thenThrow( + new IllegalArgumentException( + "Could not find a NameResolverProvider for custom://some.domain")); + when(mockFallbackInvalidBuilder.build()) + .thenThrow( + new IllegalArgumentException( + "Could not find a NameResolverProvider for dns://some.domain")); + when(mockFallbackBuilder.build()).thenReturn(mockFallbackDelegateChannel); + } + + @After + public void tearDown() { + // Ensure channel is shutdown if a test forgets, to prevent resource leaks in test environment. + if (gcpFallbackChannel != null && !gcpFallbackChannel.isShutdown()) { + gcpFallbackChannel.shutdownNow(); + } + } + + private GcpFallbackChannelOptions.Builder getDefaultOptionsBuilder() { + return GcpFallbackChannelOptions.newBuilder() + .setEnableFallback(true) + .setErrorRateThreshold(0.5f) + .setMinFailedCalls(3) + .setPeriod(Duration.ofMinutes(1)) + .setPrimaryProbingInterval(Duration.ofMinutes(5)) + .setErroneousStates( + EnumSet.of( + Status.Code.UNAVAILABLE, + Status.Code.UNAUTHENTICATED, + Status.Code.DEADLINE_EXCEEDED)); + } + + private GcpFallbackChannelOptions getDefaultOptions() { + return getDefaultOptionsBuilder().build(); + } + + private void initializeChannelAndCaptureTasks(GcpFallbackChannelOptions options) { + gcpFallbackChannel = + new GcpFallbackChannel( + options, + mockPrimaryDelegateChannel, + mockFallbackDelegateChannel, + mockScheduledExecutorService); + captureScheduledTasks(options); + } + + private void initializeChannelWithBuildersAndCaptureTasks(GcpFallbackChannelOptions options) { + gcpFallbackChannel = + new GcpFallbackChannel( + options, mockPrimaryBuilder, mockFallbackBuilder, mockScheduledExecutorService); + captureScheduledTasks(options); + } + + private void initializeChannelWithInvalidPrimaryBuilderAndCaptureTasks( + GcpFallbackChannelOptions options) { + gcpFallbackChannel = + new GcpFallbackChannel( + options, mockPrimaryInvalidBuilder, mockFallbackBuilder, mockScheduledExecutorService); + captureScheduledTasks(options); + } + + private void initializeChannelWithInvalidFallbackBuilderAndCaptureTasks( + GcpFallbackChannelOptions options) { + gcpFallbackChannel = + new GcpFallbackChannel( + options, mockPrimaryBuilder, mockFallbackInvalidBuilder, mockScheduledExecutorService); + captureScheduledTasks(options); + } + + private void captureScheduledTasks(GcpFallbackChannelOptions options) { + if (options.isEnableFallback() + && options.getPeriod() != null + && options.getPeriod().toMillis() > 0) { + verify(mockScheduledExecutorService) + .scheduleAtFixedRate( + checkErrorRatesTaskCaptor.capture(), + eq(options.getPeriod().toMillis()), + eq(options.getPeriod().toMillis()), + eq(MILLISECONDS)); + checkErrorRatesTask = checkErrorRatesTaskCaptor.getValue(); + } else { + verify(mockScheduledExecutorService, never()) + .scheduleAtFixedRate( + checkErrorRatesTaskCaptor.capture(), + eq(options.getPeriod().toMillis()), + eq(options.getPeriod().toMillis()), + eq(MILLISECONDS)); + checkErrorRatesTask = null; + } + if (options.getPrimaryProbingFunction() != null) { + verify(mockScheduledExecutorService) + .scheduleAtFixedRate( + primaryProbingTaskCaptor.capture(), + eq(options.getPrimaryProbingInterval().toMillis()), + eq(options.getPrimaryProbingInterval().toMillis()), + eq(MILLISECONDS)); + primaryProbingTask = primaryProbingTaskCaptor.getValue(); + } else { + verify(mockScheduledExecutorService, never()) + .scheduleAtFixedRate( + primaryProbingTaskCaptor.capture(), + eq(options.getPrimaryProbingInterval().toMillis()), + eq(options.getPrimaryProbingInterval().toMillis()), + eq(MILLISECONDS)); + primaryProbingTask = null; + } + if (options.getFallbackProbingFunction() != null) { + verify(mockScheduledExecutorService) + .scheduleAtFixedRate( + fallbackProbingTaskCaptor.capture(), + eq(options.getFallbackProbingInterval().toMillis()), + eq(options.getFallbackProbingInterval().toMillis()), + eq(MILLISECONDS)); + fallbackProbingTask = fallbackProbingTaskCaptor.getValue(); + } else { + verify(mockScheduledExecutorService, never()) + .scheduleAtFixedRate( + fallbackProbingTaskCaptor.capture(), + eq(options.getFallbackProbingInterval().toMillis()), + eq(options.getFallbackProbingInterval().toMillis()), + eq(MILLISECONDS)); + fallbackProbingTask = null; + } + } + + @SuppressWarnings({"unchecked"}) + private void simulateCall(Status statusToReturn, boolean expectFallbackRouting) { + final ClientCall.Listener dummyCallListener = mock(ClientCall.Listener.class); + final Metadata requestHeaders = new Metadata(); + + // First, create a new call on gcpFallbackChannel. This simulates how an app would create a + // call. + ClientCall testCall = gcpFallbackChannel.newCall(methodDescriptor, callOptions); + assertNotNull(testCall); + + ClientCall mockClientCall; + // Make sure the correct channel was used by gcpFallbackChannel. + if (expectFallbackRouting) { + verify(mockFallbackDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockPrimaryDelegateChannel, never()).newCall(methodDescriptor, callOptions); + mockClientCall = mockFallbackClientCall; + } else { + verify(mockPrimaryDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockFallbackDelegateChannel, never()).newCall(methodDescriptor, callOptions); + mockClientCall = mockPrimaryClientCall; + } + + // Then start this call with a dummy listener as we are not going to get real responses. + // This simulates how an app would start a call. + testCall.start(dummyCallListener, requestHeaders); + + ArgumentCaptor> delegateListenerCaptor = + ArgumentCaptor.forClass(ClientCall.Listener.class); + + // Make sure the start method was called on the client call mock and capture the provided + // listener. This listener is created by the channel created with ClientInterceptors + // in the GcpFallbackChannel and wraps the dummy listener we provided above. + verify(mockClientCall).start(delegateListenerCaptor.capture(), eq(requestHeaders)); + + // Call onClose on the listener to simulate completion of the call with a desired status. + delegateListenerCaptor.getValue().onClose(statusToReturn, new Metadata()); + + clearInvocations(mockPrimaryDelegateChannel, mockFallbackDelegateChannel, mockClientCall); + } + + private void simulateCanceledCall(boolean expectFallbackRouting) { + ClientCall testCall = gcpFallbackChannel.newCall(methodDescriptor, callOptions); + assertNotNull(testCall); + + testCall.cancel("Test cancellation", null); + + ClientCall mockClientCall; + if (expectFallbackRouting) { + verify(mockFallbackDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockPrimaryDelegateChannel, never()).newCall(methodDescriptor, callOptions); + mockClientCall = mockFallbackClientCall; + } else { + verify(mockPrimaryDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockFallbackDelegateChannel, never()).newCall(methodDescriptor, callOptions); + mockClientCall = mockPrimaryClientCall; + } + verify(mockClientCall).cancel(eq("Test cancellation"), isNull()); + clearInvocations(mockPrimaryDelegateChannel, mockFallbackDelegateChannel, mockClientCall); + } + + private class TestMetricExporter implements MetricExporter { + public final List exportedMetrics = Collections.synchronizedList(new ArrayList<>()); + + @Override + public CompletableResultCode export(@Nonnull Collection metrics) { + exportedMetrics.addAll(metrics); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + exportedMetrics.clear(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public AggregationTemporality getAggregationTemporality( + @Nonnull InstrumentType instrumentType) { + return AggregationTemporality.DELTA; + } + + public List getExportedMetrics() { + return exportedMetrics; + } + } + + private OpenTelemetry prepareOpenTelemetry(TestMetricExporter exporter) { + SdkMeterProvider meterProvider = + SdkMeterProvider.builder() + .registerMetricReader( + PeriodicMetricReader.builder(exporter).setInterval(Duration.ofMillis(100)).build()) + .build(); + + OpenTelemetry openTelemetry = + OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); + + return openTelemetry; + } + + private String fullMetricName(String metricName) { + return String.format("%s.%s", METRIC_PREFIX, metricName); + } + + private void assertSumMetrics( + long value, List metrics, String metricName, Attributes attrs) { + long actualValue = 0; + for (MetricData metricData : metrics) { + if (!metricData.getName().equals(metricName)) { + continue; + } + + pointsLoop: + for (LongPointData point : metricData.getLongSumData().getPoints()) { + for (AttributeKey key : attrs.asMap().keySet()) { + if (!attrs.get(key).equals(point.getAttributes().get(key))) { + continue pointsLoop; + } + } + + actualValue += point.getValue(); + } + } + assertEquals(value, actualValue); + } + + private void assertGaugeMetric( + double value, double delta, List metrics, String metricName, Attributes attrs) { + for (MetricData metricData : metrics) { + if (!metricData.getName().equals(metricName)) { + continue; + } + + pointsLoop: + for (DoublePointData point : metricData.getDoubleGaugeData().getPoints()) { + for (AttributeKey key : attrs.asMap().keySet()) { + if (!attrs.get(key).equals(point.getAttributes().get(key))) { + continue pointsLoop; + } + } + + assertEquals(value, point.getValue(), delta); + return; + } + } + + fail("Gauge metric not found in exported metrics."); + } + + @Test + public void testFallback_whenConditionsMet() throws InterruptedException { + TestMetricExporter exporter = new TestMetricExporter(); + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setMinFailedCalls(3) + .setErrorRateThreshold(0.42f) // 3 failures / 7 calls = 0.4285 + .setGcpFallbackOpenTelemetry( + GcpFallbackOpenTelemetry.newBuilder() + .withSdk(prepareOpenTelemetry(exporter)) + .build()) + .build(); + initializeChannelAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + // Simulate 4 success, 3 failures on primary. + // UNAVAILABLE, DEADLINE_EXCEEDED, and UNAUTHENTICATED must be the erroneous states by default. + simulateCall(Status.OK, false); + simulateCall(Status.UNAVAILABLE, false); + simulateCall(Status.OK, false); + simulateCall(Status.DEADLINE_EXCEEDED, false); + simulateCall(Status.OK, false); + simulateCall(Status.UNAUTHENTICATED, false); + simulateCall(Status.OK, false); + + assertFalse( + "Should not be in fallback mode until the check is ran.", + gcpFallbackChannel.isInFallbackMode()); + + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + + assertTrue( + "Should be in fallback mode after the check is ran.", + gcpFallbackChannel.isInFallbackMode()); + + // Verify new calls go to fallback channel. + simulateCall(Status.OK, true); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + + TimeUnit.MILLISECONDS.sleep(200); + + List exportedMetrics = exporter.getExportedMetrics(); + + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(CALL_STATUS_METRIC), + Attributes.of(CHANNEL_NAME, "primary", STATUS_CODE, "DEADLINE_EXCEEDED")); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(CALL_STATUS_METRIC), + Attributes.of(CHANNEL_NAME, "primary", STATUS_CODE, "UNAVAILABLE")); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(CALL_STATUS_METRIC), + Attributes.of(CHANNEL_NAME, "primary", STATUS_CODE, "UNAUTHENTICATED")); + assertSumMetrics( + 4, + exportedMetrics, + fullMetricName(CALL_STATUS_METRIC), + Attributes.of(CHANNEL_NAME, "primary", STATUS_CODE, "OK")); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(CALL_STATUS_METRIC), + Attributes.of(CHANNEL_NAME, "fallback", STATUS_CODE, "OK")); + + assertSumMetrics( + 0, + exportedMetrics, + fullMetricName(CURRENT_CHANNEL_METRIC), + Attributes.of(CHANNEL_NAME, "primary")); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(CURRENT_CHANNEL_METRIC), + Attributes.of(CHANNEL_NAME, "fallback")); + + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(FALLBACK_COUNT_METRIC), + Attributes.of(FROM_CHANNEL_NAME, "primary", TO_CHANNEL_NAME, "fallback")); + + assertGaugeMetric( + 0.4285, + 0.001, + exportedMetrics, + fullMetricName(ERROR_RATIO_METRIC), + Attributes.of(CHANNEL_NAME, "primary")); + assertGaugeMetric( + 0, + 0.001, + exportedMetrics, + fullMetricName(ERROR_RATIO_METRIC), + Attributes.of(CHANNEL_NAME, "fallback")); + } + + @Test + public void testFallback_whenConditionsMet_withCancelledCalls() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setMinFailedCalls(1) + .setErrorRateThreshold(1f) + .setErroneousStates( + EnumSet.of( + Status.Code.UNAVAILABLE, Status.Code.CANCELLED, Status.Code.DEADLINE_EXCEEDED)) + .build(); + initializeChannelAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + // Simulate 1 cancelled call on primary. + simulateCanceledCall(false); + + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + + assertTrue( + "Should be in fallback mode after cancelled call meets threshold.", + gcpFallbackChannel.isInFallbackMode()); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + + // Verify new calls go to fallback channel. + gcpFallbackChannel.newCall(methodDescriptor, callOptions); + verify(mockFallbackDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockPrimaryDelegateChannel, never()).newCall(methodDescriptor, callOptions); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testFallback_staysOnAfterPrimaryRecovers() { + AtomicLong probeCalled = new AtomicLong(0); + // Probing function returning no error. + Function primaryProbe = + channel -> { + probeCalled.incrementAndGet(); + return ""; + }; + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setMinFailedCalls(1) + .setErrorRateThreshold(0.1f) + .setPrimaryProbingFunction(primaryProbe) + .setPrimaryProbingInterval(Duration.ofSeconds(15)) + .build(); + initializeChannelAndCaptureTasks(options); + + // Trigger fallback. + simulateCall(Status.UNAVAILABLE, false); + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + assertTrue("Should be in fallback mode.", gcpFallbackChannel.isInFallbackMode()); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + + // Run probing function in GcpFallbackChannel. + assertNotNull("probePrimary must be scheduled", primaryProbingTask); + primaryProbingTask.run(); + assertEquals(1, probeCalled.get()); + + // Run more times successfully as if primary is recovered. + primaryProbingTask.run(); + primaryProbingTask.run(); + primaryProbingTask.run(); + primaryProbingTask.run(); + primaryProbingTask.run(); + assertEquals(6, probeCalled.get()); + + // Simulate calls that would have been successful on primary. + // These calls will now go to fallback, so primary's counters won't change. + // The checkErrorRates will operate on (likely) 0/0 for primary for the new period. + simulateCall(Status.OK, true); + simulateCall(Status.OK, true); + + checkErrorRatesTask.run(); // Check again. + + assertTrue( + "Should remain in fallback mode even if primary is hypothetically recovered.", + gcpFallbackChannel.isInFallbackMode()); + + // Verify new calls still go to fallback. + gcpFallbackChannel.newCall(methodDescriptor, callOptions); + verify(mockFallbackDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockPrimaryDelegateChannel, never()).newCall(methodDescriptor, callOptions); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testFallback_initiallyWhenPrimaryBuildFails() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder().setMinFailedCalls(1).setErrorRateThreshold(0.1f).build(); + initializeChannelWithInvalidPrimaryBuilderAndCaptureTasks(options); + + verify(mockPrimaryInvalidBuilder).build(); + verify(mockFallbackBuilder).build(); + assertNotNull(gcpFallbackChannel); + + assertTrue("Should be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + // Verify new calls still go to fallback. + gcpFallbackChannel.newCall(methodDescriptor, callOptions); + verify(mockFallbackDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockPrimaryDelegateChannel, never()).newCall(methodDescriptor, callOptions); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testNoFallback_whenDisabledInOptions() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setEnableFallback(false) + // Conditions for fallback would otherwise be met. + .setMinFailedCalls(1) + .setErrorRateThreshold(0.1f) + .build(); + initializeChannelAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + simulateCall(Status.UNAVAILABLE, false); // 1 failure + + assertNull("checkErrorRates must not be scheduled.", checkErrorRatesTask); + + assertFalse( + "Should not enter fallback mode when disabled.", gcpFallbackChannel.isInFallbackMode()); + + // Verify new calls still go to primary. + gcpFallbackChannel.newCall(methodDescriptor, callOptions); + verify(mockPrimaryDelegateChannel).newCall(methodDescriptor, callOptions); + verify(mockFallbackDelegateChannel, never()).newCall(methodDescriptor, callOptions); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testNoFallback_minCallsNotMet() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setMinFailedCalls(3) // Need 3 failed calls. + .setErrorRateThreshold(0.1f) // Low threshold, but minCalls is high. + .build(); + initializeChannelAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + simulateCall(Status.UNAVAILABLE, false); + simulateCall(Status.UNAVAILABLE, false); + simulateCall(Status.OK, false); + simulateCall(Status.OK, false); + // Total calls = 4, Failures = 2. Error rate = 0.5. MinFailedCalls = 3. Threshold = 0.1. + // MinFailedCalls not met. + + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + + assertFalse( + "Should not be in fallback mode, minFailedCalls not met.", + gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + + simulateCall(Status.UNAVAILABLE, false); // 3rd failure, but during the next period. + // Total calls = 1, Failures = 1. Error rate = 1.0. MinFailedCalls = 3. Threshold = 0.1. + // MinFailedCalls not met. + + checkErrorRatesTask.run(); + assertFalse( + "Should not be in fallback mode, minFailedCalls not met.", + gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testNoFallback_fallbackChannelBuildFails() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder().setMinFailedCalls(1).setErrorRateThreshold(0.1f).build(); + initializeChannelWithInvalidFallbackBuilderAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + simulateCall(Status.UNAVAILABLE, false); + simulateCall(Status.UNAVAILABLE, false); + simulateCall(Status.UNAVAILABLE, false); + // Total calls = 3, Failures = 3. Error rate = 1.0. MinFailedCalls = 1. Threshold = 0.1. + // Fallback conditions satisfied but we have nowhere to fail over to because the fallbackChannel + // wasn't built. + + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + + assertFalse("Should not be in fallback mode.", gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testNoFallback_errorRateNotMet() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setMinFailedCalls(1) // Min calls met. + .setErrorRateThreshold(0.8f) // High threshold. + .build(); + initializeChannelAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + simulateCall(Status.OK, false); + simulateCall(Status.UNAVAILABLE, false); + // Total calls = 2, Failures = 1. Error rate = 0.5. MinFailedCalls = 1. Threshold = 0.8. + // Error rate not met. + + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + + assertFalse( + "Should not be in fallback mode, errorRateThreshold is not met.", + gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testNoFallback_zeroCalls() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder().setMinFailedCalls(1).setErrorRateThreshold(0.1f).build(); + initializeChannelAndCaptureTasks(options); + + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + + assertNotNull("checkErrorRates must be scheduled.", checkErrorRatesTask); + checkErrorRatesTask.run(); + + assertFalse( + "Should not be in fallback mode, errorRateThreshold is not met.", + gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testBadPrimary_noPrimaryProbes() { + AtomicLong probeCalled = new AtomicLong(0); + // Probing function returning no error. + Function primaryProbe = + channel -> { + probeCalled.incrementAndGet(); + return ""; + }; + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setMinFailedCalls(1) + .setErrorRateThreshold(0.1f) + .setPrimaryProbingFunction(primaryProbe) + .setPrimaryProbingInterval(Duration.ofSeconds(15)) + .build(); + initializeChannelWithInvalidPrimaryBuilderAndCaptureTasks(options); + + assertNotNull("probePrimary must be scheduled", primaryProbingTask); + // Run probing function in GcpFallbackChannel. + primaryProbingTask.run(); + // The probePrimary should not call the provided function because we have no primary channel. + assertEquals(0, probeCalled.get()); + } + + @Test + public void testShutdown_shutsDownAllComponents() { + initializeChannelAndCaptureTasks(getDefaultOptions()); + gcpFallbackChannel.shutdown(); + + verify(mockPrimaryDelegateChannel).shutdown(); + verify(mockFallbackDelegateChannel).shutdown(); + verify(mockScheduledExecutorService).shutdown(); + } + + @Test + public void testShutdownNow_shutsDownAllComponents() { + initializeChannelAndCaptureTasks(getDefaultOptions()); + gcpFallbackChannel.shutdownNow(); + + verify(mockPrimaryDelegateChannel).shutdownNow(); + verify(mockFallbackDelegateChannel).shutdownNow(); + verify(mockScheduledExecutorService).shutdownNow(); + } + + @Test + public void testAwaitTermination_success() throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + long timeout = 10; + TimeUnit unit = TimeUnit.SECONDS; + + when(mockPrimaryDelegateChannel.awaitTermination(timeout, unit)).thenReturn(true); + + ArgumentCaptor fallbackTimeoutCaptor = ArgumentCaptor.forClass(Long.class); + when(mockFallbackDelegateChannel.awaitTermination( + fallbackTimeoutCaptor.capture(), eq(NANOSECONDS))) + .thenReturn(true); + + ArgumentCaptor execTimeoutCaptor = ArgumentCaptor.forClass(Long.class); + when(mockScheduledExecutorService.awaitTermination( + execTimeoutCaptor.capture(), eq(NANOSECONDS))) + .thenReturn(true); + + assertTrue(gcpFallbackChannel.awaitTermination(timeout, unit)); + + verify(mockPrimaryDelegateChannel).awaitTermination(timeout, unit); + verify(mockFallbackDelegateChannel).awaitTermination(anyLong(), eq(NANOSECONDS)); + verify(mockScheduledExecutorService).awaitTermination(anyLong(), eq(NANOSECONDS)); + + assertTrue( + "Fallback timeout should be <= original.", + fallbackTimeoutCaptor.getValue() <= unit.toNanos(timeout)); + assertTrue("Fallback timeout should be non-negative.", fallbackTimeoutCaptor.getValue() >= 0); + assertTrue( + "Executor timeout should be <= original (adjusted for fallback).", + execTimeoutCaptor.getValue() <= unit.toNanos(timeout)); + assertTrue("Executor timeout should be non-negative.", execTimeoutCaptor.getValue() >= 0); + } + + @Test + public void testAwaitTermination_primaryTimesOut() throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + long timeout = 10; + TimeUnit unit = TimeUnit.SECONDS; + + when(mockPrimaryDelegateChannel.awaitTermination(timeout, unit)).thenReturn(false); + + assertFalse(gcpFallbackChannel.awaitTermination(timeout, unit)); + + verify(mockPrimaryDelegateChannel).awaitTermination(timeout, unit); + verify(mockFallbackDelegateChannel, never()).awaitTermination(anyLong(), any(TimeUnit.class)); + verify(mockScheduledExecutorService, never()).awaitTermination(anyLong(), any(TimeUnit.class)); + } + + @Test + public void testAwaitTermination_fallbackTimesOut() throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + long timeout = 10; + TimeUnit unit = TimeUnit.SECONDS; + + when(mockPrimaryDelegateChannel.awaitTermination(timeout, unit)).thenReturn(true); + when(mockFallbackDelegateChannel.awaitTermination(anyLong(), eq(NANOSECONDS))) + .thenReturn(false); + + assertFalse(gcpFallbackChannel.awaitTermination(timeout, unit)); + + verify(mockPrimaryDelegateChannel).awaitTermination(timeout, unit); + verify(mockFallbackDelegateChannel).awaitTermination(anyLong(), eq(NANOSECONDS)); + verify(mockScheduledExecutorService, never()).awaitTermination(anyLong(), any(TimeUnit.class)); + } + + @Test + public void testAwaitTermination_executorTimesOut() throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + long timeout = 10; + TimeUnit unit = TimeUnit.SECONDS; + + when(mockPrimaryDelegateChannel.awaitTermination(timeout, unit)).thenReturn(true); + when(mockFallbackDelegateChannel.awaitTermination(anyLong(), eq(NANOSECONDS))).thenReturn(true); + when(mockScheduledExecutorService.awaitTermination(anyLong(), eq(NANOSECONDS))) + .thenReturn(false); + + assertFalse(gcpFallbackChannel.awaitTermination(timeout, unit)); + + verify(mockPrimaryDelegateChannel).awaitTermination(timeout, unit); + verify(mockFallbackDelegateChannel).awaitTermination(anyLong(), eq(NANOSECONDS)); + verify(mockScheduledExecutorService).awaitTermination(anyLong(), eq(NANOSECONDS)); + } + + @Test + public void testAwaitTermination_primaryThrowsInterruptedException() throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + InterruptedException interruptedException = + new InterruptedException("Primary awaitTermination failed"); + when(mockPrimaryDelegateChannel.awaitTermination(anyLong(), any(TimeUnit.class))) + .thenThrow(interruptedException); + + try { + gcpFallbackChannel.awaitTermination(10, TimeUnit.SECONDS); + fail("Should have thrown InterruptedException"); + } catch (InterruptedException e) { + assertEquals(interruptedException, e); + } + verify(mockFallbackDelegateChannel, never()).awaitTermination(anyLong(), any(TimeUnit.class)); + verify(mockScheduledExecutorService, never()).awaitTermination(anyLong(), any(TimeUnit.class)); + } + + @Test + public void testAwaitTermination_fallbackThrowsInterruptedException() + throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + InterruptedException interruptedException = + new InterruptedException("Fallback awaitTermination failed"); + when(mockPrimaryDelegateChannel.awaitTermination(anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mockFallbackDelegateChannel.awaitTermination(anyLong(), any(TimeUnit.class))) + .thenThrow(interruptedException); + + try { + gcpFallbackChannel.awaitTermination(10, TimeUnit.SECONDS); + fail("Should have thrown InterruptedException"); + } catch (InterruptedException e) { + assertEquals(interruptedException, e); + } + verify(mockScheduledExecutorService, never()).awaitTermination(anyLong(), any(TimeUnit.class)); + } + + @Test + public void testAwaitTermination_executorThrowsInterruptedException() + throws InterruptedException { + initializeChannelAndCaptureTasks(getDefaultOptions()); + InterruptedException interruptedException = + new InterruptedException("Executor awaitTermination failed"); + when(mockPrimaryDelegateChannel.awaitTermination(anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mockFallbackDelegateChannel.awaitTermination(anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mockScheduledExecutorService.awaitTermination(anyLong(), any(TimeUnit.class))) + .thenThrow(interruptedException); + + try { + gcpFallbackChannel.awaitTermination(10, TimeUnit.SECONDS); + fail("Should have thrown InterruptedException"); + } catch (InterruptedException e) { + assertEquals(interruptedException, e); + } + } + + @Test + public void testIsShutdown_checksAllComponents() { + initializeChannelAndCaptureTasks(getDefaultOptions()); + + // Case 1: All shutdown. + when(mockPrimaryDelegateChannel.isShutdown()).thenReturn(true); + when(mockFallbackDelegateChannel.isShutdown()).thenReturn(true); + when(mockScheduledExecutorService.isShutdown()).thenReturn(true); + assertTrue( + "All components shutdown -> isShutdown() should be true.", gcpFallbackChannel.isShutdown()); + + // Case 2: Primary is not shutdown. + when(mockPrimaryDelegateChannel.isShutdown()).thenReturn(false); + // fallback and exec are still true from previous when(). + assertFalse( + "Primary is not shutdown -> isShutdown() should be false.", + gcpFallbackChannel.isShutdown()); + when(mockPrimaryDelegateChannel.isShutdown()).thenReturn(true); // Reset for next case. + + // Case 3: Fallback is not shutdown (primary is shutdown). + when(mockFallbackDelegateChannel.isShutdown()).thenReturn(false); + // exec is still true. + assertFalse( + "Fallback not shutdown -> isShutdown() should be false.", gcpFallbackChannel.isShutdown()); + when(mockFallbackDelegateChannel.isShutdown()).thenReturn(true); // Reset. + + // Case 4: Executor is not shutdown (primary and fallback are shutdown). + when(mockScheduledExecutorService.isShutdown()).thenReturn(false); + assertFalse( + "Executor not shutdown -> isShutdown() should be false.", gcpFallbackChannel.isShutdown()); + } + + @Test + public void testIsTerminated_checksAllComponents() { + initializeChannelAndCaptureTasks(getDefaultOptions()); + + // Case 1: All terminated. + when(mockPrimaryDelegateChannel.isTerminated()).thenReturn(true); + when(mockFallbackDelegateChannel.isTerminated()).thenReturn(true); + when(mockScheduledExecutorService.isTerminated()).thenReturn(true); + assertTrue( + "All components terminated -> isTerminated() should be true.", + gcpFallbackChannel.isTerminated()); + + // Case 2: Primary not terminated. + when(mockPrimaryDelegateChannel.isTerminated()).thenReturn(false); + assertFalse( + "Primary not terminated -> isTerminated() should be false.", + gcpFallbackChannel.isTerminated()); + when(mockPrimaryDelegateChannel.isTerminated()).thenReturn(true); + + // Case 3: Fallback not terminated. + when(mockFallbackDelegateChannel.isTerminated()).thenReturn(false); + assertFalse( + "Fallback not terminated -> isTerminated() should be false.", + gcpFallbackChannel.isTerminated()); + when(mockFallbackDelegateChannel.isTerminated()).thenReturn(true); + + // Case 4: Executor not terminated. + when(mockScheduledExecutorService.isTerminated()).thenReturn(false); + assertFalse( + "Executor not terminated -> isTerminated() should be false.", + gcpFallbackChannel.isTerminated()); + } + + @Test + public void testAuthority_usesPrimaryInitially() { + initializeChannelAndCaptureTasks(getDefaultOptions()); + assertFalse(gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testAuthority_usesFallbackWhenInFallbackMode() { + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder().setMinFailedCalls(1).setErrorRateThreshold(0.1f).build(); + + initializeChannelAndCaptureTasks(options); + + simulateCall(Status.UNAVAILABLE, false); // Trigger fallback. + checkErrorRatesTask.run(); + + assertTrue(gcpFallbackChannel.isInFallbackMode()); + assertEquals(fallbackAuthority, gcpFallbackChannel.authority()); + } + + @Test + public void testConstructorWithBuilders_initializesAndBuildsChannels() { + initializeChannelWithBuildersAndCaptureTasks( + getDefaultOptions()); // This uses the builder constructor. + + verify(mockPrimaryBuilder).build(); + verify(mockFallbackBuilder).build(); + assertNotNull(gcpFallbackChannel); + assertFalse("Should not be in fallback mode initially.", gcpFallbackChannel.isInFallbackMode()); + assertEquals(primaryAuthority, gcpFallbackChannel.authority()); + } + + @SuppressWarnings("unchecked") + @Test + public void testProbingTasksScheduled_ifConfigured() { + Function mockPrimaryProber = mock(Function.class); + Function mockFallbackProber = mock(Function.class); + + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setPrimaryProbingFunction(mockPrimaryProber) + .setFallbackProbingFunction(mockFallbackProber) + .setPrimaryProbingInterval(Duration.ofSeconds(5)) + .setFallbackProbingInterval(Duration.ofSeconds(10)) + .build(); + + initializeChannelAndCaptureTasks(options); + + assertNotNull(primaryProbingTask); + assertNotNull(fallbackProbingTask); + + primaryProbingTask.run(); + verify(mockPrimaryProber).apply(mockPrimaryDelegateChannel); + + fallbackProbingTask.run(); + verify(mockFallbackProber).apply(mockFallbackDelegateChannel); + } + + @Test + public void testProbing_reportsMetrics() throws InterruptedException { + Function mockPrimaryProber = + channel -> { + return "test_error"; + }; + Function mockFallbackProber = + channel -> { + return ""; + }; + + TestMetricExporter exporter = new TestMetricExporter(); + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setPrimaryProbingFunction(mockPrimaryProber) + .setFallbackProbingFunction(mockFallbackProber) + .setPrimaryProbingInterval(Duration.ofSeconds(5)) + .setFallbackProbingInterval(Duration.ofSeconds(10)) + .setGcpFallbackOpenTelemetry( + GcpFallbackOpenTelemetry.newBuilder() + .withSdk(prepareOpenTelemetry(exporter)) + .build()) + .build(); + + initializeChannelAndCaptureTasks(options); + + assertNotNull(primaryProbingTask); + assertNotNull(fallbackProbingTask); + + primaryProbingTask.run(); + fallbackProbingTask.run(); + + TimeUnit.MILLISECONDS.sleep(200); + List exportedMetrics = exporter.getExportedMetrics(); + + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(PROBE_RESULT_METRIC), + Attributes.of(CHANNEL_NAME, "primary", PROBE_RESULT, "test_error")); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(PROBE_RESULT_METRIC), + Attributes.of(CHANNEL_NAME, "fallback", PROBE_RESULT, "")); + } + + @Test + public void testProbing_reportsInitFailureForPrimary() throws InterruptedException { + Function mockPrimaryProber = + channel -> { + return "test_error"; + }; + Function mockFallbackProber = + channel -> { + return ""; + }; + + TestMetricExporter exporter = new TestMetricExporter(); + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setPrimaryProbingFunction(mockPrimaryProber) + .setFallbackProbingFunction(mockFallbackProber) + .setPrimaryProbingInterval(Duration.ofSeconds(5)) + .setFallbackProbingInterval(Duration.ofSeconds(10)) + .setGcpFallbackOpenTelemetry( + GcpFallbackOpenTelemetry.newBuilder() + .withSdk(prepareOpenTelemetry(exporter)) + .build()) + .build(); + + initializeChannelWithInvalidPrimaryBuilderAndCaptureTasks(options); + + assertNotNull(primaryProbingTask); + assertNotNull(fallbackProbingTask); + + primaryProbingTask.run(); + fallbackProbingTask.run(); + + TimeUnit.MILLISECONDS.sleep(200); + List exportedMetrics = exporter.getExportedMetrics(); + + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(PROBE_RESULT_METRIC), + Attributes.of(CHANNEL_NAME, "primary", PROBE_RESULT, INIT_FAILURE_REASON)); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(PROBE_RESULT_METRIC), + Attributes.of(CHANNEL_NAME, "fallback", PROBE_RESULT, "")); + } + + @Test + public void testProbing_reportsInitFailureForFallback() throws InterruptedException { + Function mockPrimaryProber = + channel -> { + return "test_error"; + }; + Function mockFallbackProber = + channel -> { + return ""; + }; + + TestMetricExporter exporter = new TestMetricExporter(); + GcpFallbackChannelOptions options = + getDefaultOptionsBuilder() + .setPrimaryProbingFunction(mockPrimaryProber) + .setFallbackProbingFunction(mockFallbackProber) + .setPrimaryProbingInterval(Duration.ofSeconds(5)) + .setFallbackProbingInterval(Duration.ofSeconds(10)) + .setGcpFallbackOpenTelemetry( + GcpFallbackOpenTelemetry.newBuilder() + .withSdk(prepareOpenTelemetry(exporter)) + .build()) + .build(); + + initializeChannelWithInvalidFallbackBuilderAndCaptureTasks(options); + + assertNotNull(primaryProbingTask); + assertNotNull(fallbackProbingTask); + + primaryProbingTask.run(); + fallbackProbingTask.run(); + + TimeUnit.MILLISECONDS.sleep(200); + List exportedMetrics = exporter.getExportedMetrics(); + + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(PROBE_RESULT_METRIC), + Attributes.of(CHANNEL_NAME, "primary", PROBE_RESULT, "test_error")); + assertSumMetrics( + 1, + exportedMetrics, + fullMetricName(PROBE_RESULT_METRIC), + Attributes.of(CHANNEL_NAME, "fallback", PROBE_RESULT, INIT_FAILURE_REASON)); + } + + @Test + public void testConstructor_failsWhenOptionsIsNull() { + assertThrows( + NullPointerException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel( + null, mockPrimaryDelegateChannel, mockFallbackDelegateChannel)); + assertThrows( + NullPointerException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel(null, mockPrimaryBuilder, mockFallbackBuilder)); + } + + @Test + public void testConstructor_failsWhenPrimaryIsNull() { + assertThrows( + NullPointerException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel(getDefaultOptions(), null, mockFallbackDelegateChannel)); + assertThrows( + NullPointerException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel(getDefaultOptions(), null, mockFallbackBuilder)); + } + + @Test + public void testConstructor_failsWhenFallbackIsNull() { + assertThrows( + NullPointerException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel(getDefaultOptions(), mockPrimaryBuilder, null)); + assertThrows( + NullPointerException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel(getDefaultOptions(), mockPrimaryBuilder, null)); + } + + @Test + public void testConstructor_failsWhenBothBuildersFail() { + assertThrows( + RuntimeException.class, + () -> + gcpFallbackChannel = + new GcpFallbackChannel( + getDefaultOptions(), mockPrimaryInvalidBuilder, mockFallbackInvalidBuilder)); + } +} diff --git a/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/multiendpoint/MultiEndpointTest.java b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/multiendpoint/MultiEndpointTest.java new file mode 100644 index 000000000000..d3d6d0b649d2 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/java/com/google/cloud/grpc/multiendpoint/MultiEndpointTest.java @@ -0,0 +1,574 @@ +/* + * 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 com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.api.client.util.Sleeper; +import com.google.cloud.grpc.multiendpoint.Endpoint.EndpointState; +import com.google.common.collect.ImmutableList; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for MultiEndpoint. */ +@RunWith(JUnit4.class) +public final class MultiEndpointTest { + + private final List threeEndpoints = + new ArrayList<>(ImmutableList.of("first", "second", "third")); + + private final List fourEndpoints = + new ArrayList<>(ImmutableList.of("fourth", "first", "third", "second")); + + private static final long RECOVERY_MS = 20; + private static final long DELAY_MS = 40; + private static final long MARGIN_MS = 10; + + private void sleep(long millis) throws InterruptedException { + Sleeper.DEFAULT.sleep(millis); + } + + private MultiEndpoint initPlain(List endpoints) { + return new MultiEndpoint.Builder(endpoints).build(); + } + + private MultiEndpoint initWithRecovery(List endpoints, long recoveryTimeOut) { + return new MultiEndpoint.Builder(endpoints) + .withRecoveryTimeout(Duration.ofMillis(recoveryTimeOut)) + .build(); + } + + private MultiEndpoint initWithDelays( + List endpoints, long recoveryTimeOut, long switchingDelay) { + return new MultiEndpoint.Builder(endpoints) + .withRecoveryTimeout(Duration.ofMillis(recoveryTimeOut)) + .withSwitchingDelay(Duration.ofMillis(switchingDelay)) + .build(); + } + + @Test + public void constructor_raisesErrorWhenEmptyEndpoints() { + IllegalArgumentException thrown = + assertThrows(IllegalArgumentException.class, () -> initPlain(ImmutableList.of())); + assertThat(thrown).hasMessageThat().contains("Endpoints list must not be empty."); + + thrown = + assertThrows( + IllegalArgumentException.class, + () -> initWithRecovery(ImmutableList.of(), RECOVERY_MS)); + assertThat(thrown).hasMessageThat().contains("Endpoints list must not be empty."); + } + + @Test + public void constructor_currentIsFirstAfterInit() { + MultiEndpoint multiEndpoint = initWithDelays(threeEndpoints, RECOVERY_MS, DELAY_MS); + + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + } + + @Test + public void getCurrent_returnsTopPriorityAvailableEndpointWithoutRecovery() { + MultiEndpoint multiEndpoint = initPlain(threeEndpoints); + + // Returns first after creation. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // Second becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + + // Second is the current as the only available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Third becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), true); + + // Second is still the current because it has higher priority. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // First becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), true); + + // First becomes the current because it has higher priority. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // Second becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), false); + + // Second becoming unavailable should not affect the current first. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // First becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), false); + + // Third becomes the current as the only remaining available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(2)); + + // Third becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), false); + + // After all endpoints became unavailable the multiEndpoint sticks to the last used endpoint. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(2)); + } + + @Test + public void getCurrent_returnsTopPriorityAvailableEndpointWithRecovery() + throws InterruptedException { + MultiEndpoint multiEndpoint = initWithRecovery(threeEndpoints, RECOVERY_MS); + + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(0); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(0); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(0); + + // Returns first after creation. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // Second becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + + // First is still the current to allow it to become available within recovery timeout. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // first -> second is a fallback. + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(1); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(0); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(0); + + // Second becomes current as an available endpoint with top priority. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Third becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), true); + + // Second is still the current because it has higher priority. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Second becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), false); + + // Second is still current, allowing upto recoveryTimeout to recover. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Halfway through recovery timeout the second recovers. + sleep(RECOVERY_MS / 2); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + + // Second is the current. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // After the initial recovery timeout, the second is still current. + sleep(RECOVERY_MS / 2 + MARGIN_MS); + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Second becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), false); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // Changes to an available endpoint -- third. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(2)); + + // second -> third is a fallback. + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(2); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(0); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(0); + + // First becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), true); + + // First becomes current immediately. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // third -> first is a recovery. + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(2); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(1); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(0); + + // First becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), false); + + // First is still current, allowing upto recoveryTimeout to recover. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // Changes to an available endpoint -- third. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(2)); + + // Third becomes unavailable + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), false); + + // Third is still current, allowing upto recoveryTimeout to recover. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(2)); + + // Halfway through recovery timeout the second becomes available. + sleep(RECOVERY_MS / 2); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + + // Second becomes current immediately. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Second becomes unavailable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), false); + + // Second is still current, allowing upto recoveryTimeout to recover. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // After all endpoints became unavailable the multiEndpoint sticks to the last used endpoint. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + } + + @Test + public void setEndpoints_raisesErrorWhenEmptyEndpoints() { + MultiEndpoint multiEndpoint = initPlain(threeEndpoints); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, () -> multiEndpoint.setEndpoints(ImmutableList.of())); + assertThat(thrown).hasMessageThat().contains("Endpoints list must not be empty."); + } + + @Test + public void setEndpoints_updatesEndpoints() { + MultiEndpoint multiEndpoint = initPlain(threeEndpoints); + multiEndpoint.setEndpoints(fourEndpoints); + + // "first" which is now under index 1 still current because no other available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(1)); + } + + @Test + public void setEndpoints_updatesEndpointsWithRecovery() { + MultiEndpoint multiEndpoint = initWithRecovery(threeEndpoints, RECOVERY_MS); + multiEndpoint.setEndpoints(fourEndpoints); + + // "first" which is now under index 1 still current because no other available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(1)); + } + + @Test + public void setEndpoints_updatesEndpointsPreservingStates() { + MultiEndpoint multiEndpoint = initPlain(threeEndpoints); + + // Second is available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + multiEndpoint.setEndpoints(fourEndpoints); + + // "second" which is now under index 3 still must remain available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(3)); + } + + @Test + public void setEndpoints_updatesEndpointsPreservingStatesWithRecovery() + throws InterruptedException { + MultiEndpoint multiEndpoint = initWithRecovery(threeEndpoints, RECOVERY_MS); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // Second is available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + multiEndpoint.setEndpoints(fourEndpoints); + + // "second" which is now under index 3 still must remain available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(3)); + } + + @Test + public void setEndpoints_updatesEndpointsSwitchToTopPriorityAvailable() { + MultiEndpoint multiEndpoint = initPlain(threeEndpoints); + + // Second and third is available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), true); + + multiEndpoint.setEndpoints(fourEndpoints); + + // "third" which is now under index 2 must become current, because "second" has lower priority. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(2)); + } + + @Test + public void setEndpoints_updatesEndpointsSwitchToTopPriorityAvailableWithRecovery() + throws InterruptedException { + MultiEndpoint multiEndpoint = initWithRecovery(threeEndpoints, RECOVERY_MS); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // Second and third is available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), true); + + multiEndpoint.setEndpoints(fourEndpoints); + + // "third" which is now under index 2 must become current, because "second" has lower priority. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(2)); + } + + @Test + public void setEndpoints_updatesEndpointsRemovesOnlyActiveEndpoint() { + List extraEndpoints = new ArrayList<>(threeEndpoints); + extraEndpoints.add("extra"); + MultiEndpoint multiEndpoint = initPlain(extraEndpoints); + + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(0); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(0); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(0); + + // Extra is available. + multiEndpoint.setEndpointAvailable("extra", true); + + // Switch "first" -> "extra" is a fallback as "extra" has lower priority. + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(1); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(0); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(0); + + // Extra is removed. + multiEndpoint.setEndpoints(fourEndpoints); + + // Switch "extra" -> "first" is of "replace" type, because "extra" is no longer in the list of + // endpoints. + assertThat(multiEndpoint.getFallbackCnt()).isEqualTo(1); + assertThat(multiEndpoint.getRecoverCnt()).isEqualTo(0); + assertThat(multiEndpoint.getReplaceCnt()).isEqualTo(1); + + // "fourth" which is under index 0 must become current, because no endpoints available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(0)); + } + + @Test + public void setEndpoints_updatesEndpointsRemovesOnlyActiveEndpointWithRecovery() + throws InterruptedException { + List extraEndpoints = new ArrayList<>(threeEndpoints); + extraEndpoints.add("extra"); + MultiEndpoint multiEndpoint = initWithRecovery(extraEndpoints, RECOVERY_MS); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // Extra is available. + multiEndpoint.setEndpointAvailable("extra", true); + + // Extra is removed. + multiEndpoint.setEndpoints(fourEndpoints); + + // "fourth" which is under index 0 must become current, because no endpoints available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(0)); + } + + @Test + public void setEndpoints_recoveringEndpointGetsRemoved() throws InterruptedException { + List extraEndpoints = new ArrayList<>(threeEndpoints); + extraEndpoints.add("extra"); + MultiEndpoint multiEndpoint = initWithRecovery(extraEndpoints, RECOVERY_MS); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // Extra is available. + multiEndpoint.setEndpointAvailable("extra", true); + + // Extra is recovering. + multiEndpoint.setEndpointAvailable("extra", false); + + // Extra is removed. + multiEndpoint.setEndpoints(fourEndpoints); + + // "fourth" which is under index 0 must become current, because no endpoints available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(0)); + + // After recovery timeout has passed. + sleep(RECOVERY_MS + MARGIN_MS); + + // "fourth" is still current. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(fourEndpoints.get(0)); + } + + @Test + public void setEndpointAvailable_subsequentUnavailableShouldNotExtendRecoveryTimeout() + throws InterruptedException { + // All endpoints are recovering. + MultiEndpoint multiEndpoint = initWithRecovery(threeEndpoints, RECOVERY_MS); + + // Before recovery timeout repeat unavailable signal. + sleep(RECOVERY_MS / 2); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), false); + + // After the initial timeout it must become unavailable. + sleep(RECOVERY_MS / 2 + MARGIN_MS); + assertThat(multiEndpoint.getEndpointsMap().get(threeEndpoints.get(0)).getState()) + .isEqualTo(EndpointState.UNAVAILABLE); + } + + @Test + public void setEndpointAvailable_recoveredUnavailableRace() throws InterruptedException { + // All endpoints are recovering. + MultiEndpoint multiEndpoint = initWithRecovery(threeEndpoints, RECOVERY_MS); + + for (int i = 0; i < 100; i++) { + // Right at the recovery timeout we enable the "first". This should race with the "first" + // becoming unavailable from its recovery timer. If this race condition is not covered then + // the test will most likely fail or at least be flaky. + sleep(RECOVERY_MS); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), true); + + sleep(MARGIN_MS); + assertThat(multiEndpoint.getEndpointsMap().get(threeEndpoints.get(0)).getState()) + .isEqualTo(EndpointState.AVAILABLE); + + // Send it back to recovery state and start recovery timer. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), false); + } + } + + @Test + public void setEndpointAvailable_doNotSwitchToUnavailableFromAvailable() + throws InterruptedException { + MultiEndpoint multiEndpoint = initWithDelays(threeEndpoints, RECOVERY_MS, DELAY_MS); + // Second and third endpoint are available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), true); + + sleep(RECOVERY_MS + MARGIN_MS); + // Second is current. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // First becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), true); + + // Switching is planned to "first" after switching delay. "second" is still current. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Almost at switching delay the "first" endpoint becomes unavailable again. + sleep(DELAY_MS - MARGIN_MS); + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), false); + + // After switching delay the current must be "second". No switching to "first" should occur. + sleep(2 * MARGIN_MS); + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + } + + @Test + public void setEndpointAvailable_doNotSwitchPreemptively() throws InterruptedException { + MultiEndpoint multiEndpoint = initWithDelays(threeEndpoints, RECOVERY_MS, DELAY_MS); + + // All unavailable after recovery timeout. + sleep(RECOVERY_MS + MARGIN_MS); + + // Only second endpoint is available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(1), true); + + // After switching delay the second should be current. + sleep(DELAY_MS + MARGIN_MS); + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + // Third becomes available. This shouldn't schedule the switch as second is still + // the most preferable. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(2), true); + + sleep(DELAY_MS / 2); + // Halfway to switch delay the first endpoint becomes available. + multiEndpoint.setEndpointAvailable(threeEndpoints.get(0), true); + + sleep(DELAY_MS / 2 + MARGIN_MS); + // After complete switching delay since third become available, the second should still be + // current because we didn't schedule the switch when third became available. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(1)); + + sleep(DELAY_MS / 2); + // But after switching delay passed since first became available it should become current. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + } + + @Test + public void setEndpoints_switchingDelayed() throws InterruptedException { + MultiEndpoint multiEndpoint = initWithDelays(threeEndpoints, RECOVERY_MS, DELAY_MS); + // All endpoints are available. + threeEndpoints.forEach(e -> multiEndpoint.setEndpointAvailable(e, true)); + + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // Prepend a new endpoint and make it available. + List extraEndpoints = new ArrayList<>(); + extraEndpoints.add("extra"); + extraEndpoints.addAll(threeEndpoints); + + multiEndpoint.setEndpoints(extraEndpoints); + multiEndpoint.setEndpointAvailable("extra", true); + + // The current endpoint should not change instantly. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // But after switching delay it should. + sleep(DELAY_MS + MARGIN_MS); + assertThat(multiEndpoint.getCurrentId()).isEqualTo("extra"); + + // Make current endpoint unavailable. + multiEndpoint.setEndpointAvailable("extra", false); + + // Should wait for recovery timeout. + assertThat(multiEndpoint.getCurrentId()).isEqualTo("extra"); + + // Should switch to a healthy endpoint after recovery timeout and not the switching delay. + sleep(RECOVERY_MS + MARGIN_MS); + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // Prepend another endpoint. + List updatedEndpoints = new ArrayList<>(); + updatedEndpoints.add("extra2"); + updatedEndpoints.addAll(extraEndpoints); + + multiEndpoint.setEndpoints(updatedEndpoints); + // Now the endpoints are: + // extra2 UNAVAILABLE + // extra UNAVAILABLE + // first AVAILABLE <-- current + // second AVAILABLE + // third AVAILABLE + + // Make "extra" endpoint available. + multiEndpoint.setEndpointAvailable("extra", true); + + // Should wait for the switching delay. + // Halfway it should be still "first" endpoint. + sleep(DELAY_MS / 2); + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // Now another higher priority endpoint becomes available. + multiEndpoint.setEndpointAvailable("extra2", true); + + // Still "first" endpoint is current because switching delay has not passed. + assertThat(multiEndpoint.getCurrentId()).isEqualTo(threeEndpoints.get(0)); + + // After another half of the switching delay has passed it should switch to the "extra2" because + // it is a top priority available endpoint at the moment. + sleep(DELAY_MS / 2 + MARGIN_MS); + assertThat(multiEndpoint.getCurrentId()).isEqualTo("extra2"); + } +} diff --git a/java-spanner/grpc-gcp/src/test/resources/apiconfig.json b/java-spanner/grpc-gcp/src/test/resources/apiconfig.json new file mode 100644 index 000000000000..625ac1f45c21 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/resources/apiconfig.json @@ -0,0 +1,29 @@ +{ + "channelPool": { + "maxSize": 3, + "maxConcurrentStreamsLowWatermark": 2 + }, + "method": [ + { + "name": [ "google.spanner.v1.Spanner/CreateSession" ], + "affinity": { + "command": "BIND", + "affinityKey": "name" + } + }, + { + "name": [ "google.spanner.v1.Spanner/GetSession" ], + "affinity": { + "command": "BOUND", + "affinityKey": "name" + } + }, + { + "name": [ "google.spanner.v1.Spanner/DeleteSession" ], + "affinity": { + "command": "UNBIND", + "affinityKey": "name" + } + } + ] +} diff --git a/java-spanner/grpc-gcp/src/test/resources/empty_channel.json b/java-spanner/grpc-gcp/src/test/resources/empty_channel.json new file mode 100644 index 000000000000..74d78b29d1ed --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/resources/empty_channel.json @@ -0,0 +1,20 @@ +{ + "method": [ + { + "name": [ "/google.spanner.v1.Spanner/CreateSession" ], + "affinity" : { + "command": "BIND", + "affinityKey": "name" + } + }, + { + "name": [ "/google.spanner.v1.Spanner/GetSession", "additional name" ], + "affinity": { + "command": "BOUND", + "affinityKey": "name" + } + }, + { + } + ] +} diff --git a/java-spanner/grpc-gcp/src/test/resources/empty_method.json b/java-spanner/grpc-gcp/src/test/resources/empty_method.json new file mode 100644 index 000000000000..736bdeb17a03 --- /dev/null +++ b/java-spanner/grpc-gcp/src/test/resources/empty_method.json @@ -0,0 +1,7 @@ +{ + "channelPool": { + "maxSize": 5, + "maxConcurrentStreamsLowWatermark": 5, + "idleTimeout": 1000 + } +} diff --git a/java-spanner/pom.xml b/java-spanner/pom.xml index 64933014c04e..4b07248f67cd 100644 --- a/java-spanner/pom.xml +++ b/java-spanner/pom.xml @@ -55,6 +55,7 @@ UTF-8 github google-cloud-spanner-parent + 1.9.2 @@ -104,6 +105,11 @@ google-cloud-spanner 6.114.0 + + com.google.cloud + grpc-gcp + ${grpc-gcp.version} + com.google.truth @@ -121,6 +127,7 @@ + grpc-gcp google-cloud-spanner grpc-google-cloud-spanner-v1 grpc-google-cloud-spanner-admin-instance-v1 diff --git a/versions.txt b/versions.txt index 9e661d081f5e..e7bd3074a470 100644 --- a/versions.txt +++ b/versions.txt @@ -1005,6 +1005,7 @@ grpc-google-cloud-spanner-v1:6.114.0:6.114.0 grpc-google-cloud-spanner-admin-instance-v1:6.114.0:6.114.0 grpc-google-cloud-spanner-admin-database-v1:6.114.0:6.114.0 google-cloud-spanner:6.114.0:6.114.0 +grpc-gcp:1.9.2:1.9.2 google-cloud-spanner-executor:6.114.0:6.114.0 proto-google-cloud-spanner-executor-v1:6.114.0:6.114.0 grpc-google-cloud-spanner-executor-v1:6.114.0:6.114.0