From a6b8fc27f0acb85f440e3ef5fbde22ff51243b46 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 08:44:29 +0530 Subject: [PATCH 1/6] feat: add SsFormat encoding library This commit adds the foundational SsFormat class that provides sortable string format (ssformat) encoding utilities. This encoding is used by Spanner for key ordering and routing. Key features: - Composite tag encoding for interleaved tables - Signed/unsigned integer encoding (increasing/decreasing) - String and bytes encoding with proper escaping - Double encoding with proper sign handling - Timestamp and UUID encoding - Null value markers with configurable ordering - TargetRange class for key range representation Includes unit tests for all encoding functions. This is part of the experimental location-aware routing for improved latency. --- .../google/cloud/spanner/spi/v1/SsFormat.java | 374 ++++++++++++++++++ .../cloud/spanner/spi/v1/TargetRange.java | 54 +++ .../cloud/spanner/spi/v1/SsFormatTest.java | 251 ++++++++++++ 3 files changed, 679 insertions(+) create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java 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/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..fe36d34efa --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.assertArrayEquals; +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); + } +} From 2c9da0da95d2cfc02e0cc30b817385284fa70af7 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 08:45:46 +0530 Subject: [PATCH 2/6] feat: add ChannelFinder server interfaces This commit adds the server abstraction interfaces for location-aware routing: - ChannelFinderServer: Interface representing a Spanner server endpoint with address, health check, and channel access - ChannelFinderServerFactory: Factory interface for creating and caching server connections - GrpcChannelFinderServerFactory: gRPC implementation that creates and manages gRPC channels for different server endpoints These interfaces enable the client to maintain connections to multiple Spanner servers and route requests directly to the appropriate server based on key location information. This is part of the experimental location-aware routing for improved latency. --- .../spanner/spi/v1/ChannelFinderServer.java | 28 ++++++ .../spi/v1/ChannelFinderServerFactory.java | 24 +++++ .../v1/GrpcChannelFinderServerFactory.java | 98 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java 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/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; + } + } +} From f71ee0e54cb8a5fc5b593edc06d7b3eef3b3f48e Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 11:40:38 +0530 Subject: [PATCH 3/6] feat: add KeyRecipe and KeyRecipeCache This commit adds the key recipe system for translating requests to sortable string format (ssformat) keys for routing. Key components: - KeyRecipe: Translates requests to ssformat keys using server-provided recipes - KeyRecipeCache: Caches recipes by request fingerprint for efficient reuse - PreparedRead: Helper class for preparing read requests with routing info - RecipeGoldenTest: Golden tests for recipe encoding validation - RecipeTestCases: Test case definitions for recipe tests This is part of the experimental location-aware routing for improved latency. --- .../cloud/spanner/spi/v1/KeyRecipe.java | 814 ++++ .../cloud/spanner/spi/v1/KeyRecipeCache.java | 203 + .../spanner/spi/v1/RecipeGoldenTest.java | 411 ++ .../cloud/spanner/spi/v1/RecipeTestCases.java | 247 ++ .../src/test/resources/recipe_test.textproto | 3943 +++++++++++++++++ 5 files changed, 5618 insertions(+) create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java create mode 100644 google-cloud-spanner/src/test/resources/recipe_test.textproto 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/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/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 From 34a715d65d7f9e70d6765c36380842096eff06ae Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 11:41:33 +0530 Subject: [PATCH 4/6] feat: add KeyRangeCache for location-aware routing This commit adds the KeyRangeCache class that maps key ranges to specific server locations for routing decisions. Key features: - TabletEntry class for tablet metadata (UID, server address, incarnation) - ServerEntry class for server connection management - Key range to tablet mapping with efficient lookup - Lazy server initialization for on-demand connections - Integration with ChannelFinderServer interfaces This is part of the experimental location-aware routing for improved latency. --- .../cloud/spanner/spi/v1/KeyRangeCache.java | 588 ++++++++++++++++++ 1 file changed, 588 insertions(+) create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java 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..dde8d8dad6 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java @@ -0,0 +1,588 @@ +/* + * 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(); + } +} From 5b5e6f50701712eae629dc333603e095577e3deb Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 11:43:05 +0530 Subject: [PATCH 5/6] feat: add KeyAwareChannel and location-aware routing integration This commit adds the KeyAwareChannel class and integrates all components for location-aware routing in the Spanner client. Key components: - ChannelFinder: Orchestrates routing decisions using caches - KeyAwareChannel: Custom ManagedChannel that intercepts key-aware methods - SpannerOptions integration: Enables feature via setExperimentalHost() - GapicSpannerRpc modifications: Wire in KeyAwareChannel Activation requires: - SpannerOptions.Builder.setExperimentalHost() with location-aware endpoint - GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API=true environment variable This is part of the experimental location-aware routing for improved latency. --- .../google/cloud/spanner/SpannerOptions.java | 119 +++---- .../cloud/spanner/spi/v1/ChannelFinder.java | 119 +++++++ .../cloud/spanner/spi/v1/GapicSpannerRpc.java | 120 ++++++- .../cloud/spanner/spi/v1/KeyAwareChannel.java | 293 ++++++++++++++++++ .../cloud/spanner/BypassPointReadTest.java | 137 ++++++++ 5 files changed, 711 insertions(+), 77 deletions(-) create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java create mode 100644 google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java 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..58a90a5417 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java @@ -0,0 +1,119 @@ +/* + * 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/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/KeyAwareChannel.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java new file mode 100644 index 0000000000..5d7a746ad6 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java @@ -0,0 +1,293 @@ +/* + * 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/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."); + } +} From 1193055d1f2ff2c2de564e2de432b16645ddf243 Mon Sep 17 00:00:00 2001 From: cloud-java-bot Date: Thu, 8 Jan 2026 06:17:01 +0000 Subject: [PATCH 6/6] chore: generate libraries at Thu Jan 8 06:14:20 UTC 2026 --- .../cloud/spanner/spi/v1/ChannelFinder.java | 29 ++-- .../cloud/spanner/spi/v1/KeyAwareChannel.java | 55 +++++--- .../cloud/spanner/spi/v1/KeyRangeCache.java | 133 +++++++++++++----- .../cloud/spanner/spi/v1/SsFormatTest.java | 1 - 4 files changed, 150 insertions(+), 68 deletions(-) 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 index 58a90a5417..0398eed1d4 100644 --- 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 @@ -60,15 +60,20 @@ 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"); + 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()); + System.out.println( + "DEBUG [BYPASS]: Cache updated. Current state:\n" + rangeCache.debugString()); } finally { lock.writeLock().unlock(); } @@ -88,15 +93,17 @@ public ChannelFinderServer findServer(ReadRequest.Builder reqBuilder) { if (databaseId != 0) { hintBuilder.setDatabaseId(databaseId); } - System.out.println("DEBUG [BYPASS]: findServer - computing keys for table: " - + reqBuilder.getTable()); + 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")); + 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(); 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 index 5d7a746ad6..b8cfc1b8e7 100644 --- 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 @@ -187,21 +187,22 @@ public void sendMessage(RequestT message) { if (databaseId == null) { server = parentChannel.serverFactory.defaultServer(); - System.out.println("DEBUG [BYPASS]: No database ID found, using default server: " - + server.getAddress()); + 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]: Is bypass routing: " + + (finalReq.getRoutingHint().getGroupUid() != 0)); System.out.println("DEBUG [BYPASS]: ========================"); } } else { @@ -261,27 +262,47 @@ public void onMessage(ResponseT message) { 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()); + 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()); + 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()); + 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]: Range[" + + i + + "]: group_uid=" + + r.getGroupUid() + + ", split_id=" + + r.getSplitId()); } System.out.println("DEBUG [BYPASS]: ============================"); call.channelFinder.update(update); 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 index dde8d8dad6..a16d6555e5 100644 --- 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 @@ -169,9 +169,13 @@ private class CachedGroup { /** 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()); + 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() @@ -221,33 +225,49 @@ void update(Group groupIn) { CachedTablet tablet = tabletsByUid.get(tabletIn.getTabletUid()); if (tablet == null) { tablet = new CachedTablet(); - System.out.println("DEBUG [BYPASS]: Created new tablet for uid " + tabletIn.getTabletUid()); + 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); + 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"); + 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()); + 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); + 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")); + System.out.println( + "DEBUG [BYPASS]: Leader tablet picked, server: " + + (server != null ? server.getAddress() : "null")); return server; } } @@ -255,14 +275,24 @@ ChannelFinderServer fillRoutingHint(boolean preferLeader, RoutingHint.Builder hi // 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); + 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")); + System.out.println( + "DEBUG [BYPASS]: Tablet[" + + i + + "] picked, server: " + + (server != null ? server.getAddress() : "null")); return server; } } @@ -449,22 +479,32 @@ private boolean isNewerOrSame( /** 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"); + 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"); + 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()); + System.out.println( + "DEBUG [BYPASS]: Processing range for group " + + rangeIn.getGroupUid() + + ", split_id=" + + rangeIn.getSplitId()); replaceRangeIfNewer(rangeIn); } @@ -473,16 +513,24 @@ public void addRanges(CacheUpdate cacheUpdate) { unref(g); } - System.out.println("DEBUG [BYPASS]: After addRanges - ranges: " + ranges.size() - + ", groups: " + groups.size() + ", servers: " + servers.size()); + 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()); - + 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(); @@ -505,8 +553,9 @@ public ChannelFinderServer fillRoutingInfo( 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")); + System.out.println( + "DEBUG [BYPASS]: Found range for key, group_uid: " + + (range.group != null ? range.group.groupUid : "null")); } } @@ -539,10 +588,16 @@ public ChannelFinderServer fillRoutingInfo( 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); + 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); 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 index fe36d34efa..674ef0840f 100644 --- 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 @@ -16,7 +16,6 @@ package com.google.cloud.spanner.spi.v1; -import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue;