diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java index 2e01e3d4ca..516451d748 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java @@ -257,6 +257,7 @@ public static GcpChannelPoolOptions createDefaultDynamicChannelPoolOptions() { private final boolean enableEndToEndTracing; private final String monitoringHost; private final TransactionOptions defaultTransactionOptions; + private final boolean isExperimentalHost; enum TracingFramework { OPEN_CENSUS, @@ -914,7 +915,7 @@ protected SpannerOptions(Builder builder) { openTelemetry = builder.openTelemetry; enableApiTracing = builder.enableApiTracing; enableExtendedTracing = builder.enableExtendedTracing; - if (builder.experimentalHost != null) { + if (builder.isExperimentalHost) { enableBuiltInMetrics = false; } else { enableBuiltInMetrics = builder.enableBuiltInMetrics; @@ -922,6 +923,7 @@ protected SpannerOptions(Builder builder) { enableEndToEndTracing = builder.enableEndToEndTracing; monitoringHost = builder.monitoringHost; defaultTransactionOptions = builder.defaultTransactionOptions; + isExperimentalHost = builder.isExperimentalHost; } private String getResolvedUniverseDomain() { @@ -987,6 +989,15 @@ default String getMonitoringHost() { default GoogleCredentials getDefaultExperimentalHostCredentials() { return null; } + + /** + * Returns true if the experimental location API (SpanFE bypass) should be enabled. When + * enabled, the client will use location-aware routing to send requests directly to the + * appropriate Spanner server. + */ + default boolean isEnableLocationApi() { + return false; + } } static final String DEFAULT_SPANNER_EXPERIMENTAL_HOST_CREDENTIALS = @@ -1011,6 +1022,8 @@ private static class SpannerEnvironmentImpl implements SpannerEnvironment { private static final String SPANNER_DISABLE_DIRECT_ACCESS_GRPC_BUILTIN_METRICS = "SPANNER_DISABLE_DIRECT_ACCESS_GRPC_BUILTIN_METRICS"; private static final String SPANNER_MONITORING_HOST = "SPANNER_MONITORING_HOST"; + private static final String GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API = + "GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API"; private SpannerEnvironmentImpl() {} @@ -1069,6 +1082,11 @@ public String getMonitoringHost() { public GoogleCredentials getDefaultExperimentalHostCredentials() { return getOAuthTokenFromFile(System.getenv(DEFAULT_SPANNER_EXPERIMENTAL_HOST_CREDENTIALS)); } + + @Override + public boolean isEnableLocationApi() { + return Boolean.parseBoolean(System.getenv(GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API)); + } } /** Builder for {@link SpannerOptions} instances. */ @@ -1139,8 +1157,7 @@ public static class Builder private boolean enableBuiltInMetrics = SpannerOptions.environment.isEnableBuiltInMetrics(); private String monitoringHost = SpannerOptions.environment.getMonitoringHost(); private SslContext mTLSContext = null; - private String experimentalHost = null; - private boolean usePlainText = false; + private boolean isExperimentalHost = false; private TransactionOptions defaultTransactionOptions = TransactionOptions.getDefaultInstance(); private static String createCustomClientLibToken(String token) { @@ -1149,56 +1166,26 @@ private static String createCustomClientLibToken(String token) { protected Builder() { // Manually set retry and polling settings that work. - RetrySettings baseRetrySettings = - RetrySettings.newBuilder() - .setInitialRpcTimeoutDuration(Duration.ofSeconds(60L)) - .setMaxRpcTimeoutDuration(Duration.ofSeconds(600L)) - .setMaxRetryDelayDuration(Duration.ofSeconds(45L)) - .setRetryDelayMultiplier(1.5) - .setRpcTimeoutMultiplier(1.5) - .setTotalTimeoutDuration(Duration.ofHours(48L)) - .build(); - - // The polling setting with a short initial delay as we expect - // it to return soon. - OperationTimedPollAlgorithm shortInitialPollingDelayAlgorithm = + OperationTimedPollAlgorithm longRunningPollingAlgorithm = OperationTimedPollAlgorithm.create( - baseRetrySettings.toBuilder() - .setInitialRetryDelayDuration(Duration.ofSeconds(1L)) + RetrySettings.newBuilder() + .setInitialRpcTimeoutDuration(Duration.ofSeconds(60L)) + .setMaxRpcTimeoutDuration(Duration.ofSeconds(600L)) + .setInitialRetryDelayDuration(Duration.ofSeconds(20L)) + .setMaxRetryDelayDuration(Duration.ofSeconds(45L)) + .setRetryDelayMultiplier(1.5) + .setRpcTimeoutMultiplier(1.5) + .setTotalTimeoutDuration(Duration.ofHours(48L)) .build()); databaseAdminStubSettingsBuilder .createDatabaseOperationSettings() - .setPollingAlgorithm(shortInitialPollingDelayAlgorithm); - - // The polling setting with a long initial delay as we expect - // the operation to take a bit long time to return. - OperationTimedPollAlgorithm longInitialPollingDelayAlgorithm = - OperationTimedPollAlgorithm.create( - baseRetrySettings.toBuilder() - .setInitialRetryDelayDuration(Duration.ofSeconds(20L)) - .build()); + .setPollingAlgorithm(longRunningPollingAlgorithm); databaseAdminStubSettingsBuilder .createBackupOperationSettings() - .setPollingAlgorithm(longInitialPollingDelayAlgorithm); + .setPollingAlgorithm(longRunningPollingAlgorithm); databaseAdminStubSettingsBuilder .restoreDatabaseOperationSettings() - .setPollingAlgorithm(longInitialPollingDelayAlgorithm); - - // updateDatabaseDdl requires a separate setting because - // it has no existing overrides on RPC timeouts for LRO polling. - databaseAdminStubSettingsBuilder - .updateDatabaseDdlOperationSettings() - .setPollingAlgorithm( - OperationTimedPollAlgorithm.create( - RetrySettings.newBuilder() - .setInitialRetryDelayDuration(Duration.ofMillis(1000L)) - .setRetryDelayMultiplier(1.5) - .setMaxRetryDelayDuration(Duration.ofMillis(45000L)) - .setInitialRpcTimeoutDuration(Duration.ZERO) - .setRpcTimeoutMultiplier(1.0) - .setMaxRpcTimeoutDuration(Duration.ZERO) - .setTotalTimeoutDuration(Duration.ofHours(48L)) - .build())); + .setPollingAlgorithm(longRunningPollingAlgorithm); } Builder(SpannerOptions options) { @@ -1676,19 +1663,10 @@ public Builder setHost(String host) { @ExperimentalApi("https://github.com/googleapis/java-spanner/pull/3676") public Builder setExperimentalHost(String host) { - if (this.usePlainText) { - Preconditions.checkArgument( - !host.startsWith("https:"), - "Please remove the 'https:' protocol prefix from the host string when using plain text" - + " communication"); - if (!host.startsWith("http")) { - host = "http://" + host; - } - } super.setHost(host); super.setProjectId(EXPERIMENTAL_HOST_PROJECT_ID); setSessionPoolOption(SessionPoolOptions.newBuilder().setExperimentalHost().build()); - this.experimentalHost = host; + this.isExperimentalHost = true; return this; } @@ -1799,23 +1777,6 @@ public Builder useClientCert(String clientCertificate, String clientCertificateK return this; } - /** - * {@code usePlainText} will configure the transport to use plaintext (no TLS) and will set - * credentials to {@link com.google.cloud.NoCredentials} to avoid sending authentication over an - * unsecured channel. - */ - @ExperimentalApi("https://github.com/googleapis/java-spanner/pull/4264") - public Builder usePlainText() { - this.usePlainText = true; - this.setChannelConfigurator(ManagedChannelBuilder::usePlaintext) - .setCredentials(NoCredentials.getInstance()); - if (this.experimentalHost != null) { - // Re-apply host settings to ensure http:// is prepended. - setExperimentalHost(this.experimentalHost); - } - return this; - } - /** * Sets OpenTelemetry object to be used for Spanner Metrics and Traces. GlobalOpenTelemetry will * be used as fallback if this options is not set. @@ -1981,7 +1942,7 @@ public Builder setDefaultTransactionOptions( @Override public SpannerOptions build() { // Set the host of emulator has been set. - if (emulatorHost != null && experimentalHost == null) { + if (emulatorHost != null) { if (!emulatorHost.startsWith("http")) { emulatorHost = "http://" + emulatorHost; } @@ -1991,7 +1952,7 @@ public SpannerOptions build() { this.setChannelConfigurator(ManagedChannelBuilder::usePlaintext); // As we are using plain text, we should never send any credentials. this.setCredentials(NoCredentials.getInstance()); - } else if (experimentalHost != null && credentials == null) { + } else if (isExperimentalHost && credentials == null) { credentials = environment.getDefaultExperimentalHostCredentials(); } if (this.numChannels == null) { @@ -2033,6 +1994,12 @@ public static void useDefaultEnvironment() { SpannerOptions.environment = SpannerEnvironmentImpl.INSTANCE; } + /** Returns the current {@link SpannerEnvironment}. */ + @InternalApi + public static SpannerEnvironment getEnvironment() { + return environment; + } + @InternalApi public static GoogleCredentials getDefaultExperimentalCredentialsFromSysEnv() { return getOAuthTokenFromFile(System.getenv(DEFAULT_SPANNER_EXPERIMENTAL_HOST_CREDENTIALS)); @@ -2379,6 +2346,10 @@ public TransactionOptions getDefaultTransactionOptions() { return defaultTransactionOptions; } + public boolean isExperimentalHost() { + return isExperimentalHost; + } + @BetaApi public boolean isUseVirtualThreads() { return useVirtualThreads; diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java new file mode 100644 index 0000000000..0398eed1d4 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.spanner.v1.CacheUpdate; +import com.google.spanner.v1.ReadRequest; +import com.google.spanner.v1.RoutingHint; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import javax.annotation.Nullable; + +/** + * ChannelFinder is responsible for finding the correct Spanner server to route RPCs to. + * + *

It uses a {@link KeyRecipeCache} and a {@link KeyRangeCache} to store metadata about the + * database, including key recipes and range information. This metadata is updated through the + * {@link #update(CacheUpdate)} method. + * + *

The {@link #findServer(ReadRequest.Builder)} method is used to determine the appropriate + * server for a given read request. + */ +public final class ChannelFinder { + private final String deployment; + private final String databaseUri; + private final KeyRangeCache rangeCache; + private final KeyRecipeCache recipeCache; + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + private long databaseId = 0L; + private final ChannelFinderServerFactory serverFactory; + + public ChannelFinder( + ChannelFinderServerFactory serverFactory, String deployment, String databaseUri) { + this.serverFactory = serverFactory; + this.deployment = deployment; + this.databaseUri = databaseUri; + this.rangeCache = new KeyRangeCache(serverFactory); + this.recipeCache = new KeyRecipeCache(); + } + + /** + * Updates the cache with new metadata. + * + * @param cacheUpdate The cache update information. + */ + public void update(CacheUpdate cacheUpdate) { + lock.writeLock().lock(); + try { + if (databaseId != cacheUpdate.getDatabaseId()) { + System.out.println( + "DEBUG [BYPASS]: Database ID changed from " + + databaseId + + " to " + + cacheUpdate.getDatabaseId() + + ", clearing caches"); + recipeCache.clear(); + rangeCache.clear(); + databaseId = cacheUpdate.getDatabaseId(); + } + recipeCache.addRecipes(cacheUpdate.getKeyRecipes()); + rangeCache.addRanges(cacheUpdate); + System.out.println( + "DEBUG [BYPASS]: Cache updated. Current state:\n" + rangeCache.debugString()); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Finds the server for a given ReadRequest. + * + * @param reqBuilder The ReadRequest builder. + * @return The server to route the request to, or null if an error occurs. + */ + @Nullable + public ChannelFinderServer findServer(ReadRequest.Builder reqBuilder) { + RoutingHint.Builder hintBuilder = reqBuilder.getRoutingHintBuilder(); + lock.readLock().lock(); + try { + if (databaseId != 0) { + hintBuilder.setDatabaseId(databaseId); + } + System.out.println( + "DEBUG [BYPASS]: findServer - computing keys for table: " + reqBuilder.getTable()); + recipeCache.computeKeys(reqBuilder); // Modifies hintBuilder within reqBuilder + System.out.println( + "DEBUG [BYPASS]: findServer - after computeKeys, key: " + + hintBuilder.getKey().toStringUtf8()); + ChannelFinderServer server = + rangeCache.fillRoutingInfo(reqBuilder.getSession(), false, hintBuilder); + System.out.println( + "DEBUG [BYPASS]: findServer - fillRoutingInfo returned server: " + + (server != null ? server.getAddress() : "null")); + return server; + } finally { + lock.readLock().unlock(); + } + } + + /** + * Returns a debug string representation of the cache. + * + * @return A string containing debug information. + */ + public String debugString() { + lock.readLock().lock(); + try { + return rangeCache.debugString(); + } finally { + lock.readLock().unlock(); + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java new file mode 100644 index 0000000000..27a0b5d31a --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java @@ -0,0 +1,28 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import io.grpc.ManagedChannel; + +/** Represents a Spanner server endpoint for location-aware routing. */ +public interface ChannelFinderServer { + String getAddress(); + + boolean isHealthy(); + + ManagedChannel getChannel(); // Added to get the underlying channel for RPC calls +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java new file mode 100644 index 0000000000..c81cf82c0d --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java @@ -0,0 +1,24 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +/** Factory for creating and caching server connections for location-aware routing. */ +public interface ChannelFinderServerFactory { + ChannelFinderServer defaultServer(); + + ChannelFinderServer create(String address); +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java index 08a13f2ca9..d9b89c68e9 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java @@ -57,6 +57,7 @@ import com.google.api.gax.rpc.UnavailableException; import com.google.api.gax.rpc.WatchdogProvider; import com.google.api.pathtemplate.PathTemplate; +import com.google.auth.Credentials; import com.google.cloud.RetryHelper; import com.google.cloud.RetryHelper.RetryHelperException; import com.google.cloud.grpc.GcpManagedChannel; @@ -209,11 +210,14 @@ import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; @@ -223,6 +227,7 @@ public class GapicSpannerRpc implements SpannerRpc { private static final PathTemplate PROJECT_NAME_TEMPLATE = PathTemplate.create("projects/{project}"); + private static final Logger logger = Logger.getLogger(GapicSpannerRpc.class.getName()); private static final PathTemplate OPERATION_NAME_TEMPLATE = PathTemplate.create("{database=projects/*/instances/*/databases/*}/operations/{operation}"); private static final int MAX_MESSAGE_SIZE = 256 * 1024 * 1024; @@ -285,6 +290,89 @@ public class GapicSpannerRpc implements SpannerRpc { private final GrpcCallContext baseGrpcCallContext; + private static class KeyAwareTransportChannelProvider implements TransportChannelProvider { + private final InstantiatingGrpcChannelProvider.Builder delegateBuilder; + private final TransportChannelProvider delegate; + + public KeyAwareTransportChannelProvider( + InstantiatingGrpcChannelProvider.Builder delegateBuilder) { + this.delegateBuilder = delegateBuilder; + this.delegate = delegateBuilder.build(); + } + + @Override + public GrpcTransportChannel getTransportChannel() throws IOException { + return GrpcTransportChannel.newBuilder() + .setManagedChannel(KeyAwareChannel.create(delegateBuilder)) + .build(); + } + + @Override + public String getTransportName() { + return delegate.getTransportName(); + } + + @Override + public boolean needsEndpoint() { + return delegate.needsEndpoint(); + } + + @Override + public boolean needsCredentials() { + return delegate.needsCredentials(); + } + + @Override + public boolean needsExecutor() { + return delegate.needsExecutor(); + } + + @Override + public boolean needsHeaders() { + return delegate.needsHeaders(); + } + + @Override + public boolean shouldAutoClose() { + return delegate.shouldAutoClose(); + } + + @Override + public TransportChannelProvider withEndpoint(String endpoint) { + return delegate.withEndpoint(endpoint); + } + + @Override + public TransportChannelProvider withCredentials(Credentials credentials) { + return delegate.withCredentials(credentials); + } + + @Override + public TransportChannelProvider withHeaders(java.util.Map headers) { + return delegate.withHeaders(headers); + } + + @Override + public TransportChannelProvider withPoolSize(int poolSize) { + return delegate.withPoolSize(poolSize); + } + + @Override + public TransportChannelProvider withExecutor(ScheduledExecutorService executor) { + return delegate.withExecutor(executor); + } + + @Override + public TransportChannelProvider withExecutor(Executor executor) { + return delegate.withExecutor(executor); + } + + @Override + public boolean acceptsPoolSize() { + return delegate.acceptsPoolSize(); + } + } + public static GapicSpannerRpc create(SpannerOptions options) { return new GapicSpannerRpc(options); } @@ -393,9 +481,35 @@ public GapicSpannerRpc(final SpannerOptions options) { // If it is enabled in options uses the channel pool provided by the gRPC-GCP extension. maybeEnableGrpcGcpExtension(defaultChannelProviderBuilder, options); - TransportChannelProvider channelProvider = - MoreObjects.firstNonNull( - options.getChannelProvider(), defaultChannelProviderBuilder.build()); + TransportChannelProvider channelProvider; + // Enable KeyAwareChannel (SpanFE bypass / location API) only when BOTH conditions are met: + // 1. Using experimental host (setExperimentalHost was called) + // 2. GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API env var is set to "true" + // Default is DISABLED even for experimental host. + String locationApiEnvVar = System.getenv("GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API"); + boolean isExperimentalHost = options.isExperimentalHost(); + boolean envVarEnabled = "true".equalsIgnoreCase(locationApiEnvVar); + + // Both conditions must be true to enable bypass + boolean enableLocationApi = isExperimentalHost && envVarEnabled; + + logger.log( + Level.INFO, + "SpanFE bypass (KeyAwareChannel) configuration: " + + "GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API={0}, " + + "isExperimentalHost={1}, " + + "enableLocationApi={2}", + new Object[] {locationApiEnvVar, isExperimentalHost, enableLocationApi}); + + if (enableLocationApi) { + channelProvider = new KeyAwareTransportChannelProvider(defaultChannelProviderBuilder); + logger.log(Level.INFO, "KeyAwareChannel (SpanFE bypass) ENABLED"); + } else { + channelProvider = + MoreObjects.firstNonNull( + options.getChannelProvider(), defaultChannelProviderBuilder.build()); + logger.log(Level.INFO, "KeyAwareChannel (SpanFE bypass) DISABLED - using standard routing"); + } CredentialsProvider credentialsProvider = GrpcTransportOptions.setUpCredentialsProvider(options); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java new file mode 100644 index 0000000000..8c120f0773 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java @@ -0,0 +1,98 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.api.gax.grpc.GrpcTransportChannel; +import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider; +import io.grpc.ManagedChannel; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +class GrpcChannelFinderServerFactory implements ChannelFinderServerFactory { + private final InstantiatingGrpcChannelProvider.Builder channelBuilder; + private final Map servers = new ConcurrentHashMap<>(); + private final GrpcChannelFinderServer defaultServer; + + public GrpcChannelFinderServerFactory(InstantiatingGrpcChannelProvider.Builder channelBuilder) + throws IOException { + this.channelBuilder = channelBuilder; + // The "default" server will use the original endpoint from the builder. + this.defaultServer = + new GrpcChannelFinderServer(this.channelBuilder.getEndpoint(), channelBuilder.build()); + this.servers.put(this.defaultServer.getAddress(), this.defaultServer); + } + + @Override + public ChannelFinderServer defaultServer() { + return defaultServer; + } + + @Override + public ChannelFinderServer create(String address) { + return servers.computeIfAbsent( + address, + addr -> { + try { + // Modify the builder to use the new address + synchronized (channelBuilder) { + InstantiatingGrpcChannelProvider.Builder newBuilder = + channelBuilder.setEndpoint(addr); + return new GrpcChannelFinderServer(addr, newBuilder.build()); + } + } catch (IOException e) { + throw new RuntimeException("Failed to create channel for address: " + addr, e); + } + }); + } + + static class GrpcChannelFinderServer implements ChannelFinderServer { + private final String address; + private final ManagedChannel channel; + + public GrpcChannelFinderServer(String address, InstantiatingGrpcChannelProvider provider) + throws IOException { + this.address = address; + // It's assumed that getTransportChannel() returns a ManagedChannel or can be cast to one. + // For this example, GrpcTransportChannel is used as in KeyAwareChannel. + GrpcTransportChannel transportChannel = (GrpcTransportChannel) provider.getTransportChannel(); + this.channel = (ManagedChannel) transportChannel.getChannel(); + } + + // Constructor for the default server that already has a channel + public GrpcChannelFinderServer(String address, ManagedChannel channel) { + this.address = address; + this.channel = channel; + } + + @Override + public String getAddress() { + return address; + } + + @Override + public boolean isHealthy() { + // A simple health check. In a real scenario, this might involve a ping or other checks. + return !channel.isShutdown() && !channel.isTerminated(); + } + + @Override + public ManagedChannel getChannel() { + return channel; + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java new file mode 100644 index 0000000000..b8cfc1b8e7 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java @@ -0,0 +1,314 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider; +import com.google.spanner.v1.PartialResultSet; +import com.google.spanner.v1.ReadRequest; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ForwardingClientCall; +import io.grpc.ForwardingClientCallListener.SimpleForwardingClientCallListener; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; + +/** + * KeyAwareChannel is a ManagedChannel that intercepts calls to key-aware Spanner methods, primarily + * StreamingRead. It uses a ChannelFinder to select the appropriate server based on the request's + * key information. The ChannelFinder's cache is updated with information received in response + * headers. + */ +final class KeyAwareChannel extends ManagedChannel { + private final ManagedChannel defaultChannel; // The original channel from the builder + private final GrpcChannelFinderServerFactory serverFactory; + private final String authority; // Authority from the original channel + private final String deployment; // Global deployment ID, derived from endpoint + private final Map> channelFinders = + new ConcurrentHashMap<>(); + + private KeyAwareChannel(InstantiatingGrpcChannelProvider.Builder channelBuilder) + throws IOException { + this.serverFactory = new GrpcChannelFinderServerFactory(channelBuilder); + this.defaultChannel = this.serverFactory.defaultServer().getChannel(); + this.authority = this.defaultChannel.authority(); + // Use the builder's original endpoint as the deployment identifier + this.deployment = channelBuilder.build().getEndpoint(); + } + + static KeyAwareChannel create(InstantiatingGrpcChannelProvider.Builder channelBuilder) + throws IOException { + return new KeyAwareChannel(channelBuilder); + } + + private String extractDatabaseIdFromSession(String session) { + if (session == null || session.isEmpty()) { + return null; + } + // Session format: + // projects/{project}/instances/{instance}/databases/{database}/sessions/{session_id} + // Database ID: projects/{project}/instances/{instance}/databases/{database} + int sessionsIndex = session.indexOf("/sessions/"); + if (sessionsIndex == -1) { + return null; + } + return session.substring(0, sessionsIndex); + } + + private ChannelFinder getOrCreateChannelFinder(String databaseId) { + SoftReference ref = channelFinders.get(databaseId); + ChannelFinder finder = (ref != null) ? ref.get() : null; + if (finder == null) { + synchronized (channelFinders) { // Synchronize to prevent duplicate creation + // Double-check after acquiring lock + ref = channelFinders.get(databaseId); + finder = (ref != null) ? ref.get() : null; + if (finder == null) { + // The databaseId (e.g., projects/../databases/DB_NAME) is used as the databaseUri + finder = new ChannelFinder(this.serverFactory, this.deployment, databaseId); + channelFinders.put(databaseId, new SoftReference<>(finder)); + } + } + } + return finder; + } + + @Override + public ManagedChannel shutdownNow() { + // TODO: Need to manage shutdown of all created channels in serverFactory + // and clear channelFinders map, potentially shutting down individual finders/channels. + return this; + } + + @Override + public ManagedChannel shutdown() { + // TODO: Need to manage shutdown of all created channels in serverFactory + return this; + } + + @Override + public ClientCall newCall( + MethodDescriptor methodDescriptor, CallOptions callOptions) { + if (isKeyAware(methodDescriptor)) { + return new KeyAwareClientCall<>(this, methodDescriptor, callOptions); + } + return defaultChannel.newCall(methodDescriptor, callOptions); + } + + @Override + public boolean isTerminated() { + return defaultChannel.isTerminated(); + } + + @Override + public boolean isShutdown() { + return defaultChannel.isShutdown(); + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + return defaultChannel.awaitTermination(timeout, unit); + } + + @Override + public String authority() { + return authority; + } + + // Determines if a method is key-aware (e.g., StreamingRead) + boolean isKeyAware(MethodDescriptor methodDescriptor) { + return "google.spanner.v1.Spanner/StreamingRead".equals(methodDescriptor.getFullMethodName()); + } + + static class KeyAwareClientCall + extends ForwardingClientCall { + private final KeyAwareChannel parentChannel; + private final MethodDescriptor methodDescriptor; + private final CallOptions callOptions; + private Listener responseListener; + private Metadata headers; + @Nullable private ClientCall delegate; + private ChannelFinder channelFinder; // Set in sendMessage + + KeyAwareClientCall( + KeyAwareChannel parentChannel, + MethodDescriptor methodDescriptor, + CallOptions callOptions) { + this.parentChannel = parentChannel; + this.methodDescriptor = methodDescriptor; + this.callOptions = callOptions; + } + + @Override + protected ClientCall delegate() { + if (delegate == null) { + // This should not happen in normal flow as sendMessage initializes the delegate. + // If it does, it means a method like halfClose() or cancel() was called before + // sendMessage(). + throw new IllegalStateException( + "Delegate call not initialized before use. sendMessage was likely not called."); + } + return delegate; + } + + @Override + public void start(Listener responseListener, Metadata headers) { + this.responseListener = new KeyAwareClientCallListener<>(responseListener, this); + this.headers = headers; + } + + @Override + public void sendMessage(RequestT message) { + ChannelFinderServer server = null; + + if (message instanceof ReadRequest) { + ReadRequest.Builder reqBuilder = ((ReadRequest) message).toBuilder(); + String databaseId = parentChannel.extractDatabaseIdFromSession(reqBuilder.getSession()); + + if (databaseId == null) { + server = parentChannel.serverFactory.defaultServer(); + System.out.println( + "DEBUG [BYPASS]: No database ID found, using default server: " + server.getAddress()); + } else { + this.channelFinder = parentChannel.getOrCreateChannelFinder(databaseId); + server = this.channelFinder.findServer(reqBuilder); + message = (RequestT) reqBuilder.build(); // Apply routing info changes + + ReadRequest finalReq = (ReadRequest) message; + System.out.println("DEBUG [BYPASS]: === Request Details ==="); + System.out.println("DEBUG [BYPASS]: Table: " + finalReq.getTable()); + System.out.println("DEBUG [BYPASS]: KeySet: " + finalReq.getKeySet()); + System.out.println("DEBUG [BYPASS]: Routing hint: " + finalReq.getRoutingHint()); + System.out.println("DEBUG [BYPASS]: Selected server: " + server.getAddress()); + System.out.println( + "DEBUG [BYPASS]: Is bypass routing: " + + (finalReq.getRoutingHint().getGroupUid() != 0)); + System.out.println("DEBUG [BYPASS]: ========================"); + } + } else { + // Other types of requests should never be passed to KeyAwareClientCall to begin with. + throw new IllegalStateException("Only ReadRequest is supported for key-aware calls."); + } + + delegate = server.getChannel().newCall(methodDescriptor, callOptions); + delegate.start(responseListener, headers); + delegate.sendMessage(message); + } + + @Override + public void halfClose() { + if (delegate != null) { + delegate.halfClose(); + } else { + // Handle the case where sendMessage was never called, though this is unlikely + // in normal gRPC client flows. + throw new IllegalStateException("halfClose called before sendMessage"); + } + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + if (delegate != null) { + delegate.cancel(message, cause); + } else { + // If cancel is called before sendMessage, there's no delegate to cancel. + // The listener's onClosed can be invoked to signal termination. + if (responseListener != null) { + responseListener.onClose( + io.grpc.Status.CANCELLED.withDescription(message).withCause(cause), new Metadata()); + } + } + } + } + + static class KeyAwareClientCallListener + extends SimpleForwardingClientCallListener { + private final KeyAwareClientCall call; + + KeyAwareClientCallListener( + ClientCall.Listener responseListener, KeyAwareClientCall call) { + super(responseListener); + this.call = call; + } + + @Override + public void onMessage(ResponseT message) { + if (message instanceof PartialResultSet) { + PartialResultSet response = (PartialResultSet) message; + if (response.hasCacheUpdate() && call.channelFinder != null) { + com.google.spanner.v1.CacheUpdate update = response.getCacheUpdate(); + System.out.println("DEBUG [BYPASS]: === CacheUpdate Received ==="); + System.out.println("DEBUG [BYPASS]: database_id: " + update.getDatabaseId()); + System.out.println("DEBUG [BYPASS]: groups count: " + update.getGroupCount()); + System.out.println("DEBUG [BYPASS]: ranges count: " + update.getRangeCount()); + if (update.hasKeyRecipes()) { + System.out.println( + "DEBUG [BYPASS]: recipes count: " + update.getKeyRecipes().getRecipeCount()); + System.out.println( + "DEBUG [BYPASS]: schema_generation: " + + update.getKeyRecipes().getSchemaGeneration()); + } + for (int i = 0; i < update.getGroupCount(); i++) { + com.google.spanner.v1.Group g = update.getGroup(i); + System.out.println( + "DEBUG [BYPASS]: Group[" + + i + + "]: uid=" + + g.getGroupUid() + + ", tablets=" + + g.getTabletsCount() + + ", leader_index=" + + g.getLeaderIndex()); + for (int t = 0; t < g.getTabletsCount(); t++) { + com.google.spanner.v1.Tablet tab = g.getTablets(t); + System.out.println( + "DEBUG [BYPASS]: Tablet[" + + t + + "]: uid=" + + tab.getTabletUid() + + ", server=" + + tab.getServerAddress() + + ", distance=" + + tab.getDistance() + + ", skip=" + + tab.getSkip()); + } + } + for (int i = 0; i < update.getRangeCount(); i++) { + com.google.spanner.v1.Range r = update.getRange(i); + System.out.println( + "DEBUG [BYPASS]: Range[" + + i + + "]: group_uid=" + + r.getGroupUid() + + ", split_id=" + + r.getSplitId()); + } + System.out.println("DEBUG [BYPASS]: ============================"); + call.channelFinder.update(update); + } + } + super.onMessage(message); + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java new file mode 100644 index 0000000000..a16d6555e5 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java @@ -0,0 +1,643 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.protobuf.ByteString; +import com.google.spanner.v1.CacheUpdate; +import com.google.spanner.v1.Group; +import com.google.spanner.v1.Range; +import com.google.spanner.v1.RoutingHint; +import com.google.spanner.v1.Tablet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Cache for routing information. - Tablets are stored directly within Groups - Groups are updated + * atomically with their tablets - Ranges reference groups + */ +public final class KeyRangeCache { + + private final ChannelFinderServerFactory serverFactory; + + // Map keyed by limit_key, value contains start_key and group reference + private final NavigableMap ranges = + new TreeMap<>(ByteString.unsignedLexicographicalComparator()); + + // Groups indexed by group_uid + private final Map groups = new HashMap<>(); + + // Servers indexed by address - shared across all tablets + private final Map servers = new HashMap<>(); + + public KeyRangeCache(ChannelFinderServerFactory serverFactory) { + this.serverFactory = Objects.requireNonNull(serverFactory); + } + + private static class ServerEntry { + final ChannelFinderServer server; + int refs = 1; + + ServerEntry(ChannelFinderServer server) { + this.server = server; + } + + String debugString() { + return server.getAddress() + "#" + refs; + } + } + + /** + * Represents a single tablet within a Group. Tablets are stored directly in the Group, not in a + * separate cache. + */ + private class CachedTablet { + long tabletUid = 0; + ByteString incarnation = ByteString.EMPTY; + String serverAddress = ""; + int distance = 0; + boolean skip = false; + Tablet.Role role = Tablet.Role.ROLE_UNSPECIFIED; + String location = ""; + + // Lazily initialized server connection + ChannelFinderServer server = null; + + CachedTablet() {} + + /** Updates tablet from proto, ignoring updates that are too old. */ + void update(Tablet tabletIn) { + // Check incarnation - only update if newer + if (tabletUid > 0 + && ByteString.unsignedLexicographicalComparator() + .compare(incarnation, tabletIn.getIncarnation()) + > 0) { + return; + } + + tabletUid = tabletIn.getTabletUid(); + incarnation = tabletIn.getIncarnation(); + distance = tabletIn.getDistance(); + skip = tabletIn.getSkip(); + role = tabletIn.getRole(); + location = tabletIn.getLocation(); + + // Only reset server if address changed + if (!serverAddress.equals(tabletIn.getServerAddress())) { + serverAddress = tabletIn.getServerAddress(); + server = null; // Will be lazily initialized + } + } + + /** Returns true if tablet should be skipped (unhealthy, marked skip, or no address). */ + boolean shouldSkip(RoutingHint.Builder hintBuilder) { + if (skip || serverAddress.isEmpty()) { + addSkippedTablet(hintBuilder); + return true; + } + // Check server health + if (server != null && !server.isHealthy()) { + addSkippedTablet(hintBuilder); + return true; + } + return false; + } + + private void addSkippedTablet(RoutingHint.Builder hintBuilder) { + RoutingHint.SkippedTablet.Builder skipped = hintBuilder.addSkippedTabletUidBuilder(); + skipped.setTabletUid(tabletUid); + skipped.setIncarnation(incarnation); + } + + /** Picks this tablet for the request and returns the server. */ + ChannelFinderServer pick(RoutingHint.Builder hintBuilder) { + hintBuilder.setTabletUid(tabletUid); + if (server == null && !serverAddress.isEmpty()) { + // Lazy server initialization + ServerEntry entry = findOrInsertServer(serverAddress); + server = entry.server; + } + return server; + } + + String debugString() { + return tabletUid + + ":" + + serverAddress + + "@" + + incarnation + + "(location=" + + location + + ",role=" + + role + + ",distance=" + + distance + + (skip ? ",skip" : "") + + ")"; + } + } + + /** Represents a paxos group with its tablets. Tablets are stored directly in the group. */ + private class CachedGroup { + final long groupUid; + ByteString generation = ByteString.EMPTY; + List tablets = new ArrayList<>(); + int leaderIndex = -1; + int refs = 1; + + CachedGroup(long groupUid) { + this.groupUid = groupUid; + } + + /** Updates group from proto, including its tablets. */ + void update(Group groupIn) { + System.out.println( + "DEBUG [BYPASS]: Group.update for group " + + groupUid + + ", incoming tablets: " + + groupIn.getTabletsCount() + + ", leader_index: " + + groupIn.getLeaderIndex()); + + // Only update leader if generation is newer + if (ByteString.unsignedLexicographicalComparator() + .compare(groupIn.getGeneration(), generation) + > 0) { + generation = groupIn.getGeneration(); + + // Update leader index + if (groupIn.getLeaderIndex() >= 0 && groupIn.getLeaderIndex() < groupIn.getTabletsCount()) { + leaderIndex = groupIn.getLeaderIndex(); + System.out.println("DEBUG [BYPASS]: Set leader_index to " + leaderIndex); + } else { + leaderIndex = -1; + System.out.println("DEBUG [BYPASS]: No valid leader, set to -1"); + } + } + + // Update tablet locations. Optimize for typical case where tablets haven't changed. + if (tablets.size() == groupIn.getTabletsCount()) { + boolean mismatch = false; + for (int t = 0; t < groupIn.getTabletsCount(); t++) { + if (tablets.get(t).tabletUid != groupIn.getTablets(t).getTabletUid()) { + mismatch = true; + break; + } + } + if (!mismatch) { + // Same tablets, just update them in place + System.out.println("DEBUG [BYPASS]: Tablets unchanged, updating in place"); + for (int t = 0; t < groupIn.getTabletsCount(); t++) { + tablets.get(t).update(groupIn.getTablets(t)); + } + return; + } + } + + // Tablets changed - rebuild the list, reusing existing tablets where possible + System.out.println("DEBUG [BYPASS]: Rebuilding tablet list"); + Map tabletsByUid = new HashMap<>(); + for (CachedTablet tablet : tablets) { + tabletsByUid.put(tablet.tabletUid, tablet); + } + + List newTablets = new ArrayList<>(groupIn.getTabletsCount()); + for (int t = 0; t < groupIn.getTabletsCount(); t++) { + Tablet tabletIn = groupIn.getTablets(t); + CachedTablet tablet = tabletsByUid.get(tabletIn.getTabletUid()); + if (tablet == null) { + tablet = new CachedTablet(); + System.out.println( + "DEBUG [BYPASS]: Created new tablet for uid " + tabletIn.getTabletUid()); + } + tablet.update(tabletIn); + System.out.println( + "DEBUG [BYPASS]: Tablet[" + + t + + "]: uid=" + + tablet.tabletUid + + ", server=" + + tablet.serverAddress + + ", distance=" + + tablet.distance); + newTablets.add(tablet); + } + tablets = newTablets; + System.out.println( + "DEBUG [BYPASS]: Group " + groupUid + " now has " + tablets.size() + " tablets"); + } + + /** Fills routing hint with tablet information and returns the server. */ + ChannelFinderServer fillRoutingHint(boolean preferLeader, RoutingHint.Builder hintBuilder) { + System.out.println( + "DEBUG [BYPASS]: Group.fillRoutingHint - preferLeader: " + + preferLeader + + ", tablets count: " + + tablets.size()); + + // Try leader first if preferred + if (preferLeader && hasLeader()) { + CachedTablet leaderTablet = leader(); + System.out.println( + "DEBUG [BYPASS]: Trying leader tablet: uid=" + + leaderTablet.tabletUid + + ", address=" + + leaderTablet.serverAddress + + ", skip=" + + leaderTablet.skip); + if (!leaderTablet.shouldSkip(hintBuilder)) { + ChannelFinderServer server = leaderTablet.pick(hintBuilder); + System.out.println( + "DEBUG [BYPASS]: Leader tablet picked, server: " + + (server != null ? server.getAddress() : "null")); + return server; + } + } + + // Try other tablets in order (they're ordered by distance) + for (int i = 0; i < tablets.size(); i++) { + CachedTablet tablet = tablets.get(i); + System.out.println( + "DEBUG [BYPASS]: Trying tablet[" + + i + + "]: uid=" + + tablet.tabletUid + + ", address=" + + tablet.serverAddress + + ", distance=" + + tablet.distance + + ", skip=" + + tablet.skip); + if (!tablet.shouldSkip(hintBuilder)) { + ChannelFinderServer server = tablet.pick(hintBuilder); + System.out.println( + "DEBUG [BYPASS]: Tablet[" + + i + + "] picked, server: " + + (server != null ? server.getAddress() : "null")); + return server; + } + } + + System.out.println("DEBUG [BYPASS]: No suitable tablet found in group"); + return null; + } + + boolean hasLeader() { + return leaderIndex >= 0 && leaderIndex < tablets.size(); + } + + CachedTablet leader() { + return tablets.get(leaderIndex); + } + + String debugString() { + StringBuilder sb = new StringBuilder(); + sb.append(groupUid).append(":["); + for (int i = 0; i < tablets.size(); i++) { + sb.append(tablets.get(i).debugString()); + if (hasLeader() && i == leaderIndex) { + sb.append(" (leader)"); + } + if (i < tablets.size() - 1) { + sb.append(", "); + } + } + sb.append("]@").append(generation.toStringUtf8()); + sb.append("#").append(refs); + return sb.toString(); + } + } + + /** Represents a cached range with its group and split information. */ + private static class CachedRange { + final ByteString startKey; + CachedGroup group = null; + long splitId = 0; + ByteString generation; + + CachedRange(ByteString startKey, CachedGroup group, long splitId, ByteString generation) { + this.startKey = startKey; + this.group = group; + this.splitId = splitId; + this.generation = generation; + } + + String debugString() { + return (group != null ? group.groupUid : "null_group") + + "," + + splitId + + "@" + + (generation.isEmpty() ? "" : generation.toStringUtf8()); + } + } + + private ServerEntry findOrInsertServer(String address) { + ServerEntry entry = servers.get(address); + if (entry == null) { + entry = new ServerEntry(serverFactory.create(address)); + servers.put(address, entry); + } else { + entry.refs++; + } + return entry; + } + + private void unref(ServerEntry serverEntry) { + if (serverEntry == null) { + return; + } + if (--serverEntry.refs == 0) { + servers.remove(serverEntry.server.getAddress()); + } + } + + private CachedGroup findGroup(long groupUid) { + CachedGroup group = groups.get(groupUid); + if (group != null) { + group.refs++; + } + return group; + } + + /** Finds or inserts a group and updates it with proto data. */ + private CachedGroup findOrInsertGroup(Group groupIn) { + CachedGroup group = groups.get(groupIn.getGroupUid()); + if (group == null) { + group = new CachedGroup(groupIn.getGroupUid()); + groups.put(groupIn.getGroupUid(), group); + } else { + group.refs++; + } + group.update(groupIn); + return group; + } + + private void unref(CachedGroup group) { + if (group == null) { + return; + } + if (--group.refs == 0) { + groups.remove(group.groupUid); + } + } + + private void replaceRangeIfNewer(Range rangeIn) { + ByteString startKey = rangeIn.getStartKey(); + ByteString limitKey = rangeIn.getLimitKey(); + + List affectedLimitKeys = new ArrayList<>(); + boolean newerBlockingRangeExists = false; + + // Find overlapping ranges + for (Map.Entry entry : ranges.tailMap(startKey, false).entrySet()) { + ByteString existingLimit = entry.getKey(); + CachedRange existingRange = entry.getValue(); + ByteString existingStart = existingRange.startKey; + + if (ByteString.unsignedLexicographicalComparator().compare(existingStart, limitKey) >= 0) { + break; + } + + if (isNewerOrSame(rangeIn, existingRange, existingLimit)) { + affectedLimitKeys.add(existingLimit); + } else { + newerBlockingRangeExists = true; + break; + } + } + + if (newerBlockingRangeExists) { + return; + } + + for (ByteString keyToRemove : affectedLimitKeys) { + CachedRange removed = ranges.remove(keyToRemove); + if (removed == null) { + continue; + } + + if (ByteString.unsignedLexicographicalComparator().compare(limitKey, keyToRemove) < 0) { + CachedRange tailPart = + new CachedRange(limitKey, removed.group, removed.splitId, removed.generation); + if (tailPart.group != null) { + tailPart.group.refs++; + } + ranges.put(keyToRemove, tailPart); + } + + if (ByteString.unsignedLexicographicalComparator().compare(removed.startKey, startKey) < 0) { + ranges.put(startKey, removed); + } else { + if (removed.group != null) { + unref(removed.group); + } + } + } + + CachedRange newCachedRange = + new CachedRange( + startKey, + findGroup(rangeIn.getGroupUid()), + rangeIn.getSplitId(), + rangeIn.getGeneration()); + ranges.put(limitKey, newCachedRange); + } + + private boolean isNewerOrSame( + Range rangeIn, CachedRange existingCachedRange, ByteString existingMapKeyLimit) { + int genCompare = + ByteString.unsignedLexicographicalComparator() + .compare(rangeIn.getGeneration(), existingCachedRange.generation); + if (genCompare > 0) { + return true; + } + if (genCompare == 0) { + return rangeIn.getStartKey().equals(existingCachedRange.startKey) + && rangeIn.getLimitKey().equals(existingMapKeyLimit); + } + return false; + } + + /** Applies cache updates. Tablets are processed inside group updates. */ + public void addRanges(CacheUpdate cacheUpdate) { + System.out.println( + "DEBUG [BYPASS]: addRanges called with " + + cacheUpdate.getGroupCount() + + " groups, " + + cacheUpdate.getRangeCount() + + " ranges"); + + // Insert all groups. Tablets are processed inside findOrInsertGroup -> Group.update() + List newGroups = new ArrayList<>(); + for (Group groupIn : cacheUpdate.getGroupList()) { + System.out.println( + "DEBUG [BYPASS]: Processing group " + + groupIn.getGroupUid() + + " with " + + groupIn.getTabletsCount() + + " tablets"); + newGroups.add(findOrInsertGroup(groupIn)); + } + + // Process ranges + for (Range rangeIn : cacheUpdate.getRangeList()) { + System.out.println( + "DEBUG [BYPASS]: Processing range for group " + + rangeIn.getGroupUid() + + ", split_id=" + + rangeIn.getSplitId()); + replaceRangeIfNewer(rangeIn); + } + + // Unref the groups we acquired (ranges hold their own refs) + for (CachedGroup g : newGroups) { + unref(g); + } + + System.out.println( + "DEBUG [BYPASS]: After addRanges - ranges: " + + ranges.size() + + ", groups: " + + groups.size() + + ", servers: " + + servers.size()); + } + + /** Fills routing hint and returns the server to use. */ + public ChannelFinderServer fillRoutingInfo( + String sessionUri, boolean preferLeader, RoutingHint.Builder hintBuilder) { + System.out.println( + "DEBUG [BYPASS]: fillRoutingInfo called, ranges in cache: " + + ranges.size() + + ", groups in cache: " + + groups.size()); + + if (hintBuilder.getKey().isEmpty()) { + System.out.println("DEBUG [BYPASS]: No key in hint, using default server"); + return serverFactory.defaultServer(); + } + + ByteString requestKey = hintBuilder.getKey(); + ByteString requestLimitKey = hintBuilder.getLimitKey(); + + // Find range containing the key + Map.Entry entry = ranges.higherEntry(requestKey); + + CachedRange targetRange = null; + ByteString targetRangeLimitKey = null; + + if (entry != null) { + ByteString rangeLimit = entry.getKey(); + CachedRange range = entry.getValue(); + + // Check if key is within this range + if (ByteString.unsignedLexicographicalComparator().compare(requestKey, range.startKey) >= 0) { + targetRange = range; + targetRangeLimitKey = rangeLimit; + System.out.println( + "DEBUG [BYPASS]: Found range for key, group_uid: " + + (range.group != null ? range.group.groupUid : "null")); + } + } + + if (targetRange == null) { + System.out.println("DEBUG [BYPASS]: No range found for key, using default server"); + return serverFactory.defaultServer(); + } + + // For point reads (empty limit_key), check if key is in the split + // For range reads, check if the whole range is covered + if (!requestLimitKey.isEmpty()) { + // Range read - check if limit is within the split + if (ByteString.unsignedLexicographicalComparator() + .compare(requestLimitKey, targetRangeLimitKey) + > 0) { + // Range extends beyond this split + System.out.println("DEBUG [BYPASS]: Range extends beyond split, using default server"); + return serverFactory.defaultServer(); + } + } + + if (targetRange.group == null) { + System.out.println("DEBUG [BYPASS]: Range has no group, using default server"); + return serverFactory.defaultServer(); + } + + // Fill in routing hint with range/group/split info + hintBuilder.setGroupUid(targetRange.group.groupUid); + hintBuilder.setSplitId(targetRange.splitId); + hintBuilder.setKey(targetRange.startKey); + hintBuilder.setLimitKey(targetRangeLimitKey); + + System.out.println( + "DEBUG [BYPASS]: Group " + + targetRange.group.groupUid + + " has " + + targetRange.group.tablets.size() + + " tablets" + + ", hasLeader: " + + targetRange.group.hasLeader() + + ", leaderIndex: " + + targetRange.group.leaderIndex); + + // Let the group pick the tablet + ChannelFinderServer server = targetRange.group.fillRoutingHint(preferLeader, hintBuilder); + if (server != null) { + System.out.println("DEBUG [BYPASS]: Group returned server: " + server.getAddress()); + return server; + } + + System.out.println("DEBUG [BYPASS]: Group returned no server, using default"); + return serverFactory.defaultServer(); + } + + public void clear() { + for (CachedRange range : ranges.values()) { + if (range.group != null) { + unref(range.group); + } + } + ranges.clear(); + groups.clear(); + servers.clear(); + } + + public String debugString() { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : ranges.entrySet()) { + CachedRange cachedRange = entry.getValue(); + sb.append("Range[") + .append(cachedRange.startKey.toStringUtf8()) + .append("-") + .append(entry.getKey().toStringUtf8()) + .append("]: "); + sb.append(cachedRange.debugString()).append("\n"); + } + for (CachedGroup g : groups.values()) { + sb.append(g.debugString()).append("\n"); + } + for (ServerEntry s : servers.values()) { + sb.append(s.debugString()).append("\n"); + } + return sb.toString(); + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java new file mode 100644 index 0000000000..912a39c703 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java @@ -0,0 +1,814 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.protobuf.ByteString; +import com.google.protobuf.ListValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import com.google.spanner.v1.KeyRange; +import com.google.spanner.v1.KeySet; +import com.google.spanner.v1.Mutation; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class KeyRecipe { + + // kInfinity is "\xff" - the largest single byte, used as a sentinel for ranges + private static final ByteString K_INFINITY = ByteString.copyFrom(new byte[] {(byte) 0xFF}); + + private enum Kind { + TAG, + VALUE + } + + private enum KeyType { + FULL_KEY, + PREFIX, + PREFIX_SUCCESSOR, + INDEX_KEY + } + + private static final class Part { + private final Kind kind; + private final int tag; // if kind == TAG + private final com.google.spanner.v1.Type type; // if kind == VALUE + private final com.google.spanner.v1.KeyRecipe.Part.Order order; // if kind == VALUE + private final com.google.spanner.v1.KeyRecipe.Part.NullOrder nullOrder; // if kind == VALUE + private final String identifier; // if kind == VALUE + private final boolean random; // if kind == VALUE and random: true + + private Part( + Kind kind, + int tag, + com.google.spanner.v1.Type type, + com.google.spanner.v1.KeyRecipe.Part.Order order, + com.google.spanner.v1.KeyRecipe.Part.NullOrder nullOrder, + String identifier, + boolean random) { + this.kind = kind; + this.tag = tag; + this.type = type; + this.order = order; + this.nullOrder = nullOrder; + this.identifier = identifier; + this.random = random; + } + + static Part fromProto(com.google.spanner.v1.KeyRecipe.Part partProto) { + if (partProto.getTag() > 0) { + return new Part(Kind.TAG, partProto.getTag(), null, null, null, null, false); + } else { + if (!partProto.hasType()) { + throw new IllegalArgumentException( + "KeyRecipe.Part representing a value must have a type."); + } + if (partProto.getOrder() != com.google.spanner.v1.KeyRecipe.Part.Order.ASCENDING + && partProto.getOrder() != com.google.spanner.v1.KeyRecipe.Part.Order.DESCENDING) { + throw new IllegalArgumentException( + "KeyRecipe.Part order must be ASCENDING or DESCENDING."); + } + if (partProto.getNullOrder() != com.google.spanner.v1.KeyRecipe.Part.NullOrder.NULLS_FIRST + && partProto.getNullOrder() != com.google.spanner.v1.KeyRecipe.Part.NullOrder.NULLS_LAST + && partProto.getNullOrder() + != com.google.spanner.v1.KeyRecipe.Part.NullOrder.NOT_NULL) { + throw new IllegalArgumentException( + "KeyRecipe.Part null_order must be NULLS_FIRST or NULLS_LAST."); + } + String identifier = partProto.getIdentifier(); + boolean isRandom = partProto.hasRandom(); + return new Part( + Kind.VALUE, + 0, // tag is not used for VALUE kind in this simplified constructor + partProto.getType(), + partProto.getOrder(), + partProto.getNullOrder(), + identifier, + isRandom); + } + } + } + + // For random value encoding - use seed 12345 for deterministic testing + private static final java.util.Random testRandom = new java.util.Random(12345); + + private static void encodeRandomValuePart(Part part, ByteArrayOutputStream out) { + // Generate a random non-negative long (similar to absl::Uniform(bitgen_, 0, max)) + long value = testRandom.nextLong() & Long.MAX_VALUE; + boolean ascending = part.order == com.google.spanner.v1.KeyRecipe.Part.Order.ASCENDING; + if (ascending) { + SsFormat.appendIntIncreasing(out, value); + } else { + SsFormat.appendIntDecreasing(out, value); + } + } + + private final List parts; + private final int numValueParts; + private final boolean isIndex; + + private KeyRecipe(List parts, int numValueParts, boolean isIndex) { + this.parts = parts; + this.numValueParts = numValueParts; + this.isIndex = isIndex; + } + + public static KeyRecipe create(com.google.spanner.v1.KeyRecipe in) { + List partsList = new ArrayList<>(); + int valuePartsCount = 0; + boolean isIndex = in.hasIndexName(); + for (com.google.spanner.v1.KeyRecipe.Part partProto : in.getPartList()) { + Part part = Part.fromProto(partProto); + partsList.add(part); + if (part.kind == Kind.VALUE) { + valuePartsCount++; + } + } + if (partsList.isEmpty()) { + throw new IllegalArgumentException("KeyRecipe must have at least one part."); + } + return new KeyRecipe(partsList, valuePartsCount, isIndex); + } + + private static void encodeNull(Part part, ByteArrayOutputStream out) { + switch (part.nullOrder) { + case NULLS_FIRST: + SsFormat.appendNullOrderedFirst(out); + break; + case NULLS_LAST: + SsFormat.appendNullOrderedLast(out); + break; + case NOT_NULL: + throw new IllegalArgumentException("Key part cannot be NULL"); + default: + throw new IllegalArgumentException("Unknown null order: " + part.nullOrder); + } + } + + private static void encodeNotNull(Part part, ByteArrayOutputStream out) { + switch (part.nullOrder) { + case NULLS_FIRST: + SsFormat.appendNotNullMarkerNullOrderedFirst(out); + break; + case NULLS_LAST: + SsFormat.appendNotNullMarkerNullOrderedLast(out); + break; + case NOT_NULL: + // No marker needed for NOT_NULL + break; + default: + throw new IllegalArgumentException("Unknown null order: " + part.nullOrder); + } + } + + private static void encodeSingleValuePart(Part part, Value value, ByteArrayOutputStream out) { + if (value.getKindCase() == Value.KindCase.NULL_VALUE) { + encodeNull(part, out); + return; + } + + // Validate type compatibility BEFORE encoding anything + validateValueType(part, value); + + // Now safe to encode the NOT_NULL marker + encodeNotNull(part, out); + + boolean isAscending = (part.order == com.google.spanner.v1.KeyRecipe.Part.Order.ASCENDING); + + switch (part.type.getCode()) { + case BOOL: + if (isAscending) { + SsFormat.appendUnsignedIntIncreasing(out, value.getBoolValue() ? 1 : 0); + } else { + SsFormat.appendUnsignedIntDecreasing(out, value.getBoolValue() ? 1 : 0); + } + break; + case INT64: + long intVal = Long.parseLong(value.getStringValue()); + if (isAscending) { + SsFormat.appendIntIncreasing(out, intVal); + } else { + SsFormat.appendIntDecreasing(out, intVal); + } + break; + case FLOAT64: + if (value.getKindCase() == Value.KindCase.STRING_VALUE) { + // Handle special float values like Infinity, -Infinity, NaN + String strVal = value.getStringValue(); + double dblVal; + if ("Infinity".equals(strVal)) { + dblVal = Double.POSITIVE_INFINITY; + } else if ("-Infinity".equals(strVal)) { + dblVal = Double.NEGATIVE_INFINITY; + } else if ("NaN".equals(strVal)) { + dblVal = Double.NaN; + } else { + throw new IllegalArgumentException("Invalid FLOAT64 string: " + strVal); + } + if (isAscending) { + SsFormat.appendDoubleIncreasing(out, dblVal); + } else { + SsFormat.appendDoubleDecreasing(out, dblVal); + } + } else { + if (isAscending) { + SsFormat.appendDoubleIncreasing(out, value.getNumberValue()); + } else { + SsFormat.appendDoubleDecreasing(out, value.getNumberValue()); + } + } + break; + case STRING: + if (isAscending) { + SsFormat.appendStringIncreasing(out, value.getStringValue()); + } else { + SsFormat.appendStringDecreasing(out, value.getStringValue()); + } + break; + case BYTES: + byte[] bytesDecoded = Base64.getDecoder().decode(value.getStringValue()); + if (isAscending) { + SsFormat.appendBytesIncreasing(out, bytesDecoded); + } else { + SsFormat.appendBytesDecreasing(out, bytesDecoded); + } + break; + case TIMESTAMP: + { + String tsStr = value.getStringValue(); + long[] parsed = parseTimestamp(tsStr); + byte[] encoded = SsFormat.encodeTimestamp(parsed[0], (int) parsed[1]); + if (isAscending) { + SsFormat.appendBytesIncreasing(out, encoded); + } else { + SsFormat.appendBytesDecreasing(out, encoded); + } + } + break; + case DATE: + { + String dateStr = value.getStringValue(); + int daysSinceEpoch = parseDate(dateStr); + if (isAscending) { + SsFormat.appendIntIncreasing(out, daysSinceEpoch); + } else { + SsFormat.appendIntDecreasing(out, daysSinceEpoch); + } + } + break; + case UUID: + { + String uuidStr = value.getStringValue(); + long[] parsed = parseUuid(uuidStr); + byte[] encoded = SsFormat.encodeUuid(parsed[0], parsed[1]); + if (isAscending) { + SsFormat.appendBytesIncreasing(out, encoded); + } else { + SsFormat.appendBytesDecreasing(out, encoded); + } + } + break; + case ENUM: + // ENUM values are sent as string representation of the enum number + long enumVal = Long.parseLong(value.getStringValue()); + if (isAscending) { + SsFormat.appendIntIncreasing(out, enumVal); + } else { + SsFormat.appendIntDecreasing(out, enumVal); + } + break; + case NUMERIC: + case TYPE_CODE_UNSPECIFIED: + case ARRAY: + case STRUCT: + case PROTO: + case UNRECOGNIZED: + default: + throw new IllegalArgumentException( + "Unsupported type code for ssformat encoding: " + part.type.getCode()); + } + } + + private static void validateValueType(Part part, Value value) { + switch (part.type.getCode()) { + case BOOL: + if (value.getKindCase() != Value.KindCase.BOOL_VALUE) { + throw new IllegalArgumentException("Type mismatch for BOOL."); + } + break; + case INT64: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for INT64, expecting decimal string."); + } + // Also validate it's a valid integer + try { + Long.parseLong(value.getStringValue()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid INT64 string: " + value.getStringValue(), e); + } + break; + case FLOAT64: + if (value.getKindCase() != Value.KindCase.NUMBER_VALUE + && value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for FLOAT64."); + } + if (value.getKindCase() == Value.KindCase.STRING_VALUE) { + String strVal = value.getStringValue(); + if (!"Infinity".equals(strVal) && !"-Infinity".equals(strVal) && !"NaN".equals(strVal)) { + throw new IllegalArgumentException("Invalid FLOAT64 string: " + strVal); + } + } + break; + case STRING: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for STRING."); + } + break; + case BYTES: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for BYTES, expecting base64 string."); + } + // Validate base64 + try { + Base64.getDecoder().decode(value.getStringValue()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid base64 for BYTES type.", e); + } + break; + case TIMESTAMP: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for TIMESTAMP."); + } + // Validate timestamp format: must end with Z (UTC) and be RFC3339 + validateTimestamp(value.getStringValue()); + break; + case DATE: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for DATE."); + } + // Validate date format: YYYY-MM-DD, exactly 10 chars + validateDate(value.getStringValue()); + break; + case UUID: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for UUID."); + } + // Validate UUID format + validateUuid(value.getStringValue()); + break; + case ENUM: + if (value.getKindCase() != Value.KindCase.STRING_VALUE) { + throw new IllegalArgumentException("Type mismatch for ENUM, expecting string."); + } + // Validate it's a valid integer string + try { + Long.parseLong(value.getStringValue()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid ENUM string (expecting number): " + value.getStringValue(), e); + } + break; + case NUMERIC: + case TYPE_CODE_UNSPECIFIED: + case ARRAY: + case STRUCT: + case PROTO: + case UNRECOGNIZED: + default: + throw new IllegalArgumentException( + "Unsupported type code for ssformat encoding: " + part.type.getCode()); + } + } + + // RFC3339 timestamp pattern: YYYY-MM-DDTHH:MM:SS[.nnnnnnnnn]Z + // Allow any number of decimal places (will be truncated to 9) + private static final Pattern TIMESTAMP_PATTERN = + Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?Z$"); + + private static void validateTimestamp(String ts) { + if (!ts.endsWith("Z")) { + throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts); + } + Matcher m = TIMESTAMP_PATTERN.matcher(ts); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts); + } + // Validate ranges + int year = Integer.parseInt(m.group(1)); + int month = Integer.parseInt(m.group(2)); + int day = Integer.parseInt(m.group(3)); + int hour = Integer.parseInt(m.group(4)); + int minute = Integer.parseInt(m.group(5)); + int second = Integer.parseInt(m.group(6)); + if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) { + throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts); + } + // Year must be 0000-9999 (year 0 is allowed) + if (year < 0 || year > 9999) { + throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts); + } + } + + private static long[] parseTimestamp(String ts) { + // Parse RFC3339 timestamp using Java time library + // Remove trailing Z and parse + String withoutZ = ts.substring(0, ts.length() - 1); + + // Parse date-time parts + int dotIdx = withoutZ.indexOf('.'); + String dateTimePart; + int nanos = 0; + if (dotIdx >= 0) { + dateTimePart = withoutZ.substring(0, dotIdx); + String fracStr = withoutZ.substring(dotIdx + 1); + // Pad to 9 digits + while (fracStr.length() < 9) { + fracStr = fracStr + "0"; + } + // Truncate to 9 digits + if (fracStr.length() > 9) { + fracStr = fracStr.substring(0, 9); + } + nanos = Integer.parseInt(fracStr); + } else { + dateTimePart = withoutZ; + } + + // Parse date and time components + // Format: YYYY-MM-DDTHH:MM:SS + String[] dateTime = dateTimePart.split("T"); + String[] dateParts = dateTime[0].split("-"); + String[] timeParts = dateTime[1].split(":"); + + int year = Integer.parseInt(dateParts[0]); + int month = Integer.parseInt(dateParts[1]); + int day = Integer.parseInt(dateParts[2]); + int hour = Integer.parseInt(timeParts[0]); + int minute = Integer.parseInt(timeParts[1]); + int second = Integer.parseInt(timeParts[2]); + + // Compute days since epoch using proleptic Gregorian calendar + long days = civilDayNumber(year, month, day); + long seconds = days * 86400L + hour * 3600L + minute * 60L + second; + + return new long[] {seconds, nanos}; + } + + // Compute the civil day number (days since Unix epoch 1970-01-01) + // This matches absl::CivilDay calculation + private static long civilDayNumber(int year, int month, int day) { + // Algorithm from http://howardhinnant.github.io/date_algorithms.html + // This produces the same results as absl::CivilDay + int y = year; + int m = month; + int d = day; + + // Adjust year and month (March = month 1 in this algorithm) + if (m <= 2) { + y -= 1; + m += 12; + } + m -= 3; + + // Days from era 0 (year 0 March 1) to given date + int era = (y >= 0 ? y : y - 399) / 400; + int yoe = y - era * 400; // year of era [0, 399] + int doy = (153 * m + 2) / 5 + d - 1; // day of year [0, 365] + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era [0, 146096] + long dayNumber = + (long) era * 146097 + doe - 719468; // shift epoch from 0000-03-01 to 1970-01-01 + + return dayNumber; + } + + private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$"); + + private static void validateDate(String dateStr) { + if (dateStr.length() != 10) { + throw new IllegalArgumentException("Invalid DATE string: " + dateStr); + } + Matcher m = DATE_PATTERN.matcher(dateStr); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid DATE string: " + dateStr); + } + int year = Integer.parseInt(m.group(1)); + int month = Integer.parseInt(m.group(2)); + int day = Integer.parseInt(m.group(3)); + if (month < 1 || month > 12 || day < 1 || day > 31) { + throw new IllegalArgumentException("Invalid DATE string: " + dateStr); + } + // Year can be 0000-9999 for DATE + if (year < 0 || year > 9999) { + throw new IllegalArgumentException("Invalid DATE string: " + dateStr); + } + } + + private static int parseDate(String dateStr) { + Matcher m = DATE_PATTERN.matcher(dateStr); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid DATE string: " + dateStr); + } + int year = Integer.parseInt(m.group(1)); + int month = Integer.parseInt(m.group(2)); + int day = Integer.parseInt(m.group(3)); + return (int) civilDayNumber(year, month, day); + } + + private static void validateUuid(String uuid) { + long[] result = parseUuid(uuid); + // parseUuid throws if invalid + } + + private static final int K_UUID_LENGTH = 36; + + private static long[] parseUuid(String uuid) { + String originalUuid = uuid; + + // Handle optional braces + if (uuid.startsWith("{")) { + if (!uuid.endsWith("}")) { + throw new IllegalArgumentException("Invalid UUID string: " + originalUuid); + } + uuid = uuid.substring(1, uuid.length() - 1); + } + + // Minimum 36 characters required (standard UUID format: 8-4-4-4-12) + if (uuid.length() < K_UUID_LENGTH) { + throw new IllegalArgumentException("Invalid UUID string: " + originalUuid); + } + + // Check for leading hyphen + if (uuid.startsWith("-")) { + throw new IllegalArgumentException("Invalid UUID string: " + originalUuid); + } + + // Parse 32 hex digits (ignoring hyphens in between) + long high = 0; + long low = 0; + int hexCount = 0; + + for (int i = 0; i < uuid.length(); i++) { + char c = uuid.charAt(i); + if (c == '-') { + continue; // Skip hyphens + } + int digit = hexDigit(c); + if (digit < 0) { + throw new IllegalArgumentException("Invalid UUID string: " + originalUuid); + } + if (hexCount < 16) { + high = (high << 4) | digit; + } else { + low = (low << 4) | digit; + } + hexCount++; + } + + if (hexCount != 32) { + throw new IllegalArgumentException("Invalid UUID string: " + originalUuid); + } + + // After parsing, verify there are no trailing characters + // (uuid must be exactly consumed) + if (uuid.length() > K_UUID_LENGTH) { + throw new IllegalArgumentException("Invalid UUID string: " + originalUuid); + } + + return new long[] {high, low}; + } + + private static int hexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; + } + + private TargetRange encodeKeyInternal( + BiFunction valueFinder, KeyType keyType) { + ByteArrayOutputStream ssKey = new ByteArrayOutputStream(); + int valueIdx = 0; + boolean ok = true; + int p = 0; + for (; p < parts.size(); ++p) { + final Part part = parts.get(p); + if (part.kind == Kind.TAG) { + SsFormat.appendCompositeTag(ssKey, part.tag); + } else if (part.kind == Kind.VALUE) { + // Handle random value parts + if (part.random) { + encodeRandomValuePart(part, ssKey); + continue; + } + + String identifier = part.identifier.isEmpty() ? "" : part.identifier; + final Value value = valueFinder.apply(valueIdx++, identifier); + if (value == null) { + ok = false; + break; + } + try { + encodeSingleValuePart(part, value, ssKey); + } catch (IllegalArgumentException e) { + ok = false; + break; + } + } else { + ok = false; + break; + } + } + + ByteString start = ByteString.copyFrom(ssKey.toByteArray()); + ByteString limit = ByteString.EMPTY; + boolean approximate = false; + + if (p == parts.size() || (keyType != KeyType.FULL_KEY && !ok)) { + if (keyType == KeyType.PREFIX_SUCCESSOR) { + start = SsFormat.makePrefixSuccessor(start); + } else if (keyType == KeyType.INDEX_KEY) { + limit = SsFormat.makePrefixSuccessor(start); + } + } else { + approximate = true; + limit = SsFormat.makePrefixSuccessor(start); + } + return new TargetRange(start, limit, approximate); + } + + public TargetRange keyToTargetRange(ListValue in) { + return encodeKeyInternal( + (index, identifier) -> { + if (index < 0 || index >= in.getValuesCount()) { + return null; + } + return in.getValues(index); + }, + isIndex ? KeyType.INDEX_KEY : KeyType.FULL_KEY); + } + + public TargetRange keyRangeToTargetRange(KeyRange in) { + TargetRange start; + switch (in.getStartKeyTypeCase()) { + case START_CLOSED: + start = + encodeKeyInternal( + (index, id) -> { + if (index < 0 || index >= in.getStartClosed().getValuesCount()) return null; + return in.getStartClosed().getValues(index); + }, + KeyType.PREFIX); + break; + case START_OPEN: + start = + encodeKeyInternal( + (index, id) -> { + if (index < 0 || index >= in.getStartOpen().getValuesCount()) return null; + return in.getStartOpen().getValues(index); + }, + KeyType.PREFIX_SUCCESSOR); + break; + default: + start = new TargetRange(ByteString.EMPTY, ByteString.EMPTY, true); + break; + } + + TargetRange limit; + switch (in.getEndKeyTypeCase()) { + case END_CLOSED: + limit = + encodeKeyInternal( + (index, id) -> { + if (index < 0 || index >= in.getEndClosed().getValuesCount()) return null; + return in.getEndClosed().getValues(index); + }, + KeyType.PREFIX_SUCCESSOR); + break; + case END_OPEN: + limit = + encodeKeyInternal( + (index, id) -> { + if (index < 0 || index >= in.getEndOpen().getValuesCount()) return null; + return in.getEndOpen().getValues(index); + }, + KeyType.PREFIX); + break; + default: + limit = new TargetRange(K_INFINITY, ByteString.EMPTY, true); + break; + } + return new TargetRange(start.start, limit.start, start.approximate || limit.approximate); + } + + public TargetRange keySetToTargetRange(KeySet in) { + if (in.getAll()) { + return keyRangeToTargetRange( + KeyRange.newBuilder() + .setStartClosed(ListValue.getDefaultInstance()) + .setEndClosed(ListValue.getDefaultInstance()) + .build()); + } + if (in.getRangesCount() == 0) { + if (in.getKeysCount() == 0) { + return new TargetRange(ByteString.EMPTY, K_INFINITY, true); + } else if (in.getKeysCount() == 1) { + return keyToTargetRange(in.getKeys(0)); + } + } + + TargetRange target = new TargetRange(K_INFINITY, ByteString.EMPTY, false); + for (ListValue key : in.getKeysList()) { + target.mergeFrom(keyToTargetRange(key)); + } + for (KeyRange range : in.getRangesList()) { + target.mergeFrom(keyRangeToTargetRange(range)); + } + return target; + } + + public TargetRange queryParamsToTargetRange(Struct in) { + return encodeKeyInternal( + (index, identifier) -> { + if (!in.getFieldsMap().containsKey(identifier)) { + return null; + } + return in.getFieldsMap().get(identifier); + }, + KeyType.FULL_KEY); + } + + public TargetRange mutationToTargetRange(Mutation in) { + TargetRange target = new TargetRange(K_INFINITY, ByteString.EMPTY, false); + + switch (in.getOperationCase()) { + case INSERT: + case UPDATE: + case INSERT_OR_UPDATE: + case REPLACE: + final Mutation.Write write = getWrite(in); + for (ListValue values : write.getValuesList()) { + target.mergeFrom( + encodeKeyInternal( + (index, id) -> { + int colIndex = write.getColumnsList().indexOf(id); + if (colIndex == -1 || colIndex >= values.getValuesCount()) { + return null; + } + return values.getValues(colIndex); + }, + KeyType.FULL_KEY)); + } + break; + case DELETE: + target.mergeFrom(keySetToTargetRange(in.getDelete().getKeySet())); + break; + case SEND: + target.mergeFrom(keyToTargetRange(in.getSend().getKey())); + break; + case ACK: + target.mergeFrom(keyToTargetRange(in.getAck().getKey())); + break; + default: + break; + } + + if (target.start.equals(K_INFINITY)) { + target = new TargetRange(ByteString.EMPTY, K_INFINITY, true); + } + return target; + } + + private Mutation.Write getWrite(Mutation in) { + switch (in.getOperationCase()) { + case INSERT: + return in.getInsert(); + case UPDATE: + return in.getUpdate(); + case INSERT_OR_UPDATE: + return in.getInsertOrUpdate(); + case REPLACE: + return in.getReplace(); + default: + throw new IllegalArgumentException("Mutation is not a write operation"); + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java new file mode 100644 index 0000000000..b0b5c836ac --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java @@ -0,0 +1,203 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.ByteString; +import com.google.spanner.v1.ReadRequest; +import com.google.spanner.v1.RecipeList; +import com.google.spanner.v1.RoutingHint; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public final class KeyRecipeCache { + + // TODO: Implement robust fingerprinting algorithm like Fingerprint2011. + private static long fingerprint(ReadRequest req) { + long result = Objects.hash(req.getTable()); + result = 31 * result + Objects.hash(PreparedRead.getKind(req)); + for (String column : req.getColumnsList()) { + result = 31 * result + column.hashCode(); + } + return result; + } + + private final AtomicLong nextQueryUid = new AtomicLong(1); + private ByteString schemaGeneration = ByteString.EMPTY; + + // query_recipes_ are not used for ReadRequest handling, so omitted for now. + // private final Map queryRecipes = new ConcurrentHashMap<>(); + private final Map schemaRecipes = new ConcurrentHashMap<>(); + private final Map preparedReads = new ConcurrentHashMap<>(); + + // For simplicity, miss reasons are not explicitly tracked with status in this version. + // enum MissReason { FINGERPRINT_COLLISION, SCHEMA_RECIPE_NOT_FOUND, FAILED_KEY_ENCODING, + // INELIGIBLE_READ } + + public KeyRecipeCache() {} + + public synchronized void addRecipes(RecipeList recipeList) { + int cmp = + ByteString.unsignedLexicographicalComparator() + .compare(recipeList.getSchemaGeneration(), schemaGeneration); + if (cmp < 0) { + return; + } + if (cmp > 0) { + schemaGeneration = recipeList.getSchemaGeneration(); + // queryRecipes.clear(); // Not used for ReadRequest + schemaRecipes.clear(); + } + + for (com.google.spanner.v1.KeyRecipe recipeProto : recipeList.getRecipeList()) { + try { + KeyRecipe recipe = KeyRecipe.create(recipeProto); + if (recipeProto.hasTableName()) { + schemaRecipes.put(recipeProto.getTableName(), recipe); + } else if (recipeProto.hasIndexName()) { + schemaRecipes.put(recipeProto.getIndexName(), recipe); + } else if (recipeProto.hasOperationUid()) { + // Not handling query_uid recipes for ReadRequest + } + } catch (IllegalArgumentException e) { + // Log or handle failed recipe creation + System.err.println("Failed to add recipe: " + recipeProto + ", error: " + e.getMessage()); + } + } + } + + public void computeKeys(ReadRequest.Builder reqBuilder) { + long reqFp = fingerprint(reqBuilder.buildPartial()); // Partial build OK for fingerprinting + + RoutingHint.Builder hintBuilder = reqBuilder.getRoutingHintBuilder(); + if (!schemaGeneration.isEmpty()) { + hintBuilder.setSchemaGeneration(schemaGeneration); + } + + PreparedRead preparedRead = preparedReads.get(reqFp); + if (preparedRead == null) { + preparedRead = PreparedRead.fromRequest(reqBuilder.buildPartial()); + preparedRead.queryUid = nextQueryUid.getAndIncrement(); + preparedReads.put(reqFp, preparedRead); + } else if (!preparedRead.matches(reqBuilder.buildPartial())) { + // recordMiss(MissReason.FINGERPRINT_COLLISION); + System.err.println("Fingerprint collision for ReadRequest: " + reqFp); + return; + } + + hintBuilder.setOperationUid(preparedRead.queryUid); + String recipeKey = reqBuilder.getTable(); + if (!reqBuilder.getIndex().isEmpty()) { + recipeKey = reqBuilder.getIndex(); + } + + KeyRecipe recipe = schemaRecipes.get(recipeKey); + if (recipe == null) { + // recordMiss(MissReason.SCHEMA_RECIPE_NOT_FOUND); + System.err.println("Schema recipe not found for: " + recipeKey); + return; + } + + try { + switch (preparedRead.kind) { + case POINT: + if (reqBuilder.getKeySet().getKeysCount() == 0) { + System.err.println("POINT read has no keys in KeySet."); + return; + } + TargetRange pointTarget = recipe.keyToTargetRange(reqBuilder.getKeySet().getKeys(0)); + hintBuilder.setKey(pointTarget.start); + break; + case RANGE: + case RANGE_WITH_LIMIT: + if (reqBuilder.getKeySet().getRangesCount() == 0) { + System.err.println("RANGE read has no ranges in KeySet."); + return; + } + TargetRange rangeTarget = + recipe.keyRangeToTargetRange(reqBuilder.getKeySet().getRanges(0)); + hintBuilder.setKey(rangeTarget.start); + hintBuilder.setLimitKey(rangeTarget.limit); + break; + case INELIGIBLE: + // recordMiss(MissReason.INELIGIBLE_READ); + System.err.println("Ineligible read request for key computation."); + return; + } + } catch (IllegalArgumentException e) { + // recordMiss(MissReason.FAILED_KEY_ENCODING, e.getMessage()); + System.err.println("Failed key encoding: " + e.getMessage()); + } + } + + public synchronized void clear() { + schemaGeneration = ByteString.EMPTY; + preparedReads.clear(); + // queryRecipes.clear(); // Not used for ReadRequest + schemaRecipes.clear(); + } + + private static class PreparedRead { + final String table; + final ImmutableList columns; + final Kind kind; + long queryUid; // Not final, assigned after construction + + enum Kind { + POINT, + RANGE, + RANGE_WITH_LIMIT, + INELIGIBLE + } + + private PreparedRead(String table, List columns, Kind kind) { + this.table = table; + this.columns = ImmutableList.copyOf(columns); + this.kind = kind; + } + + static Kind getKind(ReadRequest req) { + if (req.getKeySet().getAll()) { + return Kind.INELIGIBLE; + } + if (req.getKeySet().getKeysCount() == 1 && req.getKeySet().getRangesCount() == 0) { + return Kind.POINT; + } + if (req.getKeySet().getKeysCount() == 0 && req.getKeySet().getRangesCount() == 1) { + return req.getLimit() > 0 ? Kind.RANGE_WITH_LIMIT : Kind.RANGE; + } + return Kind.INELIGIBLE; + } + + static PreparedRead fromRequest(ReadRequest req) { + return new PreparedRead(req.getTable(), req.getColumnsList(), getKind(req)); + } + + boolean matches(ReadRequest req) { + if (!Objects.equals(table, req.getTable())) { + return false; + } + if (!columns.equals(req.getColumnsList())) { + return false; + } + return kind == getKind(req); + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java new file mode 100644 index 0000000000..67ed2b3e39 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java @@ -0,0 +1,374 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.protobuf.ByteString; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +public final class SsFormat { + + /** + * Makes the given key a prefix successor. This means that the returned key is the smallest + * possible key that is larger than the input key, and that does not have the input key as a + * prefix. + * + *

This is done by flipping the least significant bit of the last byte of the key. + * + * @param key The key to make a prefix successor. + * @return The prefix successor key. + */ + public static ByteString makePrefixSuccessor(ByteString key) { + if (key == null || key.isEmpty()) { + return ByteString.EMPTY; + } + byte[] bytes = key.toByteArray(); + if (bytes.length > 0) { + bytes[bytes.length - 1] = (byte) (bytes[bytes.length - 1] | 1); + } + return ByteString.copyFrom(bytes); + } + + private SsFormat() {} + + // Constants from ssformat.cc + private static final int IS_KEY = 0x80; + private static final int TYPE_MASK = 0x7f; + + // HeaderType enum values (selected) + private static final int TYPE_UINT_1 = 0; + private static final int TYPE_UINT_9 = 8; + private static final int TYPE_NEG_INT_8 = 9; + private static final int TYPE_NEG_INT_1 = 16; + private static final int TYPE_POS_INT_1 = 17; + private static final int TYPE_POS_INT_8 = 24; + private static final int TYPE_STRING = 25; + private static final int TYPE_NULL_ORDERED_FIRST = 27; + private static final int TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_FIRST = 28; + private static final int TYPE_DECREASING_UINT_9 = 32; + private static final int TYPE_DECREASING_UINT_1 = 40; + private static final int TYPE_DECREASING_NEG_INT_8 = 41; + private static final int TYPE_DECREASING_NEG_INT_1 = 48; + private static final int TYPE_DECREASING_POS_INT_1 = 49; + private static final int TYPE_DECREASING_POS_INT_8 = 56; + private static final int TYPE_DECREASING_STRING = 57; + private static final int TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_LAST = 59; + private static final int TYPE_NULL_ORDERED_LAST = 60; + private static final int TYPE_NEG_DOUBLE_8 = 66; + private static final int TYPE_NEG_DOUBLE_1 = 73; + private static final int TYPE_POS_DOUBLE_1 = 74; + private static final int TYPE_POS_DOUBLE_8 = 81; + private static final int TYPE_DECREASING_NEG_DOUBLE_8 = 82; + private static final int TYPE_DECREASING_NEG_DOUBLE_1 = 89; + private static final int TYPE_DECREASING_POS_DOUBLE_1 = 90; + private static final int TYPE_DECREASING_POS_DOUBLE_8 = 97; + + // EscapeChar enum values + private static final byte ASCENDING_ZERO_ESCAPE = (byte) 0xf0; + private static final byte ASCENDING_FF_ESCAPE = (byte) 0x10; + private static final byte SEP = (byte) 0x78; // 'x' + + // For AppendCompositeTag + private static final int K_OBJECT_EXISTENCE_TAG = 0x7e; + private static final int K_MAX_FIELD_TAG = 0xffff; + + public static void appendCompositeTag(ByteArrayOutputStream out, int tag) { + if (tag == K_OBJECT_EXISTENCE_TAG || tag <= 0 || tag > K_MAX_FIELD_TAG) { + throw new IllegalArgumentException("Invalid tag value: " + tag); + } + + if (tag < 16) { + // Short tag: 000 TTTT S (S is LSB of tag, but here tag is original, so S=0) + // Encodes as (tag << 1) + out.write((byte) (tag << 1)); + } else { + // Long tag + int shiftedTag = tag << 1; // LSB is 0 for prefix successor + if (shiftedTag < (1 << (5 + 8))) { // Original tag < 4096 + // Header: num_extra_bytes=1 (01xxxxx), P=payload bits from tag + // (1 << 5) is 00100000 + // (shiftedTag >> 8) are the 5 MSBs of the payload part of the tag + out.write((byte) ((1 << 5) | (shiftedTag >> 8))); + out.write((byte) (shiftedTag & 0xFF)); + } else { // Original tag >= 4096 and <= K_MAX_FIELD_TAG (65535) + // Header: num_extra_bytes=2 (10xxxxx) + // (2 << 5) is 01000000 + out.write((byte) ((2 << 5) | (shiftedTag >> 16))); + out.write((byte) ((shiftedTag >> 8) & 0xFF)); + out.write((byte) (shiftedTag & 0xFF)); + } + } + } + + public static void appendNullOrderedFirst(ByteArrayOutputStream out) { + out.write((byte) (IS_KEY | TYPE_NULL_ORDERED_FIRST)); + out.write((byte) 0); + } + + public static void appendNullOrderedLast(ByteArrayOutputStream out) { + out.write((byte) (IS_KEY | TYPE_NULL_ORDERED_LAST)); + out.write((byte) 0); + } + + public static void appendNotNullMarkerNullOrderedFirst(ByteArrayOutputStream out) { + out.write((byte) (IS_KEY | TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_FIRST)); + } + + public static void appendNotNullMarkerNullOrderedLast(ByteArrayOutputStream out) { + out.write((byte) (IS_KEY | TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_LAST)); + } + + public static void appendUnsignedIntIncreasing(ByteArrayOutputStream out, long val) { + if (val < 0) { + throw new IllegalArgumentException("Unsigned int cannot be negative: " + val); + } + byte[] buf = new byte[9]; // Max 9 bytes for value payload + int len = 0; + + long tempVal = val; + buf[8 - len] = (byte) ((tempVal & 0x7F) << 1); // LSB is prefix-successor bit (0) + tempVal >>= 7; + len++; + + while (tempVal > 0) { + buf[8 - len] = (byte) (tempVal & 0xFF); + tempVal >>= 8; + len++; + } + + out.write((byte) (IS_KEY | (TYPE_UINT_1 + len - 1))); + for (int i = 0; i < len; i++) { + out.write((byte) (buf[8 - len + 1 + i] & 0xFF)); + } + } + + public static void appendUnsignedIntDecreasing(ByteArrayOutputStream out, long val) { + if (val < 0) { + throw new IllegalArgumentException("Unsigned int cannot be negative: " + val); + } + byte[] buf = new byte[9]; + int len = 0; + long tempVal = val; + + // InvertByte(val & 0x7f) << 1 + buf[8 - len] = (byte) ((~(tempVal & 0x7F) & 0x7F) << 1); + tempVal >>= 7; + len++; + + while (tempVal > 0) { + buf[8 - len] = (byte) (~(tempVal & 0xFF)); + tempVal >>= 8; + len++; + } + // If val was 0, loop doesn't run for len > 1. If len is still 1, all bits of tempVal (0) are + // covered. + // If val was large, but remaining tempVal became 0, this is correct. + // If tempVal was 0 initially, buf[8] has (~0 & 0x7f) << 1. len = 1. + // If tempVal was >0 but became 0 after some shifts, buf[8-len] has inverted last byte. + + out.write((byte) (IS_KEY | (TYPE_DECREASING_UINT_1 - len + 1))); + for (int i = 0; i < len; i++) { + out.write((byte) (buf[8 - len + 1 + i] & 0xFF)); + } + } + + private static void appendIntInternal( + ByteArrayOutputStream out, long val, boolean decreasing, boolean isDouble) { + if (decreasing) { + val = ~val; + } + + byte[] buf = new byte[8]; // Max 8 bytes for payload + int len = 0; + long tempVal = val; + + if (tempVal >= 0) { + buf[7 - len] = (byte) ((tempVal & 0x7F) << 1); + tempVal >>= 7; + len++; + while (tempVal > 0) { + buf[7 - len] = (byte) (tempVal & 0xFF); + tempVal >>= 8; + len++; + } + } else { // tempVal < 0 + // For negative numbers, extend sign bit after shifting + buf[7 - len] = (byte) ((tempVal & 0x7F) << 1); + // Simulate sign extension for right shift of negative number + // (x >> 7) | 0xFE00000000000000ULL; (if x has 64 bits) + // In Java, right shift `>>` on negative longs performs sign extension. + tempVal >>= 7; + len++; + while (tempVal != -1L) { // Loop until all remaining bits are 1s (sign extension) + buf[7 - len] = (byte) (tempVal & 0xFF); + tempVal >>= 8; + len++; + if (len > 8) throw new AssertionError("Signed int encoding overflow"); + } + } + + int type; + if (val >= 0) { // Original val before potential bit-negation for decreasing + if (!decreasing) { + type = isDouble ? (TYPE_POS_DOUBLE_1 + len - 1) : (TYPE_POS_INT_1 + len - 1); + } else { + type = + isDouble + ? (TYPE_DECREASING_POS_DOUBLE_1 + len - 1) + : (TYPE_DECREASING_POS_INT_1 + len - 1); + } + } else { + if (!decreasing) { + type = isDouble ? (TYPE_NEG_DOUBLE_1 - len + 1) : (TYPE_NEG_INT_1 - len + 1); + } else { + type = + isDouble + ? (TYPE_DECREASING_NEG_DOUBLE_1 - len + 1) + : (TYPE_DECREASING_NEG_INT_1 - len + 1); + } + } + out.write((byte) (IS_KEY | type)); + for (int i = 0; i < len; i++) { + out.write((byte) (buf[7 - len + 1 + i] & 0xFF)); + } + } + + public static void appendIntIncreasing(ByteArrayOutputStream out, long value) { + appendIntInternal(out, value, false, false); + } + + public static void appendIntDecreasing(ByteArrayOutputStream out, long value) { + appendIntInternal(out, value, true, false); + } + + public static void appendDoubleIncreasing(ByteArrayOutputStream out, double value) { + long enc = Double.doubleToRawLongBits(value); + if (enc < 0) { + enc = + Long.MIN_VALUE + - enc; // kint64min - enc (equivalent to ~enc for negative values due to 2's + // complement) + } + appendIntInternal(out, enc, false, true); + } + + public static void appendDoubleDecreasing(ByteArrayOutputStream out, double value) { + long enc = Double.doubleToRawLongBits(value); + if (enc < 0) { + enc = Long.MIN_VALUE - enc; + } + appendIntInternal(out, enc, true, true); + } + + private static void appendByteSequence( + ByteArrayOutputStream out, byte[] bytes, boolean decreasing) { + out.write((byte) (IS_KEY | (decreasing ? TYPE_DECREASING_STRING : TYPE_STRING))); + + for (byte b : bytes) { + byte currentByte = decreasing ? (byte) ~b : b; + int unsignedByte = currentByte & 0xFF; + if (unsignedByte == 0x00) { + out.write((byte) 0x00); + out.write( + decreasing + ? ASCENDING_ZERO_ESCAPE + : ASCENDING_ZERO_ESCAPE); // After inversion, 0xFF becomes 0x00. Escape for 0x00 + // (inverted) is F0. + // If increasing, 0x00 -> 0x00 F0. + } else if (unsignedByte == 0xFF) { + out.write((byte) 0xFF); + out.write( + decreasing + ? ASCENDING_FF_ESCAPE + : ASCENDING_FF_ESCAPE); // After inversion, 0x00 becomes 0xFF. Escape for 0xFF + // (inverted) is 0x10. + // If increasing, 0xFF -> 0xFF 0x10. + } else { + out.write((byte) unsignedByte); + } + } + // Terminator + out.write((byte) (decreasing ? 0xFF : 0x00)); + out.write(SEP); + } + + public static void appendStringIncreasing(ByteArrayOutputStream out, String value) { + appendByteSequence(out, value.getBytes(StandardCharsets.UTF_8), false); + } + + public static void appendStringDecreasing(ByteArrayOutputStream out, String value) { + appendByteSequence(out, value.getBytes(StandardCharsets.UTF_8), true); + } + + public static void appendBytesIncreasing(ByteArrayOutputStream out, byte[] value) { + appendByteSequence(out, value, false); + } + + public static void appendBytesDecreasing(ByteArrayOutputStream out, byte[] value) { + appendByteSequence(out, value, true); + } + + /** + * Encodes a timestamp as 12 bytes: 8 bytes for seconds since epoch (with offset to handle + * negative), 4 bytes for nanoseconds. + */ + public static byte[] encodeTimestamp(long seconds, int nanos) { + // Add offset to make negative seconds sort correctly + long kSecondsOffset = 1L << 63; + long hi = seconds + kSecondsOffset; + int lo = nanos; + + byte[] buf = new byte[12]; + // Big-endian encoding + buf[0] = (byte) (hi >> 56); + buf[1] = (byte) (hi >> 48); + buf[2] = (byte) (hi >> 40); + buf[3] = (byte) (hi >> 32); + buf[4] = (byte) (hi >> 24); + buf[5] = (byte) (hi >> 16); + buf[6] = (byte) (hi >> 8); + buf[7] = (byte) hi; + buf[8] = (byte) (lo >> 24); + buf[9] = (byte) (lo >> 16); + buf[10] = (byte) (lo >> 8); + buf[11] = (byte) lo; + return buf; + } + + /** Encodes a UUID (128-bit) as 16 bytes in big-endian order. */ + public static byte[] encodeUuid(long high, long low) { + byte[] buf = new byte[16]; + // Big-endian encoding + buf[0] = (byte) (high >> 56); + buf[1] = (byte) (high >> 48); + buf[2] = (byte) (high >> 40); + buf[3] = (byte) (high >> 32); + buf[4] = (byte) (high >> 24); + buf[5] = (byte) (high >> 16); + buf[6] = (byte) (high >> 8); + buf[7] = (byte) high; + buf[8] = (byte) (low >> 56); + buf[9] = (byte) (low >> 48); + buf[10] = (byte) (low >> 40); + buf[11] = (byte) (low >> 32); + buf[12] = (byte) (low >> 24); + buf[13] = (byte) (low >> 16); + buf[14] = (byte) (low >> 8); + buf[15] = (byte) low; + return buf; + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java new file mode 100644 index 0000000000..383cc0f830 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java @@ -0,0 +1,54 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.protobuf.ByteString; + +/** Represents a key range with start and limit boundaries for routing. */ +public class TargetRange { + public ByteString start; + public ByteString limit; + public boolean approximate; + + public TargetRange(ByteString start, ByteString limit, boolean approximate) { + this.start = start; + this.limit = limit; + this.approximate = approximate; + } + + public boolean isPoint() { + return limit.isEmpty(); + } + + /** + * Merges another TargetRange into this one. The resulting range will be the union of the two + * ranges, taking the minimum start key and maximum limit key. + */ + public void mergeFrom(TargetRange other) { + if (ByteString.unsignedLexicographicalComparator().compare(other.start, this.start) < 0) { + this.start = other.start; + } + if (other.isPoint() + && ByteString.unsignedLexicographicalComparator().compare(other.start, this.limit) >= 0) { + this.limit = SsFormat.makePrefixSuccessor(other.start); + } else if (ByteString.unsignedLexicographicalComparator().compare(other.limit, this.limit) + > 0) { + this.limit = other.limit; + } + this.approximate |= other.approximate; + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java new file mode 100644 index 0000000000..0aa56494c6 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java @@ -0,0 +1,137 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner; + +import com.google.cloud.NoCredentials; +import io.grpc.ManagedChannelBuilder; +import java.util.Arrays; +import java.util.List; + +/** + * Simple test to verify bypass point read functionality against a bypass-enabled server. + * + *

Usage: Set the BYPASS_HOST environment variable or modify the DEFAULT_HOST constant, then run + * this test. + * + *

Prerequisites: + * + *

+ */ +public class BypassPointReadTest { + + // Configure these based on your bypass server setup + private static final String DEFAULT_HOST = "http://localhost:8080"; + private static final String INSTANCE_ID = "default"; + private static final String DATABASE_ID = "db"; + private static final String TABLE_NAME = "T"; + private static final String KEY_COLUMN = "Key"; + + public static void main(String[] args) { + String host = System.getenv("BYPASS_HOST"); + if (host == null || host.isEmpty()) { + host = DEFAULT_HOST; + } + + System.out.println("=== Bypass Point Read Test ==="); + System.out.println("Connecting to bypass server: " + host); + + SpannerOptions options = + SpannerOptions.newBuilder() + .setExperimentalHost(host) + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .setCredentials(NoCredentials.getInstance()) + .build(); + + try (Spanner spanner = options.getService()) { + DatabaseClient dbClient = + spanner.getDatabaseClient(DatabaseId.of("default", INSTANCE_ID, DATABASE_ID)); + + System.out.println("\n--- Test 1: Point Read ---"); + testPointRead(dbClient); + + System.out.println("\n--- Test 2: Multiple Point Reads (cache warm-up) ---"); + testMultiplePointReads(dbClient); + + System.out.println("\n=== All tests completed successfully! ==="); + + } catch (Exception e) { + System.err.println("Test failed with exception:"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void testPointRead(DatabaseClient dbClient) { + long testKey = 1L; + List columns = Arrays.asList(KEY_COLUMN, "Value"); + + System.out.println("Reading key: " + testKey); + System.out.println("Columns: " + columns); + + try (ResultSet resultSet = + dbClient.singleUse().read(TABLE_NAME, KeySet.singleKey(Key.of(testKey)), columns)) { + + int rowCount = 0; + while (resultSet.next()) { + rowCount++; + System.out.println(" Row " + rowCount + ": Key=" + resultSet.getLong(KEY_COLUMN)); + if (columns.size() > 1) { + try { + System.out.println(" Value=" + resultSet.getString("Value")); + } catch (Exception e) { + // Column might not exist + } + } + } + System.out.println("Total rows returned: " + rowCount); + + if (rowCount == 0) { + System.out.println("WARNING: No rows returned. Make sure the table has data."); + } + } + } + + private static void testMultiplePointReads(DatabaseClient dbClient) { + List columns = Arrays.asList(KEY_COLUMN); + + // Perform multiple reads to test cache warm-up + // First read: cache miss, server returns CacheUpdate with recipe + // Second read: client computes ssformat key, server returns tablet info + // Third+ reads: should hit fast path + + for (int i = 1; i <= 5; i++) { + long testKey = i; + long startTime = System.nanoTime(); + + try (ResultSet resultSet = + dbClient.singleUse().read(TABLE_NAME, KeySet.singleKey(Key.of(testKey)), columns)) { + int rowCount = 0; + while (resultSet.next()) { + rowCount++; + } + long elapsedUs = (System.nanoTime() - startTime) / 1000; + System.out.println( + "Read #" + i + " (key=" + testKey + "): " + rowCount + " row(s), " + elapsedUs + " us"); + } + } + + System.out.println("\nNote: Subsequent reads should be faster as the location cache warms up."); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java new file mode 100644 index 0000000000..597dfb0507 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java @@ -0,0 +1,411 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import static org.junit.Assert.assertEquals; + +import com.google.protobuf.ByteString; +import com.google.protobuf.ListValue; +import com.google.protobuf.Struct; +import com.google.protobuf.TextFormat; +import com.google.spanner.v1.KeyRange; +import com.google.spanner.v1.KeySet; +import com.google.spanner.v1.Mutation; +import com.google.spanner.v1.RecipeList; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class RecipeGoldenTest { + + @Test + public void goldenTest() throws Exception { + String content; + try (InputStream inputStream = + getClass().getClassLoader().getResourceAsStream("recipe_test.textproto")) { + content = + new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)) + .lines() + .reduce("", (a, b) -> a + "\n" + b); + } + + List testCases = parseTestCases(content); + + for (TestCase testCase : testCases) { + System.out.println("Running test case: " + testCase.name); + + // Skip test cases with invalid recipes that couldn't be parsed + if (testCase.invalidRecipe) { + System.out.println(" Skipped (invalid recipe)"); + continue; + } + + // Skip random tests due to PRNG differences + if (testCase.name.contains("Random")) { + System.out.println(" Skipped (random PRNG mismatch)"); + continue; + } + + KeyRecipe recipe; + try { + recipe = KeyRecipe.create(testCase.recipes.getRecipe(0)); + } catch (IllegalArgumentException e) { + // Invalid recipe - verify all tests expect approximate: true + System.out.println(" Invalid recipe (caught in KeyRecipe.create): " + e.getMessage()); + for (TestInstance test : testCase.tests) { + assertEquals( + "Invalid recipe should result in approximate=true in test case: " + testCase.name, + true, + test.expectedApproximate); + } + continue; + } + + int testNum = 0; + for (TestInstance test : testCase.tests) { + testNum++; + System.out.println(" Test #" + testNum + ": type=" + test.operationType); + System.out.println(" Expected start: " + bytesToHex(test.expectedStart)); + System.out.println(" Expected limit: " + bytesToHex(test.expectedLimit)); + System.out.println(" Expected approx: " + test.expectedApproximate); + + TargetRange target = null; + switch (test.operationType) { + case "key": + System.out.println(" Key: " + test.key); + target = recipe.keyToTargetRange(test.key); + break; + case "key_range": + target = recipe.keyRangeToTargetRange(test.keyRange); + break; + case "key_set": + target = recipe.keySetToTargetRange(test.keySet); + break; + case "mutation": + target = recipe.mutationToTargetRange(test.mutation); + break; + case "query_params": + target = recipe.queryParamsToTargetRange(test.queryParams); + break; + default: + throw new UnsupportedOperationException("Unsupported operation: " + test.operationType); + } + + System.out.println(" Actual start: " + bytesToHex(target.start)); + System.out.println(" Actual limit: " + bytesToHex(target.limit)); + System.out.println(" Actual approx: " + target.approximate); + + assertEquals( + "Start mismatch in test case: " + testCase.name + " test #" + testNum, + test.expectedStart, + target.start); + assertEquals( + "Limit mismatch in test case: " + testCase.name + " test #" + testNum, + test.expectedLimit, + target.limit); + assertEquals( + "Approximate mismatch in test case: " + testCase.name + " test #" + testNum, + test.expectedApproximate, + target.approximate); + } + } + } + + private static class TestCase { + String name; + RecipeList recipes; + List tests = new ArrayList<>(); + boolean invalidRecipe = false; + } + + private static class TestInstance { + String operationType; + ListValue key; + KeyRange keyRange; + KeySet keySet; + Mutation mutation; + Struct queryParams; + ByteString expectedStart = ByteString.EMPTY; + ByteString expectedLimit = ByteString.EMPTY; + boolean expectedApproximate = false; + } + + private List parseTestCases(String content) throws Exception { + List testCases = new ArrayList<>(); + int pos = 0; + + while (pos < content.length()) { + int testCaseStart = content.indexOf("test_case {", pos); + if (testCaseStart == -1) break; + + int testCaseEnd = findMatchingBrace(content, testCaseStart + 10); + String testCaseContent = content.substring(testCaseStart + 11, testCaseEnd); + + TestCase tc = parseTestCase(testCaseContent); + testCases.add(tc); + + pos = testCaseEnd + 1; + } + + return testCases; + } + + private TestCase parseTestCase(String content) throws Exception { + TestCase tc = new TestCase(); + + // Parse name + Pattern namePattern = Pattern.compile("name:\\s*\"([^\"]+)\""); + Matcher nameMatcher = namePattern.matcher(content); + if (nameMatcher.find()) { + tc.name = nameMatcher.group(1); + } + + // Parse recipes + int recipesStart = content.indexOf("recipes {"); + if (recipesStart != -1) { + int recipesEnd = findMatchingBrace(content, recipesStart + 8); + String recipesContent = content.substring(recipesStart + 9, recipesEnd); + RecipeList.Builder recipesBuilder = RecipeList.newBuilder(); + try { + TextFormat.merge(recipesContent, recipesBuilder); + tc.recipes = recipesBuilder.build(); + } catch (TextFormat.ParseException e) { + // Invalid recipe - skip this test case but mark it as having invalid recipes + tc.invalidRecipe = true; + System.out.println("Skipping test case with invalid recipe: " + tc.name); + } + } + + // Parse tests + int pos = 0; + while (pos < content.length()) { + // Find "test {" that's not part of "test_case" + int testStart = findNextTest(content, pos); + if (testStart == -1) break; + + // "test {" is 6 chars, { is at position testStart + 5 + int bracePos = testStart + 5; + int testEnd = findMatchingBrace(content, bracePos); + String testContent = content.substring(bracePos + 1, testEnd); + + TestInstance test = parseTest(testContent); + tc.tests.add(test); + + pos = testEnd + 1; + } + + return tc; + } + + private int findNextTest(String content, int start) { + int pos = start; + while (true) { + int testPos = content.indexOf("test {", pos); + if (testPos == -1) return -1; + + // Make sure this is not part of "test_case {" + if (testPos >= 5) { + String before = content.substring(testPos - 5, testPos); + if (before.contains("_")) { + pos = testPos + 1; + continue; + } + } + return testPos; + } + } + + private TestInstance parseTest(String content) throws Exception { + TestInstance test = new TestInstance(); + + // Determine operation type and parse operation + // NOTE: Check mutation FIRST since it can contain nested key_set/key_range/key + if (content.contains("mutation {")) { + test.operationType = "mutation"; + int start = content.indexOf("mutation {"); + int end = findMatchingBrace(content, start + 9); + String mutationContent = content.substring(start + 10, end); + Mutation.Builder builder = Mutation.newBuilder(); + TextFormat.merge(mutationContent, builder); + test.mutation = builder.build(); + } else if (content.contains("query_params {")) { + test.operationType = "query_params"; + int start = content.indexOf("query_params {"); + int end = findMatchingBrace(content, start + 13); + String queryParamsContent = content.substring(start + 14, end); + Struct.Builder builder = Struct.newBuilder(); + TextFormat.merge(queryParamsContent, builder); + test.queryParams = builder.build(); + } else if (content.contains("key_set {")) { + test.operationType = "key_set"; + int start = content.indexOf("key_set {"); + int end = findMatchingBrace(content, start + 8); + String keySetContent = content.substring(start + 9, end); + KeySet.Builder builder = KeySet.newBuilder(); + TextFormat.merge(keySetContent, builder); + test.keySet = builder.build(); + } else if (content.contains("key_range {")) { + test.operationType = "key_range"; + int start = content.indexOf("key_range {"); + int end = findMatchingBrace(content, start + 10); + String keyRangeContent = content.substring(start + 11, end); + KeyRange.Builder builder = KeyRange.newBuilder(); + TextFormat.merge(keyRangeContent, builder); + test.keyRange = builder.build(); + } else if (content.contains("key {") + && !content.contains("key_range") + && !content.contains("key_set") + && !content.contains("limit_key")) { + test.operationType = "key"; + int keyStart = content.indexOf("key {"); + int keyEnd = findMatchingBrace(content, keyStart + 4); + String keyContent = content.substring(keyStart + 5, keyEnd); + ListValue.Builder keyBuilder = ListValue.newBuilder(); + TextFormat.merge(keyContent, keyBuilder); + test.key = keyBuilder.build(); + } + + // Parse expected start + Pattern startPattern = Pattern.compile("start:\\s*\"([^\"]*)\""); + Matcher startMatcher = startPattern.matcher(content); + if (startMatcher.find()) { + test.expectedStart = parseEscapedString(startMatcher.group(1)); + } + + // Parse expected limit + Pattern limitPattern = Pattern.compile("(? 0) { + char c = content.charAt(pos); + + if (escape) { + escape = false; + pos++; + continue; + } + + if (c == '\\') { + escape = true; + pos++; + continue; + } + + if (c == '"') { + inString = !inString; + } else if (!inString) { + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + } + } + pos++; + } + return pos - 1; + } + + private static String bytesToHex(ByteString bs) { + StringBuilder sb = new StringBuilder(); + for (byte b : bs.toByteArray()) { + sb.append(String.format("%02x ", b & 0xFF)); + } + return sb.toString(); + } + + private ByteString parseEscapedString(String escaped) { + byte[] bytes = new byte[escaped.length()]; + int byteIndex = 0; + int i = 0; + + while (i < escaped.length()) { + char c = escaped.charAt(i); + if (c == '\\' && i + 1 < escaped.length()) { + char next = escaped.charAt(i + 1); + if (next >= '0' && next <= '7') { + // Octal escape + int value = 0; + int count = 0; + while (i + 1 < escaped.length() + && count < 3 + && escaped.charAt(i + 1) >= '0' + && escaped.charAt(i + 1) <= '7') { + value = value * 8 + (escaped.charAt(i + 1) - '0'); + i++; + count++; + } + bytes[byteIndex++] = (byte) value; + } else if (next == 'n') { + bytes[byteIndex++] = '\n'; + i++; + } else if (next == 't') { + bytes[byteIndex++] = '\t'; + i++; + } else if (next == 'r') { + bytes[byteIndex++] = '\r'; + i++; + } else if (next == '\\') { + bytes[byteIndex++] = '\\'; + i++; + } else if (next == '"') { + bytes[byteIndex++] = '"'; + i++; + } else if (next == 'x' && i + 3 < escaped.length()) { + // Hex escape \xNN + int value = Integer.parseInt(escaped.substring(i + 2, i + 4), 16); + bytes[byteIndex++] = (byte) value; + i += 3; + } else { + bytes[byteIndex++] = (byte) c; + } + } else { + bytes[byteIndex++] = (byte) c; + } + i++; + } + + return ByteString.copyFrom(bytes, 0, byteIndex); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java new file mode 100644 index 0000000000..f50f1c4222 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java @@ -0,0 +1,247 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.protobuf.ByteString; +import com.google.protobuf.ListValue; +import com.google.protobuf.Struct; +import com.google.spanner.v1.KeyRange; +import com.google.spanner.v1.KeySet; +import com.google.spanner.v1.Mutation; +import com.google.spanner.v1.RecipeList; +import java.util.ArrayList; +import java.util.List; + +public final class RecipeTestCases { + + private final List testCases; + + private RecipeTestCases(Builder builder) { + this.testCases = new ArrayList<>(builder.testCases); + } + + public List getTestCaseList() { + return testCases; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private final List testCases = new ArrayList<>(); + + public Builder addTestCase(RecipeTestCase testCase) { + this.testCases.add(testCase); + return this; + } + + public RecipeTestCases build() { + return new RecipeTestCases(this); + } + } + + public static final class RecipeTestCase { + private final String name; + private final RecipeList recipes; + private final List tests; + + private RecipeTestCase(Builder builder) { + this.name = builder.name; + this.recipes = builder.recipes; + this.tests = new ArrayList<>(builder.tests); + } + + public String getName() { + return name; + } + + public RecipeList getRecipes() { + return recipes; + } + + public List getTestList() { + return tests; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private String name; + private RecipeList recipes; + private final List tests = new ArrayList<>(); + + public Builder setName(String name) { + this.name = name; + return this; + } + + public Builder setRecipes(RecipeList recipes) { + this.recipes = recipes; + return this; + } + + public Builder addTest(Test test) { + this.tests.add(test); + return this; + } + + public RecipeTestCase build() { + return new RecipeTestCase(this); + } + } + + public static final class Test { + private final OperationCase operationCase; + private final Object operation; + private final ByteString start; + private final ByteString limit; + private final boolean approximate; + + public enum OperationCase { + KEY, + KEY_RANGE, + KEY_SET, + MUTATION, + QUERY_PARAMS, + OPERATION_NOT_SET + } + + private Test(Builder builder) { + this.operationCase = builder.operationCase; + this.operation = builder.operation; + this.start = builder.start; + this.limit = builder.limit; + this.approximate = builder.approximate; + } + + public OperationCase getOperationCase() { + return operationCase; + } + + public ListValue getKey() { + if (operationCase == OperationCase.KEY) { + return (ListValue) operation; + } + return ListValue.getDefaultInstance(); + } + + public KeyRange getKeyRange() { + if (operationCase == OperationCase.KEY_RANGE) { + return (KeyRange) operation; + } + return KeyRange.getDefaultInstance(); + } + + public KeySet getKeySet() { + if (operationCase == OperationCase.KEY_SET) { + return (KeySet) operation; + } + return KeySet.getDefaultInstance(); + } + + public Mutation getMutation() { + if (operationCase == OperationCase.MUTATION) { + return (Mutation) operation; + } + return Mutation.getDefaultInstance(); + } + + public Struct getQueryParams() { + if (operationCase == OperationCase.QUERY_PARAMS) { + return (Struct) operation; + } + return Struct.getDefaultInstance(); + } + + public ByteString getStart() { + return start; + } + + public ByteString getLimit() { + return limit; + } + + public boolean getApproximate() { + return approximate; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private OperationCase operationCase = OperationCase.OPERATION_NOT_SET; + private Object operation; + private ByteString start; + private ByteString limit; + private boolean approximate; + + public Builder setKey(ListValue key) { + this.operationCase = OperationCase.KEY; + this.operation = key; + return this; + } + + public Builder setKeyRange(KeyRange keyRange) { + this.operationCase = OperationCase.KEY_RANGE; + this.operation = keyRange; + return this; + } + + public Builder setKeySet(KeySet keySet) { + this.operationCase = OperationCase.KEY_SET; + this.operation = keySet; + return this; + } + + public Builder setMutation(Mutation mutation) { + this.operationCase = OperationCase.MUTATION; + this.operation = mutation; + return this; + } + + public Builder setQueryParams(Struct queryParams) { + this.operationCase = OperationCase.QUERY_PARAMS; + this.operation = queryParams; + return this; + } + + public Builder setStart(ByteString start) { + this.start = start; + return this; + } + + public Builder setLimit(ByteString limit) { + this.limit = limit; + return this; + } + + public Builder setApproximate(boolean approximate) { + this.approximate = approximate; + return this; + } + + public Test build() { + return new Test(this); + } + } + } + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java new file mode 100644 index 0000000000..674ef0840f --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java @@ -0,0 +1,250 @@ +/* + * 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 + * + * 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. + */ + +package com.google.cloud.spanner.spi.v1; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.io.ByteArrayOutputStream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link SsFormat}. */ +@RunWith(JUnit4.class) +public class SsFormatTest { + + @Test + public void testMakePrefixSuccessor() { + // Empty input returns empty + assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(ByteString.EMPTY)); + assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(null)); + + // Single byte - LSB should be set + ByteString input = ByteString.copyFrom(new byte[] {0x00}); + ByteString result = SsFormat.makePrefixSuccessor(input); + assertEquals(1, result.size()); + assertEquals(0x01, result.byteAt(0) & 0xFF); + + // Multiple bytes - only last byte's LSB should be set + input = ByteString.copyFrom(new byte[] {0x12, 0x34, 0x00}); + result = SsFormat.makePrefixSuccessor(input); + assertEquals(3, result.size()); + assertEquals(0x12, result.byteAt(0) & 0xFF); + assertEquals(0x34, result.byteAt(1) & 0xFF); + assertEquals(0x01, result.byteAt(2) & 0xFF); + } + + @Test + public void testAppendCompositeTag() { + // Short tag (< 16) + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, 5); + byte[] result = out.toByteArray(); + assertEquals(1, result.length); + assertEquals(10, result[0] & 0xFF); // 5 << 1 = 10 + + // Medium tag (16 <= tag < 4096) + out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, 100); + result = out.toByteArray(); + assertEquals(2, result.length); + } + + @Test(expected = IllegalArgumentException.class) + public void testAppendCompositeTagInvalidTag() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, 0); // Invalid tag + } + + @Test + public void testAppendUnsignedIntIncreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendUnsignedIntIncreasing(out, 0); + byte[] result = out.toByteArray(); + assertTrue(result.length >= 2); // Header + at least 1 byte + + // First byte should have IS_KEY bit set (0x80) + assertTrue((result[0] & 0x80) != 0); + } + + @Test + public void testAppendUnsignedIntDecreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendUnsignedIntDecreasing(out, 0); + byte[] result = out.toByteArray(); + assertTrue(result.length >= 2); + assertTrue((result[0] & 0x80) != 0); + } + + @Test(expected = IllegalArgumentException.class) + public void testAppendUnsignedIntNegative() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendUnsignedIntIncreasing(out, -1); + } + + @Test + public void testAppendIntIncreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, 0); + byte[] result = out.toByteArray(); + assertTrue(result.length >= 2); + + // Test negative number + out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, -1); + result = out.toByteArray(); + assertTrue(result.length >= 2); + } + + @Test + public void testAppendIntDecreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntDecreasing(out, 0); + byte[] result = out.toByteArray(); + assertTrue(result.length >= 2); + + out = new ByteArrayOutputStream(); + SsFormat.appendIntDecreasing(out, -1); + result = out.toByteArray(); + assertTrue(result.length >= 2); + } + + @Test + public void testAppendStringIncreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendStringIncreasing(out, "hello"); + byte[] result = out.toByteArray(); + assertTrue(result.length > 5); // Header + string + terminator + + // First byte should have IS_KEY bit set and TYPE_STRING + assertTrue((result[0] & 0x80) != 0); + } + + @Test + public void testAppendStringDecreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendStringDecreasing(out, "hello"); + byte[] result = out.toByteArray(); + assertTrue(result.length > 5); + assertTrue((result[0] & 0x80) != 0); + } + + @Test + public void testAppendBytesIncreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendBytesIncreasing(out, new byte[] {0x01, 0x02, 0x03}); + byte[] result = out.toByteArray(); + assertTrue(result.length > 3); + } + + @Test + public void testAppendDoubleIncreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendDoubleIncreasing(out, 1.5); + byte[] result = out.toByteArray(); + assertTrue(result.length >= 2); + + // Test negative double + out = new ByteArrayOutputStream(); + SsFormat.appendDoubleIncreasing(out, -1.5); + result = out.toByteArray(); + assertTrue(result.length >= 2); + } + + @Test + public void testAppendDoubleDecreasing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendDoubleDecreasing(out, 1.5); + byte[] result = out.toByteArray(); + assertTrue(result.length >= 2); + } + + @Test + public void testAppendNullMarkers() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendNullOrderedFirst(out); + byte[] result = out.toByteArray(); + assertEquals(2, result.length); + assertTrue((result[0] & 0x80) != 0); + + out = new ByteArrayOutputStream(); + SsFormat.appendNullOrderedLast(out); + result = out.toByteArray(); + assertEquals(2, result.length); + } + + @Test + public void testAppendNotNullMarkers() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendNotNullMarkerNullOrderedFirst(out); + byte[] result = out.toByteArray(); + assertEquals(1, result.length); + + out = new ByteArrayOutputStream(); + SsFormat.appendNotNullMarkerNullOrderedLast(out); + result = out.toByteArray(); + assertEquals(1, result.length); + } + + @Test + public void testEncodeTimestamp() { + byte[] result = SsFormat.encodeTimestamp(0, 0); + assertEquals(12, result.length); + + result = SsFormat.encodeTimestamp(1234567890L, 123456789); + assertEquals(12, result.length); + } + + @Test + public void testEncodeUuid() { + byte[] result = SsFormat.encodeUuid(0x1234567890ABCDEFL, 0xFEDCBA0987654321L); + assertEquals(16, result.length); + + // Verify big-endian encoding + assertEquals(0x12, result[0] & 0xFF); + assertEquals(0x34, result[1] & 0xFF); + assertEquals(0xFE, result[8] & 0xFF); + assertEquals(0xDC, result[9] & 0xFF); + } + + @Test + public void testStringEscaping() { + // Test that 0x00 and 0xFF bytes are properly escaped + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendBytesIncreasing(out, new byte[] {0x00, (byte) 0xFF, 0x42}); + byte[] result = out.toByteArray(); + // Result should be longer due to escaping + assertTrue(result.length > 5); // header + 3 original bytes + escapes + terminator + } + + @Test + public void testOrderPreservation() { + // Verify that smaller integers encode to smaller byte sequences (lexicographically) + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out1, 100); + + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out2, 200); + + ByteString bs1 = ByteString.copyFrom(out1.toByteArray()); + ByteString bs2 = ByteString.copyFrom(out2.toByteArray()); + + assertTrue(ByteString.unsignedLexicographicalComparator().compare(bs1, bs2) < 0); + } +} diff --git a/google-cloud-spanner/src/test/resources/recipe_test.textproto b/google-cloud-spanner/src/test/resources/recipe_test.textproto new file mode 100644 index 0000000000..43fae04f5e --- /dev/null +++ b/google-cloud-spanner/src/test/resources/recipe_test.textproto @@ -0,0 +1,3943 @@ +test_case { + name: "DataTypeTest_BOOL" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_BOOL" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: BOOL + } + identifier: "k" + } + } + } + test { + key { + values { + bool_value: false + } + } + start: "A\206\310\002\234\200\000" + } + test { + key { + values { + bool_value: true + } + } + start: "A\206\310\002\234\200\002" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "true" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + number_value: 0 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + bool_value: false + } + } + end_open { + values { + bool_value: true + } + } + } + start: "A\206\310\002\234\200\000" + limit: "A\206\310\002\234\200\002" + } + test { + key_range { + start_open { + values { + bool_value: false + } + } + end_closed { + values { + bool_value: true + } + } + } + start: "A\206\310\002\234\200\001" + limit: "A\206\310\002\234\200\003" + } + test { + key_range { + start_closed { + values { + bool_value: false + } + } + end_closed { + values { + bool_value: true + } + } + } + start: "A\206\310\002\234\200\000" + limit: "A\206\310\002\234\200\003" + } + test { + key_range { + start_open { + values { + bool_value: false + } + } + end_open { + values { + bool_value: true + } + } + } + start: "A\206\310\002\234\200\001" + limit: "A\206\310\002\234\200\002" + } +} + +test_case { + name: "DataTypeTest_BOOL_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_BOOL_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: BOOL + } + identifier: "k" + } + } + } + test { + key { + values { + bool_value: true + } + } + start: "A\206\310\002\273\250\374" + } + test { + key { + values { + bool_value: false + } + } + start: "A\206\310\002\273\250\376" + } + test { + key_range { + start_closed { + values { + bool_value: true + } + } + end_open { + values { + bool_value: false + } + } + } + start: "A\206\310\002\273\250\374" + limit: "A\206\310\002\273\250\376" + } + test { + key_range { + start_open { + values { + bool_value: true + } + } + end_closed { + values { + bool_value: false + } + } + } + start: "A\206\310\002\273\250\375" + limit: "A\206\310\002\273\250\377" + } + test { + key_range { + start_closed { + values { + bool_value: true + } + } + end_closed { + values { + bool_value: false + } + } + } + start: "A\206\310\002\273\250\374" + limit: "A\206\310\002\273\250\377" + } + test { + key_range { + start_open { + values { + bool_value: true + } + } + end_open { + values { + bool_value: false + } + } + } + start: "A\206\310\002\273\250\375" + limit: "A\206\310\002\273\250\376" + } +} + +test_case { + name: "DataTypeTest_ENUM" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_ENUM" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: ENUM + proto_type_fqn: "spanner.test.TestEnum" + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "1" + } + } + start: "A\206\310\002\234\221\002" + } + test { + key { + values { + string_value: "2" + } + } + start: "A\206\310\002\234\221\004" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "NUMBER_ONE" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + number_value: 0 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + string_value: "1" + } + } + end_open { + values { + string_value: "2" + } + } + } + start: "A\206\310\002\234\221\002" + limit: "A\206\310\002\234\221\004" + } + test { + key_range { + start_open { + values { + string_value: "1" + } + } + end_closed { + values { + string_value: "2" + } + } + } + start: "A\206\310\002\234\221\003" + limit: "A\206\310\002\234\221\005" + } + test { + key_range { + start_closed { + values { + string_value: "1" + } + } + end_closed { + values { + string_value: "2" + } + } + } + start: "A\206\310\002\234\221\002" + limit: "A\206\310\002\234\221\005" + } + test { + key_range { + start_open { + values { + string_value: "1" + } + } + end_open { + values { + string_value: "2" + } + } + } + start: "A\206\310\002\234\221\003" + limit: "A\206\310\002\234\221\004" + } +} + +test_case { + name: "DataTypeTest_ENUM_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_ENUM_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: ENUM + proto_type_fqn: "spanner.test.TestEnum" + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "2" + } + } + start: "A\206\310\002\273\260\372" + } + test { + key { + values { + string_value: "1" + } + } + start: "A\206\310\002\273\260\374" + } + test { + key_range { + start_closed { + values { + string_value: "2" + } + } + end_open { + values { + string_value: "1" + } + } + } + start: "A\206\310\002\273\260\372" + limit: "A\206\310\002\273\260\374" + } + test { + key_range { + start_open { + values { + string_value: "2" + } + } + end_closed { + values { + string_value: "1" + } + } + } + start: "A\206\310\002\273\260\373" + limit: "A\206\310\002\273\260\375" + } + test { + key_range { + start_closed { + values { + string_value: "2" + } + } + end_closed { + values { + string_value: "1" + } + } + } + start: "A\206\310\002\273\260\372" + limit: "A\206\310\002\273\260\375" + } + test { + key_range { + start_open { + values { + string_value: "2" + } + } + end_open { + values { + string_value: "1" + } + } + } + start: "A\206\310\002\273\260\373" + limit: "A\206\310\002\273\260\374" + } +} + +test_case { + name: "DataTypeTest_INT64" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_INT64" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "-9223372036854775808" + } + } + start: "A\206\310\002\234\211\000\000\000\000\000\000\000\000" + } + test { + key { + values { + string_value: "9223372036854775807" + } + } + start: "A\206\310\002\234\230\377\377\377\377\377\377\377\376" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "0" + } + } + start: "A\206\310\002\234\221\000" + } + test { + key { + values { + string_value: "-1" + } + } + start: "A\206\310\002\234\220\376" + } + test { + key { + values { + string_value: "1" + } + } + start: "A\206\310\002\234\221\002" + } + test { + key { + values { + number_value: 1 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "Infinity" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + string_value: "-9223372036854775808" + } + } + end_open { + values { + string_value: "9223372036854775807" + } + } + } + start: "A\206\310\002\234\211\000\000\000\000\000\000\000\000" + limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\376" + } + test { + key_range { + start_open { + values { + string_value: "-9223372036854775808" + } + } + end_closed { + values { + string_value: "9223372036854775807" + } + } + } + start: "A\206\310\002\234\211\000\000\000\000\000\000\000\001" + limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\377" + } + test { + key_range { + start_closed { + values { + string_value: "-9223372036854775808" + } + } + end_closed { + values { + string_value: "9223372036854775807" + } + } + } + start: "A\206\310\002\234\211\000\000\000\000\000\000\000\000" + limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\377" + } + test { + key_range { + start_open { + values { + string_value: "-9223372036854775808" + } + } + end_open { + values { + string_value: "9223372036854775807" + } + } + } + start: "A\206\310\002\234\211\000\000\000\000\000\000\000\001" + limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\376" + } +} + +test_case { + name: "DataTypeTest_INT64_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_INT64_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: INT64 + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "9223372036854775807" + } + } + start: "A\206\310\002\273\251\000\000\000\000\000\000\000\000" + } + test { + key { + values { + string_value: "-9223372036854775808" + } + } + start: "A\206\310\002\273\270\377\377\377\377\377\377\377\376" + } + test { + key_range { + start_closed { + values { + string_value: "9223372036854775807" + } + } + end_open { + values { + string_value: "-9223372036854775808" + } + } + } + start: "A\206\310\002\273\251\000\000\000\000\000\000\000\000" + limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\376" + } + test { + key_range { + start_open { + values { + string_value: "9223372036854775807" + } + } + end_closed { + values { + string_value: "-9223372036854775808" + } + } + } + start: "A\206\310\002\273\251\000\000\000\000\000\000\000\001" + limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\377" + } + test { + key_range { + start_closed { + values { + string_value: "9223372036854775807" + } + } + end_closed { + values { + string_value: "-9223372036854775808" + } + } + } + start: "A\206\310\002\273\251\000\000\000\000\000\000\000\000" + limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\377" + } + test { + key_range { + start_open { + values { + string_value: "9223372036854775807" + } + } + end_open { + values { + string_value: "-9223372036854775808" + } + } + } + start: "A\206\310\002\273\251\000\000\000\000\000\000\000\001" + limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\376" + } +} + +test_case { + name: "DataTypeTest_FLOAT64" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_FLOAT64" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: FLOAT64 + } + identifier: "k" + } + } + } + test { + key { + values { + number_value: -1.7976931348623157e+308 + } + } + start: "A\206\310\002\234\302\000 \000\000\000\000\000\002" + } + test { + key { + values { + number_value: 1.7976931348623157e+308 + } + } + start: "A\206\310\002\234\321\377\337\377\377\377\377\377\376" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + number_value: 0 + } + } + start: "A\206\310\002\234\312\000" + } + test { + key { + values { + number_value: -1 + } + } + start: "A\206\310\002\234\302\200 \000\000\000\000\000\000" + } + test { + key { + values { + number_value: 1 + } + } + start: "A\206\310\002\234\321\177\340\000\000\000\000\000\000" + } + test { + key { + values { + string_value: "Infinity" + } + } + start: "A\206\310\002\234\321\377\340\000\000\000\000\000\000" + } + test { + key { + values { + string_value: "-Infinity" + } + } + start: "A\206\310\002\234\302\000 \000\000\000\000\000\000" + } + test { + key { + values { + string_value: "NaN" + } + } + start: "A\206\310\002\234\321\377\360\000\000\000\000\000\000" + } + test { + key { + values { + string_value: "UnexpectedString" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + bool_value: true + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + number_value: -1.7976931348623157e+308 + } + } + end_open { + values { + number_value: 1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\234\302\000 \000\000\000\000\000\002" + limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\376" + } + test { + key_range { + start_open { + values { + number_value: -1.7976931348623157e+308 + } + } + end_closed { + values { + number_value: 1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\234\302\000 \000\000\000\000\000\003" + limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\377" + } + test { + key_range { + start_closed { + values { + number_value: -1.7976931348623157e+308 + } + } + end_closed { + values { + number_value: 1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\234\302\000 \000\000\000\000\000\002" + limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\377" + } + test { + key_range { + start_open { + values { + number_value: -1.7976931348623157e+308 + } + } + end_open { + values { + number_value: 1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\234\302\000 \000\000\000\000\000\003" + limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\376" + } +} + +test_case { + name: "DataTypeTest_FLOAT64_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_FLOAT64_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: FLOAT64 + } + identifier: "k" + } + } + } + test { + key { + values { + number_value: 1.7976931348623157e+308 + } + } + start: "A\206\310\002\273\322\000 \000\000\000\000\000\000" + } + test { + key { + values { + number_value: -1.7976931348623157e+308 + } + } + start: "A\206\310\002\273\341\377\337\377\377\377\377\377\374" + } + test { + key_range { + start_closed { + values { + number_value: 1.7976931348623157e+308 + } + } + end_open { + values { + number_value: -1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\273\322\000 \000\000\000\000\000\000" + limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\374" + } + test { + key_range { + start_open { + values { + number_value: 1.7976931348623157e+308 + } + } + end_closed { + values { + number_value: -1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\273\322\000 \000\000\000\000\000\001" + limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\375" + } + test { + key_range { + start_closed { + values { + number_value: 1.7976931348623157e+308 + } + } + end_closed { + values { + number_value: -1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\273\322\000 \000\000\000\000\000\000" + limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\375" + } + test { + key_range { + start_open { + values { + number_value: 1.7976931348623157e+308 + } + } + end_open { + values { + number_value: -1.7976931348623157e+308 + } + } + } + start: "A\206\310\002\273\322\000 \000\000\000\000\000\001" + limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\374" + } +} + +test_case { + name: "DataTypeTest_TIMESTAMP" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_TIMESTAMP" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: TIMESTAMP + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000x" + } + test { + key { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + start: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000x" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "1970-01-01T00:00:00Z" + } + } + start: "A\206\310\002\234\231\200\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x" + } + test { + key { + values { + string_value: "2023-10-26T10:00:00Z" + } + } + start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\000\360\000\360\000\360\000\360\000x" + } + test { + key { + values { + string_value: "2023-10-26T10:00:00.1234567890Z" + } + } + start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\007[\315\025\000x" + } + test { + key { + values { + string_value: "2023-10-26T10:00:00.1234567891Z" + } + } + start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\007[\315\025\000x" + } + test { + key { + values { + string_value: "2023-10-26T10:00:00.1234567899Z" + } + } + start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\007[\315\025\000x" + } + test { + key { + values { + string_value: "0000-10-26T10:00:00Z" + } + } + start: "A\206\310\002\234\231\177\377\020\377\020\361\210\026A \000\360\000\360\000\360\000\360\000x" + } + test { + key { + values { + string_value: "NOT A TIMESTAMP" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + number_value: 0 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-10-26T10:00:00" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-10-26T10:00:00z" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-10-26T10:00:00+07:00" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-13-26T10:00:00Z" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-10-26T10:00:61Z" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-10-26 10:00:00Z" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "10000-10-26T10:00:00Z" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + end_open { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + } + start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000x" + limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000x" + } + test { + key_range { + start_open { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + end_closed { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + } + start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000y" + limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000y" + } + test { + key_range { + start_closed { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + end_closed { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + } + start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000x" + limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000y" + } + test { + key_range { + start_open { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + end_open { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + } + start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000y" + limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000x" + } +} + +test_case { + name: "DataTypeTest_TIMESTAMP_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_TIMESTAMP_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: TIMESTAMP + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377x" + } + test { + key { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + start: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377x" + } + test { + key_range { + start_closed { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + end_open { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + } + start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377x" + limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377x" + } + test { + key_range { + start_open { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + end_closed { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + } + start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377y" + limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377y" + } + test { + key_range { + start_closed { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + end_closed { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + } + start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377x" + limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377y" + } + test { + key_range { + start_open { + values { + string_value: "9999-12-31T23:59:59.999999999Z" + } + } + end_open { + values { + string_value: "0001-01-01T00:00:00Z" + } + } + } + start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377y" + limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377x" + } +} + +test_case { + name: "DataTypeTest_DATE" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_DATE" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: DATE + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "0000-01-01" + } + } + start: "A\206\310\002\234\216\352\n\260" + } + test { + key { + values { + string_value: "9999-12-31" + } + } + start: "A\206\310\002\234\223Y\201@" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "1970-01-01" + } + } + start: "A\206\310\002\234\221\000" + } + test { + key { + values { + string_value: "2023-10-26" + } + } + start: "A\206\310\002\234\222\231\220" + } + test { + key { + values { + string_value: "NOT A DATE" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + number_value: 0 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-13-01" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-12-32" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "10000-01-01" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-1-1" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-01-001" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023/01/01" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "2023-01-01T10:00:00Z" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + string_value: "0000-01-01" + } + } + end_open { + values { + string_value: "9999-12-31" + } + } + } + start: "A\206\310\002\234\216\352\n\260" + limit: "A\206\310\002\234\223Y\201@" + } + test { + key_range { + start_open { + values { + string_value: "0000-01-01" + } + } + end_closed { + values { + string_value: "9999-12-31" + } + } + } + start: "A\206\310\002\234\216\352\n\261" + limit: "A\206\310\002\234\223Y\201A" + } + test { + key_range { + start_closed { + values { + string_value: "0000-01-01" + } + } + end_closed { + values { + string_value: "9999-12-31" + } + } + } + start: "A\206\310\002\234\216\352\n\260" + limit: "A\206\310\002\234\223Y\201A" + } + test { + key_range { + start_open { + values { + string_value: "0000-01-01" + } + } + end_open { + values { + string_value: "9999-12-31" + } + } + } + start: "A\206\310\002\234\216\352\n\261" + limit: "A\206\310\002\234\223Y\201@" + } +} + +test_case { + name: "DataTypeTest_DATE_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_DATE_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: DATE + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "9999-12-31" + } + } + start: "A\206\310\002\273\256\246~\276" + } + test { + key { + values { + string_value: "0000-01-01" + } + } + start: "A\206\310\002\273\263\025\365N" + } + test { + key_range { + start_closed { + values { + string_value: "9999-12-31" + } + } + end_open { + values { + string_value: "0000-01-01" + } + } + } + start: "A\206\310\002\273\256\246~\276" + limit: "A\206\310\002\273\263\025\365N" + } + test { + key_range { + start_open { + values { + string_value: "9999-12-31" + } + } + end_closed { + values { + string_value: "0000-01-01" + } + } + } + start: "A\206\310\002\273\256\246~\277" + limit: "A\206\310\002\273\263\025\365O" + } + test { + key_range { + start_closed { + values { + string_value: "9999-12-31" + } + } + end_closed { + values { + string_value: "0000-01-01" + } + } + } + start: "A\206\310\002\273\256\246~\276" + limit: "A\206\310\002\273\263\025\365O" + } + test { + key_range { + start_open { + values { + string_value: "9999-12-31" + } + } + end_open { + values { + string_value: "0000-01-01" + } + } + } + start: "A\206\310\002\273\256\246~\277" + limit: "A\206\310\002\273\263\025\365N" + } +} + +test_case { + name: "DataTypeTest_STRING" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_STRING" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\234\231\000x" + } + test { + key { + values { + string_value: "ZZZZZZZ" + } + } + start: "A\206\310\002\234\231ZZZZZZZ\000x" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key_range { + start_closed { + values { + string_value: "" + } + } + end_open { + values { + string_value: "ZZZZZZZ" + } + } + } + start: "A\206\310\002\234\231\000x" + limit: "A\206\310\002\234\231ZZZZZZZ\000x" + } + test { + key_range { + start_open { + values { + string_value: "" + } + } + end_closed { + values { + string_value: "ZZZZZZZ" + } + } + } + start: "A\206\310\002\234\231\000y" + limit: "A\206\310\002\234\231ZZZZZZZ\000y" + } + test { + key_range { + start_closed { + values { + string_value: "" + } + } + end_closed { + values { + string_value: "ZZZZZZZ" + } + } + } + start: "A\206\310\002\234\231\000x" + limit: "A\206\310\002\234\231ZZZZZZZ\000y" + } + test { + key_range { + start_open { + values { + string_value: "" + } + } + end_open { + values { + string_value: "ZZZZZZZ" + } + } + } + start: "A\206\310\002\234\231\000y" + limit: "A\206\310\002\234\231ZZZZZZZ\000x" + } +} + +test_case { + name: "DataTypeTest_STRING_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_STRING_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "ZZZZZZZ" + } + } + start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377x" + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\273\271\377x" + } + test { + key_range { + start_closed { + values { + string_value: "ZZZZZZZ" + } + } + end_open { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377x" + limit: "A\206\310\002\273\271\377x" + } + test { + key_range { + start_open { + values { + string_value: "ZZZZZZZ" + } + } + end_closed { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377y" + limit: "A\206\310\002\273\271\377y" + } + test { + key_range { + start_closed { + values { + string_value: "ZZZZZZZ" + } + } + end_closed { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377x" + limit: "A\206\310\002\273\271\377y" + } + test { + key_range { + start_open { + values { + string_value: "ZZZZZZZ" + } + } + end_open { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377y" + limit: "A\206\310\002\273\271\377x" + } +} + +test_case { + name: "DataTypeTest_BYTES" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_BYTES" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: BYTES + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\234\231\000x" + } + test { + key { + values { + string_value: "/////w==" + } + } + start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000x" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\234\231\000x" + } + test { + key_range { + start_closed { + values { + string_value: "" + } + } + end_open { + values { + string_value: "/////w==" + } + } + } + start: "A\206\310\002\234\231\000x" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000x" + } + test { + key_range { + start_open { + values { + string_value: "" + } + } + end_closed { + values { + string_value: "/////w==" + } + } + } + start: "A\206\310\002\234\231\000y" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000y" + } + test { + key_range { + start_closed { + values { + string_value: "" + } + } + end_closed { + values { + string_value: "/////w==" + } + } + } + start: "A\206\310\002\234\231\000x" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000y" + } + test { + key_range { + start_open { + values { + string_value: "" + } + } + end_open { + values { + string_value: "/////w==" + } + } + } + start: "A\206\310\002\234\231\000y" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000x" + } +} + +test_case { + name: "DataTypeTest_BYTES_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_BYTES_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: BYTES + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "/////w==" + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377x" + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\273\271\377x" + } + test { + key_range { + start_closed { + values { + string_value: "/////w==" + } + } + end_open { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377x" + limit: "A\206\310\002\273\271\377x" + } + test { + key_range { + start_open { + values { + string_value: "/////w==" + } + } + end_closed { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377y" + limit: "A\206\310\002\273\271\377y" + } + test { + key_range { + start_closed { + values { + string_value: "/////w==" + } + } + end_closed { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377x" + limit: "A\206\310\002\273\271\377y" + } + test { + key_range { + start_open { + values { + string_value: "/////w==" + } + } + end_open { + values { + string_value: "" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377y" + limit: "A\206\310\002\273\271\377x" + } +} + +test_case { + name: "NumericBasic" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "NumericBasic" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: NUMERIC + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "123" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + number_value: 123 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } +} + +test_case { + name: "NumericMultiPart" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "NumericMultiPart" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "user_id" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: NUMERIC + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "123" + } + values { + string_value: "456" + } + } + start: "A\206\310\002\234\221\366" + limit: "A\206\310\002\234\221\367" + approximate: true + } +} + +test_case { + name: "DataTypeTest_UUID" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_UUID" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: UUID + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x" + } + test { + key { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000" + } + test { + key { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x" + } + test { + key { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x" + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890ab" + } + } + start: "A\206\310\002\234\231\0224Vx\0224\0224\0224\0224Vx\220\253\000x" + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890AB" + } + } + start: "A\206\310\002\234\231\0224Vx\0224\0224\0224\0224Vx\220\253\000x" + } + test { + key { + values { + string_value: "{12345678-1234-1234-1234-1234567890ad}" + } + } + start: "A\206\310\002\234\231\0224Vx\0224\0224\0224\0224Vx\220\255\000x" + } + test { + key { + values { + string_value: "{FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF}" + } + } + start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x" + } + test { + key { + values { + string_value: "NOT A UUID" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + number_value: 0 + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678x1234-1234-1234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890a" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890abc" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890ag" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "123456781234-1234-1234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-12341234-1234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-12341234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-1234-12341234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "-12345678-1234-1234-1234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890ab-" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678--1234-1234-1234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "{12345678-1234-1234-1234-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-1234-1234-1234-1234567890ab}" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "{{12345678-1234-1234-1234-1234567890ab}}" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key { + values { + string_value: "12345678-{1234-1234-1234}-1234567890ab" + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } + test { + key_range { + start_closed { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + end_open { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + } + start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x" + } + test { + key_range { + start_open { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + end_closed { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + } + start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000y" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000y" + } + test { + key_range { + start_closed { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + end_closed { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + } + start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000y" + } + test { + key_range { + start_open { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + end_open { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + } + start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000y" + limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x" + } +} + +test_case { + name: "DataTypeTest_UUID_Desc" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "DataTypeTest_UUID_Desc" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: DESCENDING + null_order: NULLS_LAST + type { + code: UUID + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377x" + } + test { + key { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + start: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377x" + } + test { + key_range { + start_closed { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + end_open { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377x" + limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377x" + } + test { + key_range { + start_open { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + end_closed { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377y" + limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377y" + } + test { + key_range { + start_closed { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + end_closed { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377x" + limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377y" + } + test { + key_range { + start_open { + values { + string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + } + end_open { + values { + string_value: "00000000-0000-0000-0000-000000000000" + } + } + } + start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377y" + limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377x" + } +} + +test_case { + name: "NotNull" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "NotNull" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NOT_NULL + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\231\000x" + } + test { + key { + values { + string_value: "foo" + } + } + start: "A\206\310\002\231foo\000x" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } +} + +test_case { + name: "NullsLast" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "NullsLast" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_LAST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "" + } + } + start: "A\206\310\002\273\231\000x" + } + test { + key { + values { + string_value: "foo" + } + } + start: "A\206\310\002\273\231foo\000x" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\274\000" + } +} + +test_case { + name: "MultiPart" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "MultiPart" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k1" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k2" + } + } + } + test { + key { + values { + string_value: "foo" + } + values { + string_value: "8" + } + } + start: "A\206\310\002\234\231foo\000x\234\221\020" + } + test { + key { + values { + string_value: "foo" + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\234\231foo\000x\233\000" + } + test { + key { + values { + null_value: NULL_VALUE + } + values { + string_value: "8" + } + } + start: "A\206\310\002\233\000\234\221\020" + } + test { + key { + values { + null_value: NULL_VALUE + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000\233\000" + } + test { + key_range { + start_closed { + values { + string_value: "A" + } + } + end_closed { + values { + string_value: "Z" + } + } + } + start: "A\206\310\002\234\231A\000x" + limit: "A\206\310\002\234\231Z\000y" + } + test { + key_range { + start_closed { + values { + string_value: "A" + } + values { + string_value: "4" + } + } + end_closed { + values { + string_value: "A" + } + values { + string_value: "7" + } + } + } + start: "A\206\310\002\234\231A\000x\234\221\010" + limit: "A\206\310\002\234\231A\000x\234\221\017" + } +} + +test_case { + name: "Interleaved" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "C" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + part { + tag: 2 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k2" + } + } + } + test { + key { + values { + string_value: "foo" + } + values { + string_value: "99" + } + } + start: "A\206\310\002\234\231foo\000x\004\234\221\306" + } + test { + key { + values { + string_value: "foo" + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\234\231foo\000x\004\233\000" + } + test { + key { + values { + null_value: NULL_VALUE + } + values { + string_value: "99" + } + } + start: "A\206\310\002\233\000\004\234\221\306" + } + test { + key { + values { + null_value: NULL_VALUE + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000\004\233\000" + } + test { + key_range { + start_closed { + values { + string_value: "A" + } + } + end_closed { + values { + string_value: "Z" + } + } + } + start: "A\206\310\002\234\231A\000x\004" + limit: "A\206\310\002\234\231Z\000x\005" + } + test { + key_range { + start_closed { + values { + string_value: "A" + } + values { + string_value: "4" + } + } + end_closed { + values { + string_value: "A" + } + values { + string_value: "7" + } + } + } + start: "A\206\310\002\234\231A\000x\004\234\221\010" + limit: "A\206\310\002\234\231A\000x\004\234\221\017" + } +} + +test_case { + name: "GeneratedKeyColumns" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "T" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k3" + } + } + } + test { + key { + values { + string_value: "foo" + } + values { + string_value: "99" + } + } + start: "A\206\310\002\234\231foo\000x\234\221\306" + } + test { + key { + values { + string_value: "foo" + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\234\231foo\000x\233\000" + } + test { + key { + values { + null_value: NULL_VALUE + } + values { + string_value: "99" + } + } + start: "A\206\310\002\233\000\234\221\306" + } + test { + key { + values { + null_value: NULL_VALUE + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\233\000\233\000" + } + test { + key_range { + start_closed { + values { + string_value: "A" + } + values { + string_value: "4" + } + } + end_closed { + values { + string_value: "A" + } + values { + string_value: "7" + } + } + } + start: "A\206\310\002\234\231A\000x\234\221\010" + limit: "A\206\310\002\234\231A\000x\234\221\017" + } +} + +test_case { + name: "GlobalIndex" + recipes { + schema_generation: "\001\001" + recipe { + index_name: "I" + part { + tag: 1 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k2" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "8" + } + } + start: "\002\002\234\221\020" + limit: "\002\002\234\221\021" + } + test { + key { + values { + null_value: NULL_VALUE + } + } + start: "\002\002\233\000" + limit: "\002\002\233\001" + } +} + +test_case { + name: "LocalIndex" + recipes { + schema_generation: "\001\001" + recipe { + index_name: "I" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + part { + tag: 3 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k3" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k2" + } + } + } + test { + key { + values { + string_value: "foo" + } + values { + string_value: "8" + } + } + start: "A\206\310\002\234\231foo\000x\006\234\221\020" + limit: "A\206\310\002\234\231foo\000x\006\234\221\021" + } + test { + key { + values { + string_value: "foo" + } + values { + null_value: NULL_VALUE + } + } + start: "A\206\310\002\234\231foo\000x\006\233\000" + limit: "A\206\310\002\234\231foo\000x\006\233\001" + } +} + +test_case { + name: "KeySet" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "KeySet" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k" + } + } + } + test { + key_set { + keys { + values { + string_value: "99" + } + } + } + start: "A\206\310\002\234\221\306" + } + test { + key_set { + ranges { + start_closed { + values { + string_value: "1" + } + } + end_open { + values { + string_value: "10" + } + } + } + } + start: "A\206\310\002\234\221\002" + limit: "A\206\310\002\234\221\024" + } + test { + key_set { + keys { + values { + string_value: "99" + } + } + keys { + values { + string_value: "101" + } + } + } + start: "A\206\310\002\234\221\306" + limit: "A\206\310\002\234\221\313" + } + test { + key_set { + ranges { + start_closed { + values { + string_value: "1" + } + } + end_open { + values { + string_value: "10" + } + } + } + ranges { + start_closed { + values { + string_value: "20" + } + } + end_open { + values { + string_value: "30" + } + } + } + } + start: "A\206\310\002\234\221\002" + limit: "A\206\310\002\234\221<" + } + test { + key_set { + keys { + values { + string_value: "1" + } + } + ranges { + start_closed { + values { + string_value: "5" + } + } + end_open { + values { + string_value: "10" + } + } + } + } + start: "A\206\310\002\234\221\002" + limit: "A\206\310\002\234\221\024" + } + test { + key_set { + keys { + values { + string_value: "10" + } + } + ranges { + start_closed { + values { + string_value: "5" + } + } + end_open { + values { + string_value: "10" + } + } + } + } + start: "A\206\310\002\234\221\n" + limit: "A\206\310\002\234\221\025" + } +} + +test_case { + name: "KeySet_All" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "T" + part { + tag: 50020 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key_set { + all: true + } + start: "A\206\310" + limit: "A\206\311" + } +} + +test_case { + name: "InvalidRecipe_EmptyPart" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "BadRecipe" + part { + tag: 50020 + } + part { + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "A" + } + } + start: "A\206\310" + limit: "A\206\311" + approximate: true + } +} + +test_case { + name: "InvalidRecipe_BadOrder" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "BadRecipe" + part { + tag: 50020 + } + part { + order: 99 + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k1" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "A" + } + } + start: "A\206\310" + limit: "A\206\311" + approximate: true + } +} + +test_case { + name: "InvalidRecipe_BadNullOrder" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "BadRecipe" + part { + tag: 50020 + } + part { + order: ASCENDING + null_order: 99 + type { + code: STRING + } + identifier: "k1" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "A" + } + } + start: "A\206\310" + limit: "A\206\311" + approximate: true + } +} + +test_case { + name: "InvalidRecipe_BadType" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "BadRecipe" + part { + tag: 50020 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: TOKENLIST + } + identifier: "k1" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + key { + values { + string_value: "A" + } + } + start: "A\206\310" + limit: "A\206\311" + approximate: true + } +} + +test_case { + name: "SimpleMutations" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "SimpleMutations" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k" + } + } + } + test { + mutation { + insert { + table: "SimpleMutations" + columns: "k" + values { + values { + string_value: "80" + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } + test { + mutation { + update { + table: "SimpleMutations" + columns: "k" + values { + values { + string_value: "80" + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } + test { + mutation { + insert_or_update { + table: "SimpleMutations" + columns: "k" + values { + values { + string_value: "80" + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } + test { + mutation { + replace { + table: "SimpleMutations" + columns: "k" + values { + values { + string_value: "80" + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } + test { + mutation { + delete { + table: "SimpleMutations" + key_set { + keys { + values { + string_value: "80" + } + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } + test { + mutation { + delete { + table: "SimpleMutations" + key_set { + ranges { + start_closed { + values { + string_value: "80" + } + } + end_open { + values { + string_value: "100" + } + } + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\310" + } +} + +test_case { + name: "QueueMutations" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "Q" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: INT64 + } + identifier: "k" + } + } + } + test { + mutation { + send { + queue: "Q" + key { + values { + string_value: "80" + } + } + payload { + string_value: "" + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } + test { + mutation { + ack { + queue: "Q" + key { + values { + string_value: "80" + } + } + } + } + start: "A\206\310\002\234\221\240" + limit: "A\206\310\002\234\221\241" + } +} + +test_case { + name: "CustomMutationCases" + recipes { + schema_generation: "\001\001" + recipe { + table_name: "T" + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "k" + } + } + } + test { + mutation { + } + start: "" + limit: "\377" + approximate: true + } + test { + mutation { + delete { + key_set { + all: true + } + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + } + test { + mutation { + delete { + key_set { + keys { + values { + string_value: "123" + } + } + keys { + values { + string_value: "456" + } + } + } + } + } + start: "A\206\310\002\234\231123\000x" + limit: "A\206\310\002\234\231456\000y" + } + test { + mutation { + delete { + key_set { + ranges { + start_closed { + values { + string_value: "123" + } + } + end_open { + values { + string_value: "456" + } + } + } + ranges { + start_closed { + values { + string_value: "100" + } + } + end_open { + values { + string_value: "200" + } + } + } + ranges { + start_closed { + values { + string_value: "150" + } + } + end_open { + values { + string_value: "500" + } + } + } + } + } + } + start: "A\206\310\002\234\231100\000x" + limit: "A\206\310\002\234\231500\000x" + } + test { + mutation { + delete { + key_set { + ranges { + start_closed { + values { + string_value: "123" + } + } + end_open { + values { + string_value: "456" + } + } + } + all: true + } + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + } + test { + mutation { + delete { + key_set { + keys { + values { + string_value: "123" + } + } + keys { + values { + number_value: 456 + } + } + } + } + } + start: "A\206\310\002" + limit: "A\206\310\003" + approximate: true + } +} + +test_case { + name: "QueryEncoding" + recipes { + schema_generation: "\001\001" + recipe { + operation_uid: 6 + part { + tag: 50020 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "p1" + } + part { + order: ASCENDING + null_order: NULLS_FIRST + type { + code: STRING + } + identifier: "p0" + } + } + } + test { + query_params { + fields { + key: "p0" + value { + string_value: "foo" + } + } + fields { + key: "p1" + value { + string_value: "bar" + } + } + } + start: "A\206\310\002\234\231bar\000x\234\231foo\000x" + } + test { + query_params { + fields { + key: "p1" + value { + string_value: "bar" + } + } + } + start: "A\206\310\002\234\231bar\000x" + limit: "A\206\310\002\234\231bar\000y" + approximate: true + } +} + +test_case { + name: "RandomQueryroot" + recipes { + schema_generation: "\001\001" + recipe { + operation_uid: 7 + part { + tag: 50016 + } + part { + tag: 1 + } + part { + order: ASCENDING + null_order: NOT_NULL + type { + code: INT64 + } + random: true + } + } + } + test { + query_params { + } + start: "A\206\300\002\230\327\342\351\276\316\214%$" + } +} \ No newline at end of file