This is an automated email from the ASF dual-hosted git repository.

ptupitsyn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 48bbe6e0629 IGNITE-28012 Client protocol: Fix 
ErrorExtensions.SQL_UPDATE_COUNTERS handling (#7836)
48bbe6e0629 is described below

commit 48bbe6e0629df0960fbb5e9cb48de6f7b845f8ee
Author: Pavel Tupitsyn <[email protected]>
AuthorDate: Tue Mar 24 13:06:52 2026 +0100

    IGNITE-28012 Client protocol: Fix ErrorExtensions.SQL_UPDATE_COUNTERS 
handling (#7836)
---
 .../org/apache/ignite/sql/SqlBatchException.java   |  2 +-
 .../internal/client/proto/ClientMessagePacker.java | 21 ++++++++++
 .../client/proto/ClientMessageUnpacker.java        | 27 +++++++++++++
 .../internal/client/proto/ErrorExtensions.java     |  5 +++
 .../client/proto/ProtocolBitmaskFeature.java       |  7 +++-
 .../client/proto/ClientMessagePackerTest.java      | 40 ++++++++++++++++++
 .../proto/ClientMessagePackerUnpackerTest.java     | 47 ++++++++++++++++++++++
 .../ignite/client/handler/ItClientHandlerTest.java |  1 +
 .../ignite/client/handler/ClientHandlerModule.java |  3 +-
 .../handler/ClientInboundMessageHandler.java       | 37 ++++++++++-------
 .../ignite/internal/client/TcpClientChannel.java   |  7 +++-
 .../internal/client/ClientCompatibilityTests.java  | 44 ++++++++++++++++++++
 ...ldClientWithCurrentServerCompatibilityTest.java |  9 +++++
 .../cpp/ignite/protocol/bitmask_feature.h          |  6 ++-
 modules/platforms/cpp/ignite/protocol/reader.h     | 40 ++++++++++++++++++
 modules/platforms/cpp/ignite/protocol/utils.cpp    |  6 +++
 modules/platforms/cpp/ignite/protocol/utils.h      |  1 +
 .../dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs     | 22 ++++++++++
 .../dotnet/Apache.Ignite/Internal/ClientSocket.cs  |  5 ++-
 .../Internal/Proto/ProtocolBitmaskFeature.cs       |  7 +++-
 20 files changed, 315 insertions(+), 22 deletions(-)

diff --git 
a/modules/api/src/main/java/org/apache/ignite/sql/SqlBatchException.java 
b/modules/api/src/main/java/org/apache/ignite/sql/SqlBatchException.java
index 5a7fa853da6..13613b5c262 100644
--- a/modules/api/src/main/java/org/apache/ignite/sql/SqlBatchException.java
+++ b/modules/api/src/main/java/org/apache/ignite/sql/SqlBatchException.java
@@ -45,7 +45,7 @@ public class SqlBatchException extends SqlException {
      * @param message Detailed message.
      * @param cause Optional cause.
      */
-    public SqlBatchException(UUID traceId, int code, long[] updCntrs, String 
message, @Nullable Throwable cause) {
+    public SqlBatchException(UUID traceId, int code, long @Nullable [] 
updCntrs, String message, @Nullable Throwable cause) {
         super(traceId, code, message, cause);
 
         this.updCntrs = updCntrs != null ? updCntrs : LONG_EMPTY_ARRAY;
diff --git 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessagePacker.java
 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessagePacker.java
index 63f6da5b368..52ef39b0cbf 100644
--- 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessagePacker.java
+++ 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessagePacker.java
@@ -630,6 +630,27 @@ public class ClientMessagePacker implements AutoCloseable {
         }
     }
 
+    /**
+     * Writes a long array as a single binary value.
+     *
+     * @param arr Long array value.
+     */
+    public void packLongArrayAsBinary(long @Nullable [] arr) {
+        assert !closed : "Packer is closed";
+
+        if (arr == null) {
+            packNil();
+
+            return;
+        }
+
+        packBinaryHeader(arr.length * 8);
+
+        for (long value : arr) {
+            buf.writeLong(value);
+        }
+    }
+
     /**
      * Packs an array of objects in BinaryTuple format.
      *
diff --git 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessageUnpacker.java
 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessageUnpacker.java
index 4b010fd0fdb..8ac5be8e9b1 100644
--- 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessageUnpacker.java
+++ 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientMessageUnpacker.java
@@ -791,6 +791,33 @@ public class ClientMessageUnpacker implements 
AutoCloseable {
         return res;
     }
 
+    /**
+     * Reads a long array from a single binary value.
+     *
+     * @return Array of longs.
+     */
+    public long @Nullable [] unpackLongArrayAsBinary() {
+        assert refCnt > 0 : "Unpacker is closed";
+
+        if (tryUnpackNil()) {
+            return null;
+        }
+
+        int binSize = unpackBinaryHeader();
+
+        if (binSize % 8 != 0) {
+            throw new MessageFormatException("Binary size should be a multiple 
of 8, but was " + binSize);
+        }
+
+        long[] res = new long[binSize / 8];
+
+        for (int i = 0; i < res.length; i++) {
+            res[i] = buf.readLong();
+        }
+
+        return res;
+    }
+
     /**
      * Unpacks batch of arguments from binary tuples.
      *
diff --git 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ErrorExtensions.java
 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ErrorExtensions.java
index de99dda7b21..c55f121e982 100644
--- 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ErrorExtensions.java
+++ 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ErrorExtensions.java
@@ -23,8 +23,13 @@ package org.apache.ignite.internal.client.proto;
 public class ErrorExtensions {
     public static final String EXPECTED_SCHEMA_VERSION = "expected-schema-ver";
 
+    // Problematic array format that can't be skipped by clients.
+    @Deprecated
     public static final String SQL_UPDATE_COUNTERS = "sql-update-counters";
 
+    // New format with single binary value for counters.
+    public static final String SQL_UPDATE_COUNTERS_2 = "sql-update-counters-2";
+
     public static final String DELAYED_ACK = "delayed-ack";
 
     public static final String TX_KILL = "tx-kill";
diff --git 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ProtocolBitmaskFeature.java
 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ProtocolBitmaskFeature.java
index a908678aebc..de7ddd28298 100644
--- 
a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ProtocolBitmaskFeature.java
+++ 
b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ProtocolBitmaskFeature.java
@@ -112,7 +112,12 @@ public enum ProtocolBitmaskFeature {
     /**
      * Send discard requests to directly mapped partitions.
      */
-    TX_DIRECT_MAPPING_SEND_DISCARD(17);
+    TX_DIRECT_MAPPING_SEND_DISCARD(17),
+
+    /**
+     * Client supports SQL_UPDATE_COUNTERS_2 error extension (single binary 
value instead of array).
+     */
+    SQL_UPDATE_COUNTERS_2(18);
 
     private static final EnumSet<ProtocolBitmaskFeature> 
ALL_FEATURES_AS_ENUM_SET =
             EnumSet.allOf(ProtocolBitmaskFeature.class);
diff --git 
a/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerTest.java
 
b/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerTest.java
index 6f338dadd2d..95fc8193576 100644
--- 
a/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerTest.java
+++ 
b/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerTest.java
@@ -178,6 +178,46 @@ public class ClientMessagePackerTest {
         testPacker(p -> p.writePayload(b, 1, 1), p -> p.writePayload(b, 1, 1));
     }
 
+    @Test
+    public void testPackLongArrayAsBinaryFormat() throws IOException {
+        long[] arr = {1L, 2L, Long.MAX_VALUE, Long.MIN_VALUE};
+        byte[] packed = packIgnite(p -> p.packLongArrayAsBinary(arr));
+
+        try (var unpacker = MessagePack.newDefaultUnpacker(packed)) {
+            int binarySize = unpacker.unpackBinaryHeader();
+            assertEquals(arr.length * 8, binarySize, "Binary size should be 
array length * 8 bytes");
+
+            byte[] bytes = unpacker.readPayload(binarySize);
+            ByteBuffer buffer = ByteBuffer.wrap(bytes);
+
+            for (long expected : arr) {
+                long actual = buffer.getLong();
+                assertEquals(expected, actual, "Long value should match");
+            }
+        }
+    }
+
+    @Test
+    public void testPackLongArrayAsBinaryNull() throws IOException {
+        byte[] packed = packIgnite(p -> p.packLongArrayAsBinary(null));
+
+        try (var unpacker = MessagePack.newDefaultUnpacker(packed)) {
+            // Should be nil
+            unpacker.unpackNil();
+        }
+    }
+
+    @Test
+    public void testPackLongArrayAsBinaryEmpty() throws IOException {
+        byte[] packed = packIgnite(p -> p.packLongArrayAsBinary(new long[0]));
+
+        try (var unpacker = MessagePack.newDefaultUnpacker(packed)) {
+            // Should be binary with size 0
+            int binarySize = unpacker.unpackBinaryHeader();
+            assertEquals(0, binarySize);
+        }
+    }
+
     private interface MessagePackerConsumer {
         void accept(MessagePacker p) throws IOException;
     }
diff --git 
a/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerUnpackerTest.java
 
b/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerUnpackerTest.java
index 9e486eb29d4..3f8278ab80d 100644
--- 
a/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerUnpackerTest.java
+++ 
b/modules/client-common/src/test/java/org/apache/ignite/internal/client/proto/ClientMessagePackerUnpackerTest.java
@@ -36,6 +36,7 @@ import java.util.BitSet;
 import java.util.Random;
 import java.util.UUID;
 import org.apache.ignite.table.QualifiedName;
+import org.jetbrains.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -246,4 +247,50 @@ public class ClientMessagePackerUnpackerTest {
             }
         }
     }
+
+    @Test
+    public void testLongArrayAsBinary() {
+        long[] arr = {1L, 2L, 3L, 100L, 1000L, 10000L};
+        assertArrayEquals(arr, packUnpackLongArrayAsBinary(arr));
+    }
+
+    @Test
+    public void testLongArrayAsBinaryEmpty() {
+        long[] arr = new long[0];
+        long[] res = packUnpackLongArrayAsBinary(arr);
+
+        assertArrayEquals(arr, res);
+    }
+
+    @Test
+    public void testLongArrayAsBinaryNull() {
+        assertNull(packUnpackLongArrayAsBinary(null));
+    }
+
+    @Test
+    public void testLongArrayAsBinarySingleElement() {
+        long[] arr = {42L};
+        assertArrayEquals(arr, packUnpackLongArrayAsBinary(arr));
+    }
+
+    @Test
+    public void testLongArrayAsBinaryMinMaxValues() {
+        long[] arr = {Long.MIN_VALUE, Long.MAX_VALUE, 0L, -1L, 1L};
+        assertArrayEquals(arr, packUnpackLongArrayAsBinary(arr));
+    }
+
+    private static long @Nullable [] packUnpackLongArrayAsBinary(long 
@Nullable [] arr) {
+        try (var packer = new 
ClientMessagePacker(PooledByteBufAllocator.DEFAULT.directBuffer())) {
+            packer.packLongArrayAsBinary(arr);
+
+            var buf = packer.getBuffer();
+            byte[] data = new byte[buf.readableBytes()];
+            buf.readBytes(data);
+
+            try (var unpacker = new 
ClientMessageUnpacker(Unpooled.wrappedBuffer(data))) {
+                unpacker.skipValues(4);
+                return unpacker.unpackLongArrayAsBinary();
+            }
+        }
+    }
 }
diff --git 
a/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItClientHandlerTest.java
 
b/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItClientHandlerTest.java
index 88ecf25bfa8..b87b84c8ef1 100644
--- 
a/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItClientHandlerTest.java
+++ 
b/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItClientHandlerTest.java
@@ -564,6 +564,7 @@ public class ItClientHandlerTest extends 
BaseIgniteAbstractTest {
             expected.set(15);
             expected.set(16);
             expected.set(17);
+            expected.set(18);
 
             assertEquals(expected, supportedFeatures);
 
diff --git 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
index 802aa726f6d..b7e6a357fc6 100644
--- 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
+++ 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
@@ -102,7 +102,8 @@ public class ClientHandlerModule implements 
IgniteComponent, PlatformComputeTran
             ProtocolBitmaskFeature.COMPUTE_OBSERVABLE_TS,
             ProtocolBitmaskFeature.TX_DIRECT_MAPPING_SEND_REMOTE_WRITES,
             ProtocolBitmaskFeature.SQL_PARTITION_AWARENESS_TABLE_NAME,
-            ProtocolBitmaskFeature.TX_DIRECT_MAPPING_SEND_DISCARD
+            ProtocolBitmaskFeature.TX_DIRECT_MAPPING_SEND_DISCARD,
+            ProtocolBitmaskFeature.SQL_UPDATE_COUNTERS_2
     ));
 
     /** Connection id generator.
diff --git 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientInboundMessageHandler.java
 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientInboundMessageHandler.java
index 16b3a90fbc0..36348e5c90f 100644
--- 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientInboundMessageHandler.java
+++ 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientInboundMessageHandler.java
@@ -21,6 +21,7 @@ import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.SQL
 import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.SQL_MULTISTATEMENT_SUPPORT;
 import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.SQL_PARTITION_AWARENESS;
 import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.SQL_PARTITION_AWARENESS_TABLE_NAME;
+import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.SQL_UPDATE_COUNTERS_2;
 import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.STREAMER_RECEIVER_EXECUTION_OPTIONS;
 import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.TX_ALLOW_NOOP_ENLIST;
 import static 
org.apache.ignite.internal.client.proto.ProtocolBitmaskFeature.TX_CLIENT_GETALL_SUPPORTS_TX_OPTIONS;
@@ -730,23 +731,11 @@ public class ClientInboundMessageHandler
     }
 
     private void writeErrorCore(Throwable err, ClientMessagePacker packer) {
-        int extCnt = 0;
-        boolean retriable = false;
-
         SchemaVersionMismatchException schemaVersionMismatchException = 
findException(err, SchemaVersionMismatchException.class);
         SqlBatchException sqlBatchException = findException(err, 
SqlBatchException.class);
         DelayedAckException delayedAckException = findException(err, 
DelayedAckException.class);
         TransactionKilledException killedException = findException(err, 
TransactionKilledException.class);
 
-        if (schemaVersionMismatchException != null || sqlBatchException != 
null || delayedAckException != null || killedException != null) {
-            extCnt = 1;
-        } else {
-            retriable = findException(err, 
RetriableTransactionException.class) != null;
-            if (retriable) {
-                extCnt++;
-            }
-        }
-
         err = firstNotNull(
                 schemaVersionMismatchException,
                 sqlBatchException,
@@ -781,7 +770,18 @@ public class ClientInboundMessageHandler
         }
 
         // Extensions.
+        int extCnt = 0;
+        if (schemaVersionMismatchException != null || sqlBatchException != 
null || delayedAckException != null || killedException != null) {
+            extCnt++;
+        }
+
+        var retriable = findException(err, 
RetriableTransactionException.class) != null;
+        if (retriable) {
+            extCnt++;
+        }
+
         if (extCnt > 0) {
+            // IMPORTANT: every extension must be a single msgpack value, so 
that the client can skip unknown values.
             packer.packInt(extCnt);
 
             if (retriable) {
@@ -793,9 +793,16 @@ public class ClientInboundMessageHandler
                 packer.packString(ErrorExtensions.EXPECTED_SCHEMA_VERSION);
                 
packer.packInt(schemaVersionMismatchException.expectedVersion());
             } else if (sqlBatchException != null) {
-                // TODO IGNITE-28012 SQL_UPDATE_COUNTERS is an array and must 
come last
-                packer.packString(ErrorExtensions.SQL_UPDATE_COUNTERS);
-                packer.packLongArray(sqlBatchException.updateCounters());
+                if (clientContext.hasFeature(SQL_UPDATE_COUNTERS_2)) {
+                    // New format: single binary value (protocol-compliant).
+                    packer.packString(ErrorExtensions.SQL_UPDATE_COUNTERS_2);
+                    
packer.packLongArrayAsBinary(sqlBatchException.updateCounters());
+                } else {
+                    // Old format: array of longs (for backward compatibility 
with older clients).
+                    // IMPORTANT: This part must come last in the payload, it 
can't be skipped correctly.
+                    packer.packString(ErrorExtensions.SQL_UPDATE_COUNTERS);
+                    packer.packLongArray(sqlBatchException.updateCounters());
+                }
             } else if (delayedAckException != null) {
                 packer.packString(ErrorExtensions.DELAYED_ACK);
                 packer.packUuid(delayedAckException.txId());
diff --git 
a/modules/client/src/main/java/org/apache/ignite/internal/client/TcpClientChannel.java
 
b/modules/client/src/main/java/org/apache/ignite/internal/client/TcpClientChannel.java
index bbc257ff6d8..5c198f1da98 100644
--- 
a/modules/client/src/main/java/org/apache/ignite/internal/client/TcpClientChannel.java
+++ 
b/modules/client/src/main/java/org/apache/ignite/internal/client/TcpClientChannel.java
@@ -105,7 +105,8 @@ class TcpClientChannel implements ClientChannel, 
ClientMessageHandler, ClientCon
             ProtocolBitmaskFeature.SQL_MULTISTATEMENT_SUPPORT,
             ProtocolBitmaskFeature.COMPUTE_OBSERVABLE_TS,
             ProtocolBitmaskFeature.TX_DIRECT_MAPPING_SEND_REMOTE_WRITES,
-            ProtocolBitmaskFeature.TX_DIRECT_MAPPING_SEND_DISCARD
+            ProtocolBitmaskFeature.TX_DIRECT_MAPPING_SEND_DISCARD,
+            ProtocolBitmaskFeature.SQL_UPDATE_COUNTERS_2
     ));
 
     /** Minimum supported heartbeat interval. */
@@ -659,8 +660,12 @@ class TcpClientChannel implements ClientChannel, 
ClientMessageHandler, ClientCon
             if (key.equals(ErrorExtensions.EXPECTED_SCHEMA_VERSION)) {
                 expectedSchemaVersion = unpacker.unpackInt();
             } else if (key.equals(ErrorExtensions.SQL_UPDATE_COUNTERS)) {
+                // Deprecated format, keep for compat with older servers.
                 return new SqlBatchException(traceId, code, 
unpacker.unpackLongArray(),
                         errMsg != null ? errMsg : "SQL batch execution error", 
causeWithStackTrace);
+            } else if (key.equals(ErrorExtensions.SQL_UPDATE_COUNTERS_2)) {
+                return new SqlBatchException(traceId, code, 
unpacker.unpackLongArrayAsBinary(),
+                        errMsg != null ? errMsg : "SQL batch execution error", 
causeWithStackTrace);
             } else if (key.equals(ErrorExtensions.DELAYED_ACK)) {
                 return new ClientDelayedAckException(traceId, code, errMsg, 
unpacker.unpackUuid(), causeWithStackTrace);
             } else if (key.equals(ErrorExtensions.TX_KILL)) {
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/ClientCompatibilityTests.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/ClientCompatibilityTests.java
index be63b944d76..7f60fd20070 100644
--- 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/ClientCompatibilityTests.java
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/ClientCompatibilityTests.java
@@ -60,7 +60,9 @@ import org.apache.ignite.internal.jobs.DeploymentUtils;
 import org.apache.ignite.network.ClusterNode;
 import org.apache.ignite.sql.BatchedArguments;
 import org.apache.ignite.sql.ColumnMetadata;
+import org.apache.ignite.sql.IgniteSql;
 import org.apache.ignite.sql.ResultSetMetadata;
+import org.apache.ignite.sql.SqlBatchException;
 import org.apache.ignite.sql.SqlRow;
 import org.apache.ignite.sql.Statement;
 import org.apache.ignite.table.DataStreamerItem;
@@ -236,6 +238,48 @@ public interface ClientCompatibilityTests {
         assertEquals(2, rows.size());
     }
 
+    @Test
+    default void testSqlBatchException() {
+        IgniteSql sql = client().sql();
+
+        // Insert initial row to trigger constraint violation
+        int duplicateId = idGen().incrementAndGet();
+        sql.execute((Transaction) null, "INSERT INTO " + TABLE_NAME_TEST + " 
(id, name) VALUES (?, ?)", duplicateId, "initial");
+
+        // Create batch where some rows will succeed and one will fail with 
duplicate key
+        int id1 = idGen().incrementAndGet();
+        int id2 = idGen().incrementAndGet();
+        int id3 = idGen().incrementAndGet();
+        int id4 = idGen().incrementAndGet();
+
+        BatchedArguments args = BatchedArguments.create()
+                .add(id1, "test1")
+                .add(id2, "test2")
+                .add(id3, "test3")
+                .add(duplicateId, "duplicate") // This will fail - duplicate 
primary key
+                .add(id4, "test4"); // This won't be executed due to error
+
+        var ex = assertThrows(
+                SqlBatchException.class,
+                () -> sql.executeBatch(null, "INSERT INTO " + TABLE_NAME_TEST 
+ " (id, name) VALUES (?, ?)", args));
+
+        // Verify error extensions: update counters should reflect 3 
successful inserts before the error
+        assertEquals(3, ex.updateCounters().length, "Expected 3 successful 
updates before error");
+
+        // Verify all successful inserts have counter = 1
+        for (long counter : ex.updateCounters()) {
+            assertEquals(1, counter, "Each successful insert should have 
update count = 1");
+        }
+
+        // Verify the successful rows were actually inserted
+        List<SqlRow> rows = sql("SELECT * FROM " + TABLE_NAME_TEST + " WHERE 
id IN (?, ?, ?)", id1, id2, id3);
+        assertEquals(3, rows.size(), "The 3 rows before the error should have 
been inserted");
+
+        // Verify the row after the error was not inserted
+        List<SqlRow> notInserted = sql("SELECT * FROM " + TABLE_NAME_TEST + " 
WHERE id = ?", id4);
+        assertEquals(0, notInserted.size(), "Row after the error should not 
have been inserted");
+    }
+
     @Test
     default void testRecordViewOperations() {
         int id = idGen().incrementAndGet();
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/OldClientWithCurrentServerCompatibilityTest.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/OldClientWithCurrentServerCompatibilityTest.java
index 0e546f9a8a4..8f1afb5086d 100644
--- 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/OldClientWithCurrentServerCompatibilityTest.java
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/client/OldClientWithCurrentServerCompatibilityTest.java
@@ -167,6 +167,15 @@ public class OldClientWithCurrentServerCompatibilityTest 
extends BaseIgniteAbstr
         delegate.testSqlBatch();
     }
 
+    @Test
+    @Override
+    public void testSqlBatchException() {
+        // Old clients don't support SQL_UPDATE_COUNTERS_2 extension,
+        // but they should still be able to receive and process 
SqlBatchException.
+        // The updateCounters() will return null for old clients.
+        delegate.testSqlBatchException();
+    }
+
     @Test
     @Override
     public void testRecordViewOperations() {
diff --git a/modules/platforms/cpp/ignite/protocol/bitmask_feature.h 
b/modules/platforms/cpp/ignite/protocol/bitmask_feature.h
index 41bdaea0a7f..6edac29641a 100644
--- a/modules/platforms/cpp/ignite/protocol/bitmask_feature.h
+++ b/modules/platforms/cpp/ignite/protocol/bitmask_feature.h
@@ -30,6 +30,9 @@ namespace ignite::protocol {
 enum class bitmask_feature {
     /** Qualified name table requests. */
     TABLE_REQS_USE_QUALIFIED_NAME = 2,
+
+    /** SQL_UPDATE_COUNTERS_2 error extension (single binary value instead of 
array). */
+    SQL_UPDATE_COUNTERS_2 = 18,
 };
 
 /**
@@ -38,10 +41,11 @@ enum class bitmask_feature {
  * @return Return all supported bitmask features in binary form.
  */
 inline std::vector<std::byte> all_supported_bitmask_features() {
-    std::vector<std::byte> res(1, std::byte{0});
+    std::vector<std::byte> res(3, std::byte{0});
 
     bitset_span span(res.data(), res.size());
     
span.set(static_cast<std::size_t>(bitmask_feature::TABLE_REQS_USE_QUALIFIED_NAME));
+    span.set(static_cast<std::size_t>(bitmask_feature::SQL_UPDATE_COUNTERS_2));
 
     return res;
 }
diff --git a/modules/platforms/cpp/ignite/protocol/reader.h 
b/modules/platforms/cpp/ignite/protocol/reader.h
index 9c57eefc96c..3aceb99b170 100644
--- a/modules/platforms/cpp/ignite/protocol/reader.h
+++ b/modules/platforms/cpp/ignite/protocol/reader.h
@@ -18,6 +18,7 @@
 #pragma once
 
 #include <ignite/common/bytes_view.h>
+#include <ignite/common/detail/bytes.h>
 #include <ignite/common/ignite_error.h>
 #include <ignite/common/uuid.h>
 #include <ignite/protocol/utils.h>
@@ -235,6 +236,45 @@ public:
         return read_int64_array();
     }
 
+    /**
+     * Read array of int64 from binary data.
+     * Compatible with Java's ClientMessageUnpacker.unpackLongArrayAsBinary.
+     *
+     * @return Vector of int64 values.
+     */
+    [[nodiscard]] std::vector<std::int64_t> read_int64_array_from_binary() {
+        auto binary_data = read_binary();
+
+        if (binary_data.size() % 8 != 0) {
+            throw ignite_error("Binary data size must be a multiple of 8, but 
was " + std::to_string(binary_data.size()));
+        }
+
+        std::size_t count = binary_data.size() / 8;
+        std::vector<std::int64_t> result;
+        result.reserve(count);
+
+        for (std::size_t i = 0; i < count; ++i) {
+            std::int64_t value = detail::bytes::load<detail::endian::BIG, 
std::int64_t>(
+                binary_data.data() + i * 8);
+            result.push_back(value);
+        }
+
+        return result;
+    }
+
+    /**
+     * Read array of int64 from binary data, or nullopt if nil.
+     *
+     * @return Vector of int64 values or nullopt.
+     */
+    [[nodiscard]] std::optional<std::vector<std::int64_t>> 
read_int64_array_from_binary_nullable() {
+        if (try_read_nil()) {
+            return std::nullopt;
+        }
+
+        return read_int64_array_from_binary();
+    }
+
     /**
      * Read int32 or nullopt.
      *
diff --git a/modules/platforms/cpp/ignite/protocol/utils.cpp 
b/modules/platforms/cpp/ignite/protocol/utils.cpp
index 65e17337ad8..2663bab7ae6 100644
--- a/modules/platforms/cpp/ignite/protocol/utils.cpp
+++ b/modules/platforms/cpp/ignite/protocol/utils.cpp
@@ -223,8 +223,14 @@ ignite_error read_error(reader &reader) {
                 auto ver = reader.read_int32();
                 res.add_extra<std::int32_t>(std::move(key), ver);
             } else if (key == error_extensions::SQL_UPDATE_COUNTERS) {
+                // Deprecated format: array of int64 values. Keep for 
compatibility with older servers.
                 auto affected_rows = reader.read_int64_array();
                 res.add_extra<std::vector<std::int64_t>>(std::move(key), 
std::move(affected_rows));
+            } else if (key == error_extensions::SQL_UPDATE_COUNTERS_2) {
+                // New format: single binary value containing int64 array.
+                auto affected_rows = reader.read_int64_array_from_binary();
+                // Store under the old key for backward compatibility with 
existing code.
+                
res.add_extra<std::vector<std::int64_t>>(error_extensions::SQL_UPDATE_COUNTERS, 
std::move(affected_rows));
             } else {
                 reader.skip();
             }
diff --git a/modules/platforms/cpp/ignite/protocol/utils.h 
b/modules/platforms/cpp/ignite/protocol/utils.h
index e505896e484..25b9a753367 100644
--- a/modules/platforms/cpp/ignite/protocol/utils.h
+++ b/modules/platforms/cpp/ignite/protocol/utils.h
@@ -44,6 +44,7 @@ class reader;
 namespace error_extensions {
 constexpr const char* EXPECTED_SCHEMA_VERSION = "expected-schema-ver";
 constexpr const char* SQL_UPDATE_COUNTERS = "sql-update-counters";
+constexpr const char* SQL_UPDATE_COUNTERS_2 = "sql-update-counters-2";
 };
 
 /** Magic bytes. */
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
index b5bd59599da..09a1956da44 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
@@ -764,6 +764,28 @@ namespace Apache.Ignite.Tests.Sql
             Assert.AreEqual("Statement of type \"Query\" is not allowed in 
current context [allowedTypes=[DML]].", ex.Message);
         }
 
+        [Test]
+        public async Task TestExecuteBatchWithDuplicateKeyException()
+        {
+            int duplicateId = 1000;
+            var sql = "INSERT INTO TEST (ID, VAL) VALUES (?, ?)";
+            await Client.Sql.ExecuteAsync(null, sql, duplicateId, "initial");
+
+            object?[][] args =
+            [
+                [1001, "test1"],
+                [1002, "test2"],
+                [1003, "test3"],
+                [duplicateId, "duplicate"],
+                [1004, "test4"]
+            ];
+
+            // TODO IGNITE-22575 Propagate SqlBatchException.updateCounters
+            var ex = Assert.ThrowsAsync<SqlBatchException>(async () => await 
Client.Sql.ExecuteBatchAsync(null, sql, args));
+            Assert.AreEqual("PK unique constraint is violated", ex.Message);
+            Assert.AreEqual("IGN-SQL-5", ex.CodeAsString);
+        }
+
         [Test]
         public async Task TestCancelQueryCursor([Values(true, false)] bool 
beforeIter)
         {
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
index 06ceed22d0d..8d41b343537 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
@@ -54,7 +54,8 @@ namespace Apache.Ignite.Internal
             ProtocolBitmaskFeature.StreamerReceiverExecutionOptions |
             ProtocolBitmaskFeature.SqlPartitionAwareness |
             ProtocolBitmaskFeature.SqlPartitionAwarenessTableName |
-            ProtocolBitmaskFeature.ComputeObservableTs;
+            ProtocolBitmaskFeature.ComputeObservableTs |
+            ProtocolBitmaskFeature.SqlUpdateCounters2;
 
         /** Features as a byte array */
         private static readonly byte[] FeatureBytes = Features.ToBytes();
@@ -482,6 +483,8 @@ namespace Apache.Ignite.Internal
                 }
             }
 
+            Debug.Assert(reader.End, "All error response bytes should be 
consumed.");
+
             return ex;
         }
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
index e516ec318c5..88114696baf 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
@@ -68,5 +68,10 @@ internal enum ProtocolBitmaskFeature
     /// <summary>
     /// Partition awareness for SQL requests with table name in metadata.
     /// </summary>
-    SqlPartitionAwarenessTableName = 1 << 16
+    SqlPartitionAwarenessTableName = 1 << 16,
+
+    /// <summary>
+    /// SQL_UPDATE_COUNTERS_2 error extension (single binary value instead of 
array).
+    /// </summary>
+    SqlUpdateCounters2 = 1 << 18
 }

Reply via email to