From 0b9c0f63f5c79e0a5d955011453bf415cb27e184 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Thu, 20 Aug 2026 09:17:16 +0000
Subject: [PATCH] Seek the primary key in the SQL Server upsert and retry a transaction conflict (#867)
---
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java | 80 ++++++++++++++++++++++++++++++++++++++++
1 files changed, 80 insertions(+), 0 deletions(-)
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
index 76760c1..b650301 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -45,6 +45,10 @@
import java.util.Collections;
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
@@ -1236,4 +1240,80 @@
storage.close();
}
}
+ /**
+ * Two or more <em>distinct new</em> keys written into one tree per transaction is the shape the primary key
+ * seek made able to deadlock: on the NOT MATCHED path the seek range-locks the gap before the next existing
+ * key, that lock is self-incompatible, and the key hash scatters logically ordered keys across the index, so
+ * two writers inserting different keys can each end up holding what the other needs. The ascending key order
+ * that {@code IndexBuffer} maintains does not help there. Nothing may escape {@link JDBCStorage#write}, which
+ * replays the conflict, and no record may be lost to it (#867).
+ */
+ @Test(timeOut = 600000)
+ public void testConcurrentWritersInsertingDistinctKeys() throws Exception {
+ final int writers = 4;
+ final int rounds = 25;
+ final int keysPerTransaction = 3;
+ final int seeded = 10;
+ final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+ final TreeName tree = new TreeName("testConcurrentInsert", "tree");
+ final ExecutorService executor = Executors.newFixedThreadPool(writers);
+ try {
+ storage.open(AccessMode.READ_WRITE);
+ // seeded, so that every insert below takes the NOT MATCHED path with a gap to lock in front of it
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.openTree(tree, true);
+ for (int i = 0; i < seeded; i++) {
+ txn.put(tree, key(i), value(i));
+ }
+ }
+ });
+ final List<Callable<Void>> concurrent = new ArrayList<>();
+ for (int writer = 0; writer < writers; writer++) {
+ final int id = writer;
+ concurrent.add(new Callable<Void>() {
+ @Override
+ public Void call() throws Exception {
+ for (int round = 0; round < rounds; round++) {
+ final int current = round;
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ for (int i = 0; i < keysPerTransaction; i++) {
+ txn.put(tree,
+ ByteString.valueOfUtf8(String.format("w%02d-r%03d-k%d", id, current, i)),
+ value(i));
+ }
+ }
+ });
+ }
+ return null;
+ }
+ });
+ }
+ for (final Future<Void> written : executor.invokeAll(concurrent)) {
+ // a conflict the storage did not replay surfaces here, as it would reach an LDAP client
+ written.get();
+ }
+ storage.read(new ReadOperation<Void>() {
+ @Override
+ public Void run(ReadableTransaction txn) throws Exception {
+ assertEquals(txn.getRecordCount(tree), seeded + writers * rounds * keysPerTransaction);
+ return null;
+ }
+ });
+ } finally {
+ executor.shutdownNow();
+ try {
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(tree);
+ }
+ });
+ } catch (Exception ignored) {}
+ storage.close();
+ }
+ }
}
--
Gitblit v1.10.0