From a6b8fc27f0acb85f440e3ef5fbde22ff51243b46 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 08:44:29 +0530 Subject: [PATCH 1/4] 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 5ad36f77205046a02037d535be86536668bfd0ed Mon Sep 17 00:00:00 2001 From: cloud-java-bot Date: Thu, 8 Jan 2026 03:41:42 +0000 Subject: [PATCH 2/4] chore: generate libraries at Thu Jan 8 03:39:03 UTC 2026 --- .../java/com/google/cloud/spanner/spi/v1/SsFormat.java | 7 ++----- .../java/com/google/cloud/spanner/spi/v1/TargetRange.java | 2 ++ .../java/com/google/cloud/spanner/spi/v1/SsFormatTest.java | 1 - 3 files changed, 4 insertions(+), 6 deletions(-) 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 index 67ed2b3e39..1882a12a83 100644 --- 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 @@ -45,7 +45,6 @@ public static ByteString makePrefixSuccessor(ByteString key) { private SsFormat() {} - // Constants from ssformat.cc private static final int IS_KEY = 0x80; private static final int TYPE_MASK = 0x7f; @@ -258,10 +257,8 @@ public static void appendIntDecreasing(ByteArrayOutputStream out, long value) { 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) + // Transform negative doubles to maintain lexicographic sort order + enc = Long.MIN_VALUE - enc; } appendIntInternal(out, enc, false, true); } 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 index 383cc0f830..bfcd2e30a8 100644 --- 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 @@ -16,9 +16,11 @@ package com.google.cloud.spanner.spi.v1; +import com.google.api.core.InternalApi; import com.google.protobuf.ByteString; /** Represents a key range with start and limit boundaries for routing. */ +@InternalApi public class TargetRange { public ByteString start; public ByteString limit; 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; From 5b5818872571000e46c0498e206dc10b9aa142bf Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 8 Jan 2026 12:59:32 +0530 Subject: [PATCH 3/4] fix: add clirr exemptions for Protobuf 4.27.4+ runtime upgrade Added missing clirr exemptions for the protobuf library upgrade from GeneratedMessage to GeneratedMessageV3: - 5001: Removed superclass (GeneratedMessage/GeneratedMessage$Builder) - 7005: Parameter type changes (BuilderParent types) - 7006: Return type changes (internalGetFieldAccessorTable) - 7014: Method made final (getDescriptor) These exemptions are applied to all proto modules: - proto-google-cloud-spanner-admin-instance-v1 - proto-google-cloud-spanner-admin-database-v1 - proto-google-cloud-spanner-v1 - proto-google-cloud-spanner-executor-v1 --- .../clirr-ignored-differences.xml | 58 ++++++++++++++++++- .../clirr-ignored-differences.xml | 58 ++++++++++++++++++- .../clirr-ignored-differences.xml | 58 ++++++++++++++++++- .../clirr-ignored-differences.xml | 58 ++++++++++++++++++- 4 files changed, 228 insertions(+), 4 deletions(-) diff --git a/proto-google-cloud-spanner-admin-database-v1/clirr-ignored-differences.xml b/proto-google-cloud-spanner-admin-database-v1/clirr-ignored-differences.xml index 3799fb341a..fb64ee1847 100644 --- a/proto-google-cloud-spanner-admin-database-v1/clirr-ignored-differences.xml +++ b/proto-google-cloud-spanner-admin-database-v1/clirr-ignored-differences.xml @@ -17,7 +17,63 @@ boolean has*(*) - + + + + 5001 + com/google/spanner/admin/database/v1/* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/admin/database/v1/*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/admin/database/v1/*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/admin/database/v1/*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/admin/database/v1/*$*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/admin/database/v1/*$*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/admin/database/v1/*Proto + com/google/protobuf/GeneratedFile + + + + 7005 + com/google/spanner/admin/database/v1/** + * newBuilderForType(*) + ** + + + + 7006 + com/google/spanner/admin/database/v1/** + * internalGetFieldAccessorTable() + ** + + + + 7014 + com/google/spanner/admin/database/v1/** + * getDescriptor() + 7006 com/google/spanner/admin/database/v1/** diff --git a/proto-google-cloud-spanner-admin-instance-v1/clirr-ignored-differences.xml b/proto-google-cloud-spanner-admin-instance-v1/clirr-ignored-differences.xml index fa9181b755..7236471b37 100644 --- a/proto-google-cloud-spanner-admin-instance-v1/clirr-ignored-differences.xml +++ b/proto-google-cloud-spanner-admin-instance-v1/clirr-ignored-differences.xml @@ -17,7 +17,63 @@ boolean has*(*) - + + + + 5001 + com/google/spanner/admin/instance/v1/* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/admin/instance/v1/*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/admin/instance/v1/*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/admin/instance/v1/*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/admin/instance/v1/*$*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/admin/instance/v1/*$*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/admin/instance/v1/*Proto + com/google/protobuf/GeneratedFile + + + + 7005 + com/google/spanner/admin/instance/v1/** + * newBuilderForType(*) + ** + + + + 7006 + com/google/spanner/admin/instance/v1/** + * internalGetFieldAccessorTable() + ** + + + + 7014 + com/google/spanner/admin/instance/v1/** + * getDescriptor() + 7006 com/google/spanner/admin/instance/v1/** diff --git a/proto-google-cloud-spanner-executor-v1/clirr-ignored-differences.xml b/proto-google-cloud-spanner-executor-v1/clirr-ignored-differences.xml index c8787595be..50ed2b0eec 100644 --- a/proto-google-cloud-spanner-executor-v1/clirr-ignored-differences.xml +++ b/proto-google-cloud-spanner-executor-v1/clirr-ignored-differences.xml @@ -37,7 +37,63 @@ com/google/spanner/executor/v1/SpannerExecutorProxyGrpc$SpannerExecutorProxyStub - + + + + 5001 + com/google/spanner/executor/v1/* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/executor/v1/*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/executor/v1/*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/executor/v1/*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/executor/v1/*$*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/executor/v1/*$*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/executor/v1/*Proto + com/google/protobuf/GeneratedFile + + + + 7005 + com/google/spanner/executor/v1/** + * newBuilderForType(*) + ** + + + + 7006 + com/google/spanner/executor/v1/** + * internalGetFieldAccessorTable() + ** + + + + 7014 + com/google/spanner/executor/v1/** + * getDescriptor() + 7006 com/google/spanner/executor/v1/** diff --git a/proto-google-cloud-spanner-v1/clirr-ignored-differences.xml b/proto-google-cloud-spanner-v1/clirr-ignored-differences.xml index 89fd05b2e3..7cb9c078e6 100644 --- a/proto-google-cloud-spanner-v1/clirr-ignored-differences.xml +++ b/proto-google-cloud-spanner-v1/clirr-ignored-differences.xml @@ -17,7 +17,63 @@ boolean has*(*) - + + + + 5001 + com/google/spanner/v1/* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/v1/*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/v1/*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/v1/*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/v1/*$*$* + com/google/protobuf/GeneratedMessage + + + 5001 + com/google/spanner/v1/*$*$*$Builder + com/google/protobuf/GeneratedMessage$Builder + + + 5001 + com/google/spanner/v1/*Proto + com/google/protobuf/GeneratedFile + + + + 7005 + com/google/spanner/v1/** + * newBuilderForType(*) + ** + + + + 7006 + com/google/spanner/v1/** + * internalGetFieldAccessorTable() + ** + + + + 7014 + com/google/spanner/v1/** + * getDescriptor() + 7006 com/google/spanner/v1/** From 431156319adb7cb7ee16cc7cfd51f50c1f52faf2 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Fri, 9 Jan 2026 15:37:49 +0530 Subject: [PATCH 4/4] add test --- .../cloud/spanner/spi/v1/SsFormatTest.java | 820 +++++++++++++++--- 1 file changed, 702 insertions(+), 118 deletions(-) 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 674ef0840f..2641f28f1b 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,11 +16,19 @@ package com.google.cloud.spanner.spi.v1; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.google.protobuf.ByteString; import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.TreeSet; +import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -29,21 +37,175 @@ @RunWith(JUnit4.class) public class SsFormatTest { + private static List signedIntTestValues; + private static List unsignedIntTestValues; + private static List doubleTestValues; + + /** Comparator for unsigned lexicographic comparison of byte arrays. */ + private static final Comparator UNSIGNED_BYTE_COMPARATOR = + (a, b) -> + ByteString.unsignedLexicographicalComparator() + .compare(ByteString.copyFrom(a), ByteString.copyFrom(b)); + + @BeforeClass + public static void setUpTestData() { + signedIntTestValues = buildSignedIntTestValues(); + unsignedIntTestValues = buildUnsignedIntTestValues(); + doubleTestValues = buildDoubleTestValues(); + } + + private static List buildSignedIntTestValues() { + TreeSet values = new TreeSet<>(); + + // Range of small values + for (int i = -300; i < 300; i++) { + values.add((long) i); + } + + // Powers of 2 and boundaries + for (int i = 0; i < 63; i++) { + long powerOf2 = 1L << i; + values.add(powerOf2); + values.add(powerOf2 - 1); + values.add(powerOf2 + 1); + values.add(-powerOf2); + values.add(-powerOf2 - 1); + values.add(-powerOf2 + 1); + } + + // Edge cases + values.add(Long.MIN_VALUE); + values.add(Long.MAX_VALUE); + + return new ArrayList<>(values); + } + + private static List buildUnsignedIntTestValues() { + TreeSet values = new TreeSet<>(Long::compareUnsigned); + + // Range of small values + for (int i = 0; i < 600; i++) { + values.add((long) i); + } + + // Powers of 2 and boundaries (treating as unsigned) + for (int i = 0; i < 64; i++) { + long powerOf2 = 1L << i; + values.add(powerOf2); + if (powerOf2 > 0) { + values.add(powerOf2 - 1); + } + values.add(powerOf2 + 1); + } + + // Max unsigned value (all bits set) + values.add(-1L); // 0xFFFFFFFFFFFFFFFF as unsigned + + return new ArrayList<>(values); + } + + private static List buildDoubleTestValues() { + TreeSet values = + new TreeSet<>( + (a, b) -> { + // Handle NaN specially - put at end + if (Double.isNaN(a) && Double.isNaN(b)) return 0; + if (Double.isNaN(a)) return 1; + if (Double.isNaN(b)) return -1; + return Double.compare(a, b); + }); + + // Basic values + values.add(0.0); + values.add(-0.0); + values.add(Double.POSITIVE_INFINITY); + values.add(Double.NEGATIVE_INFINITY); + values.add(Double.MIN_VALUE); + values.add(Double.MAX_VALUE); + values.add(-Double.MIN_VALUE); + values.add(-Double.MAX_VALUE); + + // Powers of 10 + double value = 1.0; + for (int i = 0; i < 10; i++) { + values.add(value); + values.add(-value); + value /= 10; + } + + long[] signs = {0, 1}; + long[] exponents = { + 0, 1, 2, 100, 200, 512, 1000, 1020, 1021, 1022, 1023, 1024, 1025, 1026, 1027, 1028, 1029, + 2000, 2045, 2046, 2047 + }; + long[] fractions = { + 0, + 1, + 2, + 10, + 16, + 255, + 256, + 32767, + 32768, + 65535, + 65536, + 1000000, + 0x7ffffffeL, + 0x7fffffffL, + 0x80000000L, + 0x80000001L, + 0x80000002L, + 0x0003456789abcdefL, + 0x0007fffffffffffeL, + 0x0007ffffffffffffL, + 0x0008000000000000L, + 0x0008000000000001L, + 0x000cba9876543210L, + 0x000fffffffff0000L, + 0x000ffffffffff000L, + 0x000fffffffffff00L, + 0x000ffffffffffff0L, + 0x000ffffffffffff8L, + 0x000ffffffffffffcL, + 0x000ffffffffffffeL, + 0x000fffffffffffffL + }; + + for (long sign : signs) { + for (long exponent : exponents) { + for (long fraction : fractions) { + long bits = (sign << 63) | (exponent << 52) | fraction; + values.add(Double.longBitsToDouble(bits)); + } + } + } + + return new ArrayList<>(values); + } + + // ==================== Prefix Successor Tests ==================== + @Test - public void testMakePrefixSuccessor() { - // Empty input returns empty + public void makePrefixSuccessor_emptyInput_returnsEmpty() { assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(ByteString.EMPTY)); assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(null)); + } - // Single byte - LSB should be set + @Test + public void makePrefixSuccessor_singleByte_setsLsb() { ByteString input = ByteString.copyFrom(new byte[] {0x00}); ByteString result = SsFormat.makePrefixSuccessor(input); + assertEquals(1, result.size()); assertEquals(0x01, result.byteAt(0) & 0xFF); + } + + @Test + public void makePrefixSuccessor_multipleBytes_onlyModifiesLastByte() { + ByteString input = ByteString.copyFrom(new byte[] {0x12, 0x34, 0x00}); + ByteString result = SsFormat.makePrefixSuccessor(input); - // 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); @@ -51,200 +213,622 @@ public void testMakePrefixSuccessor() { } @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 + public void makePrefixSuccessor_resultIsGreaterThanOriginal() { + byte[] original = new byte[] {0x10, 0x20, 0x30}; + ByteString successor = SsFormat.makePrefixSuccessor(ByteString.copyFrom(original)); + + assertTrue( + ByteString.unsignedLexicographicalComparator() + .compare(ByteString.copyFrom(original), successor) + < 0); + } - // Medium tag (16 <= tag < 4096) - out = new ByteArrayOutputStream(); - SsFormat.appendCompositeTag(out, 100); - result = out.toByteArray(); - assertEquals(2, result.length); + // ==================== Composite Tag Tests ==================== + + @Test + public void appendCompositeTag_shortTag_encodesInOneByte() { + // Tags 1-15 should fit in 1 byte + for (int tag = 1; tag <= 15; tag++) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, tag); + byte[] result = out.toByteArray(); + + assertEquals("Tag " + tag + " should encode to 1 byte", 1, result.length); + assertEquals("Tag " + tag + " should encode as tag << 1", tag << 1, result[0] & 0xFF); + } } - @Test(expected = IllegalArgumentException.class) - public void testAppendCompositeTagInvalidTag() { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendCompositeTag(out, 0); // Invalid tag + @Test + public void appendCompositeTag_mediumTag_encodesInTwoBytes() { + // Tags 16-4095 should fit in 2 bytes + int[] testTags = {16, 100, 1000, 4095}; + for (int tag : testTags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, tag); + byte[] result = out.toByteArray(); + + assertEquals("Tag " + tag + " should encode to 2 bytes", 2, result.length); + } } @Test - public void testAppendUnsignedIntIncreasing() { + public void appendCompositeTag_largeTag_encodesInThreeBytes() { + // Tags 4096-65535 should fit in 3 bytes + int[] testTags = {4096, 10000, 65535}; + for (int tag : testTags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, tag); + byte[] result = out.toByteArray(); + + assertEquals("Tag " + tag + " should encode to 3 bytes", 3, result.length); + } + } + + @Test + public void appendCompositeTag_invalidTag_throws() { ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendUnsignedIntIncreasing(out, 0); - byte[] result = out.toByteArray(); - assertTrue(result.length >= 2); // Header + at least 1 byte + assertThrows(IllegalArgumentException.class, () -> SsFormat.appendCompositeTag(out, 0)); + assertThrows(IllegalArgumentException.class, () -> SsFormat.appendCompositeTag(out, -1)); + assertThrows(IllegalArgumentException.class, () -> SsFormat.appendCompositeTag(out, 65536)); + } - // First byte should have IS_KEY bit set (0x80) - assertTrue((result[0] & 0x80) != 0); + @Test + public void appendCompositeTag_preservesOrdering() { + // Verify smaller tags encode to lexicographically smaller byte sequences + for (int tag1 = 1; tag1 <= 100; tag1++) { + for (int tag2 = tag1 + 1; tag2 <= 101 && tag2 <= tag1 + 10; tag2++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, tag1); + SsFormat.appendCompositeTag(out2, tag2); + + assertTrue( + "Tag " + tag1 + " should encode smaller than tag " + tag2, + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } } + // ==================== Signed Integer Tests ==================== + @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); + public void appendIntIncreasing_preservesOrdering() { + // Verify that encoded integers maintain their natural ordering + for (int i = 0; i < signedIntTestValues.size() - 1; i++) { + long v1 = signedIntTestValues.get(i); + long v2 = signedIntTestValues.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendIntIncreasing(out1, v1); + SsFormat.appendIntIncreasing(out2, v2); + + assertTrue( + "Encoded " + v1 + " should be less than encoded " + v2, + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } } - @Test(expected = IllegalArgumentException.class) - public void testAppendUnsignedIntNegative() { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendUnsignedIntIncreasing(out, -1); + @Test + public void appendIntDecreasing_reversesOrdering() { + // Verify that decreasing encoding reverses the ordering + for (int i = 0; i < signedIntTestValues.size() - 1; i++) { + long v1 = signedIntTestValues.get(i); + long v2 = signedIntTestValues.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendIntDecreasing(out1, v1); + SsFormat.appendIntDecreasing(out2, v2); + + assertTrue( + "Decreasing encoded " + v1 + " should be greater than encoded " + v2, + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } } @Test - public void testAppendIntIncreasing() { + public void appendIntIncreasing_hasIsKeyBitSet() { ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendIntIncreasing(out, 0); + SsFormat.appendIntIncreasing(out, 42); 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); + assertTrue("IS_KEY bit (0x80) should be set", (result[0] & 0x80) != 0); } @Test - public void testAppendIntDecreasing() { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendIntDecreasing(out, 0); - byte[] result = out.toByteArray(); - assertTrue(result.length >= 2); + public void appendIntIncreasing_edgeCases() { + long[] edgeCases = {Long.MIN_VALUE, -1, 0, 1, Long.MAX_VALUE}; + + for (long value : edgeCases) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, value); + byte[] result = out.toByteArray(); - out = new ByteArrayOutputStream(); - SsFormat.appendIntDecreasing(out, -1); - result = out.toByteArray(); - assertTrue(result.length >= 2); + assertTrue("Result should have at least 2 bytes for value " + value, result.length >= 2); + assertTrue("IS_KEY bit should be set for value " + value, (result[0] & 0x80) != 0); + } + } + + // ==================== Unsigned Integer Tests ==================== + + @Test + public void appendUnsignedIntIncreasing_preservesOrdering() { + // Filter to only non-negative values for unsigned comparison + List positiveValues = new ArrayList<>(); + for (long v : unsignedIntTestValues) { + if (v >= 0) positiveValues.add(v); + } + positiveValues.sort(Long::compareUnsigned); + + for (int i = 0; i < positiveValues.size() - 1; i++) { + long v1 = positiveValues.get(i); + long v2 = positiveValues.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendUnsignedIntIncreasing(out1, v1); + SsFormat.appendUnsignedIntIncreasing(out2, v2); + + assertTrue( + "Unsigned encoded " + + Long.toUnsignedString(v1) + + " should be less than " + + Long.toUnsignedString(v2), + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } } @Test - public void testAppendStringIncreasing() { + public void appendUnsignedIntIncreasing_rejectsNegativeValues() { ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendStringIncreasing(out, "hello"); - byte[] result = out.toByteArray(); - assertTrue(result.length > 5); // Header + string + terminator + assertThrows( + IllegalArgumentException.class, () -> SsFormat.appendUnsignedIntIncreasing(out, -1)); + } + + @Test + public void appendUnsignedIntDecreasing_reversesOrdering() { + long[] values = {0, 1, 100, 1000, Long.MAX_VALUE}; + + for (int i = 0; i < values.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); - // First byte should have IS_KEY bit set and TYPE_STRING - assertTrue((result[0] & 0x80) != 0); + SsFormat.appendUnsignedIntDecreasing(out1, values[i]); + SsFormat.appendUnsignedIntDecreasing(out2, values[i + 1]); + + assertTrue( + "Decreasing unsigned encoded " + values[i] + " should be greater than " + values[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } } + // ==================== String Tests ==================== + @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); + public void appendStringIncreasing_preservesOrdering() { + String[] strings = {"", "a", "aa", "ab", "b", "hello", "world", "\u00ff"}; + Arrays.sort(strings); + + for (int i = 0; i < strings.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendStringIncreasing(out1, strings[i]); + SsFormat.appendStringIncreasing(out2, strings[i + 1]); + + assertTrue( + "Encoded '" + strings[i] + "' should be less than '" + strings[i + 1] + "'", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void appendStringDecreasing_reversesOrdering() { + String[] strings = {"", "a", "b", "hello"}; + + for (int i = 0; i < strings.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendStringDecreasing(out1, strings[i]); + SsFormat.appendStringDecreasing(out2, strings[i + 1]); + + assertTrue( + "Decreasing encoded '" + strings[i] + "' should be greater than '" + strings[i + 1] + "'", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } } @Test - public void testAppendBytesIncreasing() { + public void appendStringIncreasing_escapesSpecialBytes() { + // Test that 0x00 and 0xFF bytes are properly escaped ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendBytesIncreasing(out, new byte[] {0x01, 0x02, 0x03}); + SsFormat.appendBytesIncreasing(out, new byte[] {0x00, (byte) 0xFF, 0x42}); byte[] result = out.toByteArray(); - assertTrue(result.length > 3); + + // Result should be longer due to escaping: + // header (1) + escaped 0x00 (2) + escaped 0xFF (2) + 0x42 (1) + terminator (2) = 8 + assertTrue("Result should include escape sequences", result.length > 5); } @Test - public void testAppendDoubleIncreasing() { + public void appendStringIncreasing_emptyString() { ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendDoubleIncreasing(out, 1.5); + SsFormat.appendStringIncreasing(out, ""); 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); + // Empty string should still have header + terminator + assertTrue("Empty string encoding should have at least 3 bytes", result.length >= 3); + assertTrue("IS_KEY bit should be set", (result[0] & 0x80) != 0); } + // ==================== Bytes Tests ==================== + @Test - public void testAppendDoubleDecreasing() { + public void appendBytesIncreasing_preservesOrdering() { + byte[][] testBytes = { + new byte[] {}, + new byte[] {0x00}, + new byte[] {0x01}, + new byte[] {0x01, 0x02}, + new byte[] {(byte) 0xFF} + }; + + for (int i = 0; i < testBytes.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendBytesIncreasing(out1, testBytes[i]); + SsFormat.appendBytesIncreasing(out2, testBytes[i + 1]); + + assertTrue( + "Encoded bytes should maintain lexicographic order", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + // ==================== Double Tests ==================== + + @Test + public void appendDoubleIncreasing_preservesOrdering() { + // Filter out NaN as it has special comparison semantics + List sortedDoubles = new ArrayList<>(); + for (double d : doubleTestValues) { + if (!Double.isNaN(d)) { + sortedDoubles.add(d); + } + } + sortedDoubles.sort(Double::compare); + + for (int i = 0; i < sortedDoubles.size() - 1; i++) { + double v1 = sortedDoubles.get(i); + double v2 = sortedDoubles.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendDoubleIncreasing(out1, v1); + SsFormat.appendDoubleIncreasing(out2, v2); + + int cmp = UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()); + + // Note: -0.0 and 0.0 encode identically (both map to 0 internally), so allow equality + assertTrue("Encoded " + v1 + " should be <= encoded " + v2, cmp <= 0); + } + } + + @Test + public void appendDoubleDecreasing_reversesOrdering() { + double[] values = {-Double.MAX_VALUE, -1.0, 0.0, 1.0, Double.MAX_VALUE}; + + for (int i = 0; i < values.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendDoubleDecreasing(out1, values[i]); + SsFormat.appendDoubleDecreasing(out2, values[i + 1]); + + assertTrue( + "Decreasing encoded " + values[i] + " should be greater than " + values[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } + } + + @Test + public void appendDoubleIncreasing_specialValues() { + // Test special double values + // Note: -0.0 is excluded because it encodes identically to 0.0 + // (both have internal representation mapping to 0) + double[] specialValues = { + Double.NEGATIVE_INFINITY, + -Double.MAX_VALUE, + -1.0, + -Double.MIN_VALUE, + 0.0, // -0.0 encodes the same as 0.0 + Double.MIN_VALUE, + 1.0, + Double.MAX_VALUE, + Double.POSITIVE_INFINITY + }; + + // Verify ordering is preserved + for (int i = 0; i < specialValues.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendDoubleIncreasing(out1, specialValues[i]); + SsFormat.appendDoubleIncreasing(out2, specialValues[i + 1]); + + assertTrue( + "Special value " + specialValues[i] + " should encode less than " + specialValues[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void appendDoubleIncreasing_negativeZeroEqualsPositiveZero() { + // Verify that -0.0 and 0.0 encode identically + // This is correct behavior: both map to internal representation 0 + ByteArrayOutputStream outNegZero = new ByteArrayOutputStream(); + ByteArrayOutputStream outPosZero = new ByteArrayOutputStream(); + + SsFormat.appendDoubleIncreasing(outNegZero, -0.0); + SsFormat.appendDoubleIncreasing(outPosZero, 0.0); + + assertArrayEquals( + "-0.0 and 0.0 should encode identically", + outNegZero.toByteArray(), + outPosZero.toByteArray()); + } + + @Test + public void appendDoubleIncreasing_nan() { ByteArrayOutputStream out = new ByteArrayOutputStream(); - SsFormat.appendDoubleDecreasing(out, 1.5); + SsFormat.appendDoubleIncreasing(out, Double.NaN); byte[] result = out.toByteArray(); - assertTrue(result.length >= 2); + + assertTrue("NaN encoding should have at least 2 bytes", result.length >= 2); + assertTrue("IS_KEY bit should be set for NaN", (result[0] & 0x80) != 0); } + // ==================== Null Marker Tests ==================== + @Test - public void testAppendNullMarkers() { + public void appendNullOrderedFirst_encoding() { ByteArrayOutputStream out = new ByteArrayOutputStream(); SsFormat.appendNullOrderedFirst(out); byte[] result = out.toByteArray(); - assertEquals(2, result.length); - assertTrue((result[0] & 0x80) != 0); - out = new ByteArrayOutputStream(); + assertEquals("Null ordered first should encode to 2 bytes", 2, result.length); + assertTrue("IS_KEY bit should be set", (result[0] & 0x80) != 0); + } + + @Test + public void appendNullOrderedLast_encoding() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); SsFormat.appendNullOrderedLast(out); - result = out.toByteArray(); - assertEquals(2, result.length); + byte[] result = out.toByteArray(); + + assertEquals("Null ordered last should encode to 2 bytes", 2, result.length); + assertTrue("IS_KEY bit should be set", (result[0] & 0x80) != 0); } @Test - public void testAppendNotNullMarkers() { + public void appendNotNullMarkerNullOrderedFirst_encoding() { ByteArrayOutputStream out = new ByteArrayOutputStream(); SsFormat.appendNotNullMarkerNullOrderedFirst(out); byte[] result = out.toByteArray(); - assertEquals(1, result.length); - out = new ByteArrayOutputStream(); + assertEquals("Not-null marker (nulls first) should encode to 1 byte", 1, result.length); + } + + @Test + public void appendNotNullMarkerNullOrderedLast_encoding() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); SsFormat.appendNotNullMarkerNullOrderedLast(out); - result = out.toByteArray(); - assertEquals(1, result.length); + byte[] result = out.toByteArray(); + + assertEquals("Not-null marker (nulls last) should encode to 1 byte", 1, result.length); } @Test - public void testEncodeTimestamp() { + public void nullOrderedFirst_sortsBeforeValues() { + ByteArrayOutputStream nullOut = new ByteArrayOutputStream(); + ByteArrayOutputStream valueOut = new ByteArrayOutputStream(); + + SsFormat.appendNullOrderedFirst(nullOut); + SsFormat.appendNotNullMarkerNullOrderedFirst(valueOut); + SsFormat.appendIntIncreasing(valueOut, Long.MIN_VALUE); + + assertTrue( + "Null (ordered first) should sort before any value", + UNSIGNED_BYTE_COMPARATOR.compare(nullOut.toByteArray(), valueOut.toByteArray()) < 0); + } + + @Test + public void nullOrderedLast_sortsAfterValues() { + ByteArrayOutputStream nullOut = new ByteArrayOutputStream(); + ByteArrayOutputStream valueOut = new ByteArrayOutputStream(); + + SsFormat.appendNullOrderedLast(nullOut); + SsFormat.appendNotNullMarkerNullOrderedLast(valueOut); + SsFormat.appendIntIncreasing(valueOut, Long.MAX_VALUE); + + assertTrue( + "Null (ordered last) should sort after any value", + UNSIGNED_BYTE_COMPARATOR.compare(nullOut.toByteArray(), valueOut.toByteArray()) > 0); + } + + // ==================== Timestamp Tests ==================== + + @Test + public void encodeTimestamp_length() { byte[] result = SsFormat.encodeTimestamp(0, 0); - assertEquals(12, result.length); + assertEquals("Timestamp should encode to 12 bytes", 12, result.length); + } - result = SsFormat.encodeTimestamp(1234567890L, 123456789); - assertEquals(12, result.length); + @Test + public void encodeTimestamp_preservesOrdering() { + long[][] timestamps = { + {0, 0}, + {0, 1}, + {0, 999999999}, + {1, 0}, + {100, 500000000}, + {Long.MAX_VALUE / 2, 0} + }; + + for (int i = 0; i < timestamps.length - 1; i++) { + byte[] t1 = SsFormat.encodeTimestamp(timestamps[i][0], (int) timestamps[i][1]); + byte[] t2 = SsFormat.encodeTimestamp(timestamps[i + 1][0], (int) timestamps[i + 1][1]); + + assertTrue( + "Earlier timestamp should encode smaller", UNSIGNED_BYTE_COMPARATOR.compare(t1, t2) < 0); + } } + // ==================== UUID Tests ==================== + @Test - public void testEncodeUuid() { - byte[] result = SsFormat.encodeUuid(0x1234567890ABCDEFL, 0xFEDCBA0987654321L); - assertEquals(16, result.length); + public void encodeUuid_length() { + byte[] result = SsFormat.encodeUuid(0, 0); + assertEquals("UUID should encode to 16 bytes", 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 encodeUuid_bigEndianEncoding() { + byte[] result = SsFormat.encodeUuid(0x0102030405060708L, 0x090A0B0C0D0E0F10L); + + // Verify big-endian encoding of high bits + assertEquals(0x01, result[0] & 0xFF); + assertEquals(0x02, result[1] & 0xFF); + assertEquals(0x03, result[2] & 0xFF); + assertEquals(0x04, result[3] & 0xFF); + assertEquals(0x05, result[4] & 0xFF); + assertEquals(0x06, result[5] & 0xFF); + assertEquals(0x07, result[6] & 0xFF); + assertEquals(0x08, result[7] & 0xFF); + + // Verify big-endian encoding of low bits + assertEquals(0x09, result[8] & 0xFF); + assertEquals(0x0A, result[9] & 0xFF); + assertEquals(0x0B, result[10] & 0xFF); + assertEquals(0x0C, result[11] & 0xFF); + assertEquals(0x0D, result[12] & 0xFF); + assertEquals(0x0E, result[13] & 0xFF); + assertEquals(0x0F, result[14] & 0xFF); + assertEquals(0x10, result[15] & 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 + public void encodeUuid_preservesOrdering() { + // UUIDs compared as unsigned 128-bit integers should preserve order + long[][] uuids = { + {0, 0}, + {0, 1}, + {0, Long.MAX_VALUE}, + {1, 0}, + {Long.MAX_VALUE, Long.MAX_VALUE} + }; + + for (int i = 0; i < uuids.length - 1; i++) { + byte[] u1 = SsFormat.encodeUuid(uuids[i][0], uuids[i][1]); + byte[] u2 = SsFormat.encodeUuid(uuids[i + 1][0], uuids[i + 1][1]); + + assertTrue("UUID ordering should be preserved", UNSIGNED_BYTE_COMPARATOR.compare(u1, u2) < 0); + } } + // ==================== Composite Key Tests ==================== + @Test - public void testOrderPreservation() { - // Verify that smaller integers encode to smaller byte sequences (lexicographically) + public void compositeKey_tagPlusIntPreservesOrdering() { + int tag = 5; + long[] values = {Long.MIN_VALUE, -1, 0, 1, Long.MAX_VALUE}; + + for (int i = 0; i < values.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, tag); + SsFormat.appendIntIncreasing(out1, values[i]); + + SsFormat.appendCompositeTag(out2, tag); + SsFormat.appendIntIncreasing(out2, values[i + 1]); + + assertTrue( + "Composite key with " + values[i] + " should be less than with " + values[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void compositeKey_differentTagsSortByTag() { + long value = 100; + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); - SsFormat.appendIntIncreasing(out1, 100); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, 5); + SsFormat.appendIntIncreasing(out1, value); + + SsFormat.appendCompositeTag(out2, 10); + SsFormat.appendIntIncreasing(out2, value); + + assertTrue( + "Key with smaller tag should sort first", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + @Test + public void compositeKey_multipleKeyParts() { + // Simulate encoding a composite key with multiple parts: tag + int + string + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); ByteArrayOutputStream out2 = new ByteArrayOutputStream(); - SsFormat.appendIntIncreasing(out2, 200); - ByteString bs1 = ByteString.copyFrom(out1.toByteArray()); - ByteString bs2 = ByteString.copyFrom(out2.toByteArray()); + SsFormat.appendCompositeTag(out1, 1); + SsFormat.appendIntIncreasing(out1, 100); + SsFormat.appendStringIncreasing(out1, "alice"); + + SsFormat.appendCompositeTag(out2, 1); + SsFormat.appendIntIncreasing(out2, 100); + SsFormat.appendStringIncreasing(out2, "bob"); - assertTrue(ByteString.unsignedLexicographicalComparator().compare(bs1, bs2) < 0); + assertTrue( + "Keys with same prefix but different strings should order by string", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + + // ==================== Order Preservation Summary Test ==================== + + @Test + public void orderPreservation_comprehensiveIntTest() { + // Take a sample of values to avoid O(n^2) test time + int step = Math.max(1, signedIntTestValues.size() / 100); + List sample = new ArrayList<>(); + for (int i = 0; i < signedIntTestValues.size(); i += step) { + sample.add(signedIntTestValues.get(i)); + } + + // Encode all values + List encoded = new ArrayList<>(); + for (long v : sample) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, v); + encoded.add(out.toByteArray()); + } + + // Verify the encoded values are in the same order as the original values + for (int i = 0; i < sample.size() - 1; i++) { + int comparison = UNSIGNED_BYTE_COMPARATOR.compare(encoded.get(i), encoded.get(i + 1)); + assertTrue( + "Order should be preserved: " + sample.get(i) + " < " + sample.get(i + 1), + comparison < 0); + } } }