This is an automated email from the ASF dual-hosted git repository.
nizhikov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite.git
The following commit(s) were added to refs/heads/master by this push:
new a9bc0d673ae IGNITE-27088 BinaryWriter should use internal String#value
(#13529)
a9bc0d673ae is described below
commit a9bc0d673aecd559fe1f881bc8000e36c7bbac93
Author: Nikolay <[email protected]>
AuthorDate: Fri Sep 11 18:44:13 2026 +0300
IGNITE-27088 BinaryWriter should use internal String#value (#13529)
---
.../jmh/binary/JmhBinaryStringWriteBenchmark.java | 166 ++++++++++++
.../ignite/internal/binary/BinaryWriterExImpl.java | 25 +-
.../ignite/internal/binary/StringWriter.java | 281 +++++++++++++++++++++
.../ignite/IgniteCommonsSystemProperties.java | 10 +
.../direct/stream/DirectByteBufferStream.java | 12 +-
.../internal/binary/StringWriterSelfTest.java | 203 +++++++++++++++
.../testsuites/IgniteBinaryObjectsTestSuite.java | 2 +
7 files changed, 681 insertions(+), 18 deletions(-)
diff --git
a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java
b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java
new file mode 100644
index 00000000000..a2a3beb48c2
--- /dev/null
+++
b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.ignite.internal.benchmarks.jmh.binary;
+
+import org.apache.ignite.internal.benchmarks.jmh.runner.JmhIdeBenchmarkRunner;
+import org.apache.ignite.internal.binary.StringWriter;
+import org.apache.ignite.internal.binary.streams.BinaryOutputStream;
+import org.apache.ignite.internal.binary.streams.BinaryStreams;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.profile.GCProfiler;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static
org.apache.ignite.IgniteCommonsSystemProperties.IGNITE_BINARY_STRING_ZERO_COPY;
+import static org.openjdk.jmh.annotations.Mode.AverageTime;
+import static org.openjdk.jmh.annotations.Scope.Thread;
+
+/**
+ * Compares zero-copy string serialization with the legacy serialization.
+ * @see
org.apache.ignite.IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY
+ */
+@State(Thread)
+@OutputTimeUnit(NANOSECONDS)
+@BenchmarkMode(AverageTime)
+@Warmup(iterations = 5, time = 5, timeUnit = SECONDS)
+@Measurement(iterations = 5, time = 10, timeUnit = SECONDS)
+public class JmhBinaryStringWriteBenchmark {
+ /** */
+ @Param({"true", "false"})
+ private boolean zeroCopy;
+
+ /** */
+ @Param({"8", "64", "512", "4096"})
+ private int len;
+
+ /** */
+ @Param({"ascii", "latin1", "cyrillic", "mixed"})
+ private String content;
+
+ /** */
+ private BinaryOutputStream out;
+
+ /** */
+ private String str;
+
+ /** */
+ public static void main(String[] args) throws Exception {
+ OptionsBuilder builder = JmhIdeBenchmarkRunner.create()
+ .forks(1)
+ .benchmarks(JmhBinaryStringWriteBenchmark.class.getName())
+ .profilers(GCProfiler.class)
+ .optionsBuilder();
+
+ new Runner(builder.build()).run();
+ }
+
+ /** */
+ @Setup
+ public void setup() {
+ // Must be set before the first use of StringWriter in this JVM.
+ System.setProperty(IGNITE_BINARY_STRING_ZERO_COPY,
String.valueOf(zeroCopy));
+
+ StringBuilder sb = new StringBuilder(len);
+
+ for (int i = 0; sb.length() < len; i++) {
+ switch (content) {
+ case "ascii":
+ sb.append((char)('a' + i % 26));
+
+ break;
+
+ case "latin1":
+ // Every 8th char is a Latin-1 char with the sign bit set.
+ sb.append(i % 8 == 7 ? (char)(0xC0 + i % 0x20) :
(char)('a' + i % 26));
+
+ break;
+
+ case "cyrillic":
+ sb.append((char)('\u0410' + i % 32));
+
+ break;
+
+ case "mixed":
+ // ASCII, Latin-1, 2-byte, 3-byte chars and a surrogate
pair.
+ switch (i % 5) {
+ case 0:
+ sb.append((char)('a' + i % 26));
+
+ break;
+
+ case 1:
+ sb.append('\u00e9');
+
+ break;
+
+ case 2:
+ sb.append('\u0416');
+
+ break;
+
+ case 3:
+ sb.append('\u20ac');
+
+ break;
+
+ default:
+ sb.append("\ud83d\ude00");
+ }
+
+ break;
+
+ default:
+ throw new IllegalArgumentException("Unknown content type:
" + content);
+ }
+ }
+
+ str = sb.toString();
+
+ out = BinaryStreams.outputStream(4 * len + 64);
+ }
+
+ /** */
+ @TearDown
+ public void tearDown() {
+ out.close();
+ }
+
+ /** */
+ @Benchmark
+ public void writeString(Blackhole bh) {
+ out.position(0);
+
+ if (zeroCopy)
+ StringWriter.write(str, out);
+ else
+ StringWriter.writeStringLegacy(str, out);
+
+ bh.consume(out.position());
+ }
+}
diff --git
a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java
b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java
index 176a1ec9eb1..d2eded81638 100644
---
a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java
+++
b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java
@@ -30,6 +30,7 @@ import java.util.Date;
import java.util.Map;
import java.util.UUID;
import org.apache.ignite.IgniteCheckedException;
+import org.apache.ignite.IgniteCommonsSystemProperties;
import org.apache.ignite.binary.BinaryObjectException;
import org.apache.ignite.binary.BinaryRawWriter;
import org.apache.ignite.internal.UnregisteredClassException;
@@ -40,13 +41,17 @@ import org.apache.ignite.internal.util.typedef.internal.A;
import org.apache.ignite.marshaller.Marshallers;
import org.jetbrains.annotations.Nullable;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.ignite.IgniteCommonsSystemProperties.DFLT_ZERO_COPY;
+import static
org.apache.ignite.IgniteCommonsSystemProperties.IGNITE_BINARY_STRING_ZERO_COPY;
import static org.apache.ignite.internal.util.CommonUtils.MAX_ARRAY_SIZE;
/**
* Binary writer implementation.
*/
class BinaryWriterExImpl implements BinaryWriterEx {
+ /** Zero-copy serialization enabled flag. */
+ static final boolean ZERO_COPY =
IgniteCommonsSystemProperties.getBoolean(IGNITE_BINARY_STRING_ZERO_COPY,
DFLT_ZERO_COPY);
+
/** Length: integer. */
private static final int LEN_INT = 4;
@@ -733,20 +738,10 @@ class BinaryWriterExImpl implements BinaryWriterEx {
@Override public void writeString(@Nullable String val) throws
BinaryObjectException {
if (val == null)
out.writeByte(GridBinaryMarshaller.NULL);
- else {
- byte[] strArr;
-
- if (BinaryUtils.USE_STR_SERIALIZATION_VER_2)
- strArr = BinaryUtils.strToUtf8Bytes(val);
- else
- strArr = val.getBytes(UTF_8);
-
- out.unsafeEnsure(1 + 4);
- out.unsafeWriteByte(GridBinaryMarshaller.STRING);
- out.unsafeWriteInt(strArr.length);
-
- out.writeByteArray(strArr);
- }
+ else if (ZERO_COPY)
+ StringWriter.write(val, out);
+ else
+ StringWriter.writeStringLegacy(val, out);
}
/** {@inheritDoc} */
diff --git
a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java
b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java
new file mode 100644
index 00000000000..36e77925cb2
--- /dev/null
+++
b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java
@@ -0,0 +1,281 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.ignite.internal.binary;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import org.apache.ignite.IgniteCommonsSystemProperties;
+import org.apache.ignite.internal.binary.streams.BinaryOutputStream;
+import org.apache.ignite.internal.util.GridUnsafe;
+import org.apache.ignite.lang.IgniteBiTuple;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.ignite.internal.binary.BinaryWriterExImpl.ZERO_COPY;
+
+/**
+ * Writes {@link String} values to a {@link BinaryOutputStream} in UTF-8
without allocation of temporary byte arrays.
+ *
+ * @see IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY
+ */
+public final class StringWriter {
+ /** Latin-1 value of the {@code java.lang.String#coder} field. */
+ private static final byte LATIN1 = 0;
+
+ /** Offset of the {@code java.lang.String#value} field, or {@code -1} if
the compact string fast path is unavailable. */
+ private static final long STR_VALUE_OFF;
+
+ /** Offset of the {@code java.lang.String#coder} field, or {@code -1} if
the compact string fast path is unavailable. */
+ private static final long STR_CODER_OFF;
+
+ static {
+ IgniteBiTuple<Long, Long> result = fieldsOffsets();
+
+ STR_VALUE_OFF = result.get1();
+ STR_CODER_OFF = result.get2();
+ }
+
+ /**
+ * Handle of the intrinsified {@code java.lang.StringCoding#hasNegatives},
or {@code null} if unavailable.
+ * The intrinsic scans the array with SIMD instructions, far faster than
any scalar loop.
+ */
+ private static final MethodHandle HAS_NEGATIVES = hasNegatives();
+
+ /** */
+ private StringWriter() {
+ // No-op.
+ }
+
+ /**
+ * Writes a string to the output stream.
+ *
+ * @param val Value.
+ * @param out Output stream.
+ */
+ public static void write(@NotNull String val, BinaryOutputStream out) {
+ // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for
length.
+ out.unsafeEnsure(1 + 4);
+ out.unsafeWriteByte(GridBinaryMarshaller.STRING);
+
+ int lenPos = out.position();
+
+ out.unsafePosition(out.position() + 4);
+
+ int written;
+
+ byte[] latin1 = latin1Value(val);
+
+ if (latin1 != null) {
+ if (out.hasArray() && !hasNegatives(latin1)) {
+ out.unsafeEnsure(latin1.length);
+ // Pure ASCII: UTF-8 representation matches the internal
array, copy it as-is.
+ System.arraycopy(latin1, 0, out.array(), out.position(),
latin1.length);
+
+ written = latin1.length;
+
+ out.unsafePosition(out.position() + written);
+ }
+ else
+ written = writeLatin1(latin1, out);
+ }
+ else
+ written = writeChars(val, out);
+
+ out.unsafeWriteInt(lenPos, written);
+ }
+
+ /**
+ * Writes a Latin-1 encoded string value to the stream.
+ *
+ * @param val Internal Latin-1 array of the string.
+ * @param out Output stream.
+ * @return Number of bytes written.
+ */
+ private static int writeLatin1(byte[] val, BinaryOutputStream out) {
+ out.unsafeEnsure(Math.addExact(val.length, val.length));
+
+ int start = out.position();
+
+ for (int i = 0; i < val.length; i++) {
+ byte b = val[i];
+
+ if (b >= 0)
+ out.unsafeWriteByte(b);
+ else {
+ int c = b & 0b1111_1111;
+
+ out.unsafeWriteByte((byte)(0b1100_0000 | (c >> 6)));
+ out.unsafeWriteByte((byte)(0b1000_0000 | (c & 0b0011_1111)));
+ }
+ }
+
+ return out.position() - start;
+ }
+
+ /**
+ * Writes string chars UTF-8 encoded to the stream. Replicates {@code
String#getBytes(UTF_8)} behavior exactly,
+ * including replacement of malformed surrogates with {@code '?'}. Stream
capacity must be ensured by the caller.
+ *
+ * @param val Value.
+ * @param out Output stream.
+ * @return Number of bytes written.
+ */
+ private static int writeChars(String val, BinaryOutputStream out) {
+ // Allocating memory for worst case - 3 bytes per char.
+ out.unsafeEnsure(Math.multiplyExact(3, val.length()));
+
+ int start = out.position();
+ int len = val.length();
+
+ for (int i = 0; i < len; i++) {
+ char c = val.charAt(i);
+
+ if (c < 0x80)
+ out.unsafeWriteByte((byte)c);
+ else if (c < 0x800) {
+ out.unsafeWriteByte((byte)(0b11_000000 | (c >> 6)));
+ out.unsafeWriteByte((byte)(0b10_000000 | (c & 0b00_111111)));
+ }
+ else if (!Character.isSurrogate(c)) {
+ out.unsafeWriteByte((byte)(0b1110_0000 | (c >> 12)));
+ out.unsafeWriteByte((byte)(0b1000_0000 | ((c >> 6) &
0b0011_1111)));
+ out.unsafeWriteByte((byte)(0b1000_0000 | (c & 0b0011_1111)));
+ }
+ else {
+ char c2;
+
+ if (Character.isHighSurrogate(c) && i + 1 < len &&
Character.isLowSurrogate(c2 = val.charAt(i + 1))) {
+ int cp = Character.toCodePoint(c, c2);
+
+ out.unsafeWriteByte((byte)(0b1111_0000 | (cp >> 18)));
+ out.unsafeWriteByte((byte)(0b1000_0000 | ((cp >> 12) &
0b0011_1111)));
+ out.unsafeWriteByte((byte)(0b1000_0000 | ((cp >> 6) &
0b0011_1111)));
+ out.unsafeWriteByte((byte)(0b1000_0000 | (cp &
0b0011_1111)));
+
+ i++;
+ }
+ else
+ out.unsafeWriteByte((byte)'?');
+ }
+ }
+
+ return out.position() - start;
+ }
+
+ /**
+ * @param val String.
+ * @return Internal Latin-1 array of the string,
+ * or {@code null} if the string is UTF-16 encoded or the internal
layout of {@link String} is unknown.
+ */
+ @Nullable public static byte[] latin1Value(String val) {
+ if (STR_VALUE_OFF < 0 || GridUnsafe.getByteField(val, STR_CODER_OFF)
!= LATIN1)
+ return null;
+
+ return (byte[])GridUnsafe.getObjectField(val, STR_VALUE_OFF);
+ }
+
+ /**
+ * @param arr Array.
+ * @return {@code True} if the array contains a byte with the sign bit set.
+ */
+ public static boolean hasNegatives(byte[] arr) {
+ if (HAS_NEGATIVES != null) {
+ try {
+ return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length);
+ }
+ catch (Throwable ignored) {
+ // Fall through to the generic implementation.
+ }
+ }
+
+ // 8-byte strides with an early exit.
+ int i = 0;
+
+ for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) {
+ long hasNegatives = GridUnsafe.getLong(arr,
GridUnsafe.BYTE_ARR_OFF + i)
+ &
0b10000000_10000000_10000000_10000000_10000000_10000000_10000000_10000000L;
+
+ if (hasNegatives != 0)
+ return true;
+ }
+
+ for (; i < arr.length; i++) {
+ if (arr[i] < 0)
+ return true;
+ }
+
+ return false;
+ }
+
+ /** */
+ private static IgniteBiTuple<Long, Long> fieldsOffsets() {
+ if (ZERO_COPY) {
+ try {
+ Field valField = String.class.getDeclaredField("value");
+ Field coderField = String.class.getDeclaredField("coder");
+
+ return new IgniteBiTuple<>(
+ GridUnsafe.objectFieldOffset(valField),
+ GridUnsafe.objectFieldOffset(coderField)
+ );
+ }
+ catch (Throwable ignored) {
+ // No-op.
+ }
+ }
+
+ return new IgniteBiTuple<>(-1L, -1L);
+ }
+
+ /** */
+ private static @Nullable MethodHandle hasNegatives() {
+ if (ZERO_COPY) {
+ try {
+ Method mtd =
Class.forName("java.lang.StringCoding").getDeclaredMethod("hasNegatives",
byte[].class, int.class, int.class);
+
+ mtd.setAccessible(true);
+
+ return MethodHandles.lookup().unreflect(mtd);
+ }
+ catch (Throwable ignored) {
+ // No-op.
+ }
+ }
+
+ return null;
+ }
+
+ /** */
+ public static void writeStringLegacy(@NotNull String val,
BinaryOutputStream out) {
+ byte[] strArr;
+
+ if (BinaryUtils.USE_STR_SERIALIZATION_VER_2)
+ strArr = BinaryUtils.strToUtf8Bytes(val);
+ else
+ strArr = val.getBytes(UTF_8);
+
+ out.unsafeEnsure(1 + 4);
+ out.unsafeWriteByte(GridBinaryMarshaller.STRING);
+ out.unsafeWriteInt(strArr.length);
+
+ out.writeByteArray(strArr);
+ }
+}
diff --git
a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java
b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java
index ec443398ce3..62e4b5f8318 100644
---
a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java
+++
b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java
@@ -38,6 +38,9 @@ public class IgniteCommonsSystemProperties {
/** Default value of {@link
IgniteCommonsSystemProperties#IGNITE_USE_BINARY_ARRAYS}. */
public static final boolean DFLT_IGNITE_USE_BINARY_ARRAYS = false;
+ /** Default value of {@link
IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY}. */
+ public static final boolean DFLT_ZERO_COPY = true;
+
/**
* Setting to {@code true} enables writing sensitive information in {@code
toString()} output.
*/
@@ -128,6 +131,13 @@ public class IgniteCommonsSystemProperties {
public static final String
IGNITE_BINARY_MARSHALLER_USE_STRING_SERIALIZATION_VER_2 =
"IGNITE_BINARY_MARSHALLER_USE_STRING_SERIALIZATION_VER_2";
+ /**
+ * Enables zero-copy UTF-8 serialization of {@link String} values.
+ * Default value is {@code true}.
+ */
+ @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String
values", defaults = "" + DFLT_ZERO_COPY)
+ public static final String IGNITE_BINARY_STRING_ZERO_COPY =
"IGNITE_BINARY_STRING_ZERO_COPY";
+
/**
* Enables storage of typed arrays.
* The default value is {@code BinaryUtils#DFLT_IGNITE_USE_BINARY_ARRAYS}.
diff --git
a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java
b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java
index c0752343b54..d2176dfb6f6 100644
---
a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java
+++
b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java
@@ -19,6 +19,7 @@ package org.apache.ignite.internal.direct.stream;
import java.lang.reflect.Array;
import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collection;
@@ -33,6 +34,7 @@ import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import org.apache.ignite.IgniteCheckedException;
import org.apache.ignite.IgniteException;
+import org.apache.ignite.internal.binary.StringWriter;
import org.apache.ignite.internal.managers.communication.CompressedMessage;
import org.apache.ignite.internal.processors.affinity.AffinityTopologyVersion;
import org.apache.ignite.internal.processors.cache.CacheObject;
@@ -733,8 +735,12 @@ public class DirectByteBufferStream {
*/
public void writeString(String val) {
if (val != null) {
- if (curStrBackingArr == null)
- curStrBackingArr = val.getBytes();
+ if (curStrBackingArr == null) {
+ curStrBackingArr = StringWriter.latin1Value(val);
+
+ if (curStrBackingArr == null ||
StringWriter.hasNegatives(curStrBackingArr))
+ curStrBackingArr = val.getBytes(StandardCharsets.UTF_8);
+ }
writeByteArray(curStrBackingArr);
@@ -1362,7 +1368,7 @@ public class DirectByteBufferStream {
public String readString() {
byte[] arr = readByteArray();
- return arr != null ? new String(arr) : null;
+ return arr != null ? new String(arr, StandardCharsets.UTF_8) : null;
}
/**
diff --git
a/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java
b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java
new file mode 100644
index 00000000000..da8312895e1
--- /dev/null
+++
b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.ignite.internal.binary;
+
+import java.util.Arrays;
+import java.util.concurrent.ThreadLocalRandom;
+import org.apache.ignite.internal.binary.streams.BinaryOutputStream;
+import org.apache.ignite.internal.binary.streams.BinaryStreams;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * Tests that {@link StringWriter} output is byte-identical to serialization
of the {@link String#getBytes()} result,
+ * which was used before zero-copy string serialization was introduced.
+ */
+public class StringWriterSelfTest extends GridCommonAbstractTest {
+ /** */
+ public static final int ASCII_MAX = 0x80;
+
+ /** */
+ public static final int LATIN1_MAX = 0x100;
+
+ /** */
+ public static final int TWO_BYTES_MAX = 0x800;
+
+ /** */
+ public static final int CHAR_HIGH_BOUND = 0x10000;
+
+ /** Tests for all encoder paths: ASCII bulk copy, Latin-1, generic UTF-16
and malformed surrogates. */
+ @Test
+ public void testCorpus() {
+ String[] cases = {
+ "",
+ "a",
+ "?",
+ "abcdefghijklmnopqrstuvwxyz0123456789", // Long ASCII: exercises
the 8-byte stride scan and bulk copy.
+ "caf\u00e9", // Latin-1 with a negative
byte.
+ "\u00ff\u0080\u00a0", // Latin-1, negative bytes
only.
+ "\u041f\u0440\u0438\u0432\u0435\u0442", // Cyrillic: 2-byte UTF-8
sequences.
+ "\u0800\u1234\uffff", // 3-byte UTF-8 sequences.
+ "\ud83d\ude00", // Emoji: valid surrogate
pair.
+ "a\ud83d\ude00b\u00e9\u0416\u0001", // Mixed content.
+ "\ud800", // Lone high surrogate.
+ "\udc00", // Lone low surrogate.
+ "a\ud800", // High surrogate at the
end.
+ "\ud800a", // High surrogate followed
by a regular char.
+ "\ud800\ud800", // Two high surrogates.
+ "\udc00\ud800", // Low surrogate before a
high one.
+ "\u0000", // NUL char.
+ "nul\u0000nul"
+ };
+
+ for (String str : cases)
+ check(str);
+ }
+
+ /** Randomized differential test against {@link String#getBytes()}. */
+ @Test
+ public void testRandomStrings() {
+ ThreadLocalRandom rnd = ThreadLocalRandom.current();
+
+ for (int iter = 0; iter < 100; iter++) {
+ StringBuilder sb = new StringBuilder(1 + rnd.nextInt(42));
+
+ for (int i = 0; i < sb.capacity(); i++) {
+ int bucket = rnd.nextInt(100);
+
+ char c;
+
+ if (bucket < 40)
+ // ASCII.
+ c = (char)rnd.nextInt(ASCII_MAX);
+ else if (bucket < 55)
+ // Latin-1.
+ c = (char)(ASCII_MAX + rnd.nextInt(LATIN1_MAX -
ASCII_MAX));
+ else if (bucket < 65)
+ // Other 2-byte chars.
+ c = (char)(LATIN1_MAX + rnd.nextInt(TWO_BYTES_MAX -
LATIN1_MAX));
+ else if (bucket < 75)
+ // 3-byte chars.
+ c = (char)(TWO_BYTES_MAX +
rnd.nextInt(Character.MIN_SURROGATE - TWO_BYTES_MAX));
+ else if (bucket < 90)
+ // Surrogates, mostly malformed.
+ c = (char)(Character.MIN_SURROGATE +
rnd.nextInt((Character.MAX_SURROGATE + 1) - Character.MIN_SURROGATE));
+ else
+ // 3-byte chars above the surrogate range.
+ c = (char)((Character.MAX_SURROGATE + 1) +
rnd.nextInt(CHAR_HIGH_BOUND - (Character.MAX_SURROGATE + 1)));
+
+ sb.append(c);
+ }
+
+ assertFalse(sb.isEmpty());
+
+ check(sb.toString());
+ }
+ }
+
+ /** Tests strings whose UTF-8 form is larger than the stream's minimal
capacity. */
+ @Test
+ public void testLargeStrings() {
+ int len = 100_000;
+
+ StringBuilder ascii = new StringBuilder(len);
+ StringBuilder latin1 = new StringBuilder(len);
+ StringBuilder cyrillic = new StringBuilder(len);
+ StringBuilder mixed = new StringBuilder(len * 6);
+
+ for (int i = 0; i < len; i++) {
+ ascii.append((char)('a' + i % 26));
+ // Every char is a Latin-1 char with the sign bit set: worst case
for the 2-bytes-per-char reservation.
+ latin1.append((char)(ASCII_MAX + i % (LATIN1_MAX - ASCII_MAX)));
+ cyrillic.append((char)('\u0410' + i % 32));
+ mixed.append((char)('a' + i %
26)).append('\u00e9').append('\u0416').append('\u20ac').append("\ud83d\ude00");
+ }
+
+ check(ascii.toString());
+ check(latin1.toString());
+ check(cyrillic.toString());
+ check(mixed.toString());
+ }
+
+ /** Tests that the stream position is correct after a string write, so
surrounding values are not corrupted. */
+ @Test
+ public void testStreamPosition() {
+ int int1 = 0xDEADBEEF;
+ String str1 = "caf\u00e9";
+ String str2 = "\ud83d\ude00";
+ int int2 = 0xCAFEBABE;
+
+ // Small initial capacity to check buffer reallocation.
+ try (BinaryOutputStream out = BinaryStreams.outputStream(2)) {
+ out.writeInt(int1);
+ StringWriter.write(str1, out);
+ StringWriter.write(str2, out);
+ out.writeInt(int2);
+
+ byte[] strBytes1 = strBytes(str1);
+ byte[] strBytes2 = strBytes(str2);
+
+ byte[] exp = new byte[Integer.BYTES + strBytes1.length +
strBytes2.length + Integer.BYTES];
+
+ System.arraycopy(intBytes(int1), 0, exp, 0, Integer.BYTES);
+ System.arraycopy(strBytes1, 0, exp, Integer.BYTES,
strBytes1.length);
+ System.arraycopy(strBytes2, 0, exp, Integer.BYTES +
strBytes1.length, strBytes2.length);
+ System.arraycopy(intBytes(int2), 0, exp, Integer.BYTES +
strBytes1.length + strBytes2.length, Integer.BYTES);
+
+ assertTrue(Arrays.equals(exp, out.arrayCopy()));
+ }
+ }
+
+ /**
+ * Checks that serialized form of the given string is byte-identical to
serialization of the {@link String#getBytes()} result.
+ * @param str String to check.
+ */
+ private void check(String str) {
+ try (BinaryOutputStream out = BinaryStreams.outputStream(1)) {
+ StringWriter.write(str, out);
+
+ assertTrue("String serialization mismatch: " + str,
Arrays.equals(strBytes(str), out.arrayCopy()));
+ }
+ }
+
+ /**
+ * @param str String.
+ * @return Expected serialized form of the string: flag, UTF-8 length and
UTF-8 bytes.
+ */
+ private static byte[] strBytes(String str) {
+ byte[] bytes = str.getBytes(UTF_8);
+ byte[] res = new byte[Byte.BYTES + Integer.BYTES + bytes.length];
+
+ res[0] = GridBinaryMarshaller.STRING;
+
+ System.arraycopy(intBytes(bytes.length), 0, res, 1, 4);
+ System.arraycopy(bytes, 0, res, 5, bytes.length);
+
+ return res;
+ }
+
+ /**
+ * @param val Value.
+ * @return Little-endian representation of the value.
+ */
+ private static byte[] intBytes(int val) {
+ return new byte[] {(byte)val, (byte)(val >> 8), (byte)(val >> 16),
(byte)(val >> 24)};
+ }
+}
diff --git
a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
index 69b639df6b1..bdc9230a585 100644
---
a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
+++
b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
@@ -46,6 +46,7 @@ import
org.apache.ignite.internal.binary.GridBinaryWildcardsSelfTest;
import
org.apache.ignite.internal.binary.GridDefaultBinaryMappersBinaryMetaDataSelfTest;
import
org.apache.ignite.internal.binary.GridSimpleLowerCaseBinaryMappersBinaryMetaDataSelfTest;
import org.apache.ignite.internal.binary.RawBinaryObjectExtractorTest;
+import org.apache.ignite.internal.binary.StringWriterSelfTest;
import
org.apache.ignite.internal.binary.builder.BinaryObjectBuilderAdditionalSelfTest;
import
org.apache.ignite.internal.binary.noncompact.BinaryFieldsHeapNonCompactSelfTest;
import
org.apache.ignite.internal.binary.noncompact.BinaryFieldsOffheapNonCompactSelfTest;
@@ -107,6 +108,7 @@ import org.junit.runners.Suite;
BinaryTreeSelfTest.class,
BinaryMarshallerSelfTest.class,
+ StringWriterSelfTest.class,
BinaryObjectExceptionSelfTest.class,
BinarySerialiedFieldComparatorSelfTest.class,