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