From 47769981c6fb596f892f0b2ab64c24c8e22b38e9 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 23 Sep 2026 07:38:47 +0000
Subject: [PATCH] [#915] Bound the wait of a write transaction for a row lock another session holds (#1010)
---
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 100 insertions(+), 1 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 3cbdbfe..f3b3500 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
@@ -291,12 +291,18 @@
* Oracle asserts the other half of the same contract: nothing of ours is set there, because its
* own {@code ddl_lock_timeout} gives up at once - so the drop still fails rather than hanging, and
* the failure names the engine's own doing rather than a property of ours that armed nothing.
+ * <p>
+ * The bound is set looser than the row lock bound of the write this drop runs inside, deliberately:
+ * sql server has one {@code LOCK_TIMEOUT} for both waits, so a DDL bound tighter than the row one
+ * passes this case whether this backend armed it or merely inherited the row bound of the write
+ * around it - and inheriting it is exactly what would leave this property governing nothing and the
+ * failure naming neither property (#915).
*/
@Test(timeOut = 120000)
public void testTheDdlGivesUpOnALockAnotherSessionHolds() throws Exception {
final TreeName tree = new TreeName("testDdlLockBound", "tree");
final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
- System.setProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY, "2");
+ System.setProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY, "7");
try {
storage.open(AccessMode.READ_WRITE);
storage.write(new WriteOperation() {
@@ -728,6 +734,93 @@
});
}
+ /**
+ * A write queued behind a row lock another session holds gives up at
+ * {@code ROW_LOCK_TIMEOUT_PROPERTY} and is replayed, and the replay goes through once that session
+ * lets the rows go: the wait ends inside the replay window instead of consuming it, which is what
+ * left the operation with no replay at all before (#903, #915).
+ * <p>
+ * On an engine with no session setting for that wait - oracle - the same write waits the blocker
+ * out on its first attempt and needs no replay, which is what "left alone" means there. That is
+ * asserted rather than skipped: it is the difference this bound makes, read from the one place it
+ * shows.
+ */
+ @Test(timeOut = 300000)
+ public void testAWriteBlockedByARowLockIsReplayedInsideTheWindow() throws Exception {
+ final int boundSeconds = 2;
+ final TreeName tree = new TreeName("testRowLockBound", "tree");
+ final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+ System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, Integer.toString(boundSeconds));
+ final ExecutorService releasing = Executors.newSingleThreadExecutor();
+ try {
+ storage.open(AccessMode.READ_WRITE);
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.openTree(tree, true);
+ txn.put(tree, key(1), value(1));
+ }
+ });
+
+ // a session of its own, taking an exclusive lock on the row the write below wants: an update
+ // that changes nothing takes the same lock as one that does, and it parses on all four engines
+ try (final Connection holder = DriverManager.getConnection(getJdbcUrl())) {
+ holder.setAutoCommit(false);
+ try (final PreparedStatement lock = holder.prepareStatement(
+ "update " + storage.getTableName(tree) + " set v=v")) {
+ lock.executeUpdate();
+ }
+ // held for twice the bound, so that the first attempt of the write gives up on it and the
+ // replay finds the row free - both inside the replay window, which is what this is about
+ final Future<?> released = releasing.submit(new Callable<Void>() {
+ @Override
+ public Void call() throws Exception {
+ Thread.sleep(boundSeconds * 2 * 1000L);
+ holder.rollback(); // the rows go back exactly as they were
+ return null;
+ }
+ });
+
+ final AtomicInteger attempts = new AtomicInteger();
+ final long startedAt = System.nanoTime();
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ attempts.incrementAndGet();
+ txn.put(tree, key(1), value(2));
+ }
+ });
+ final long elapsedMillis = (System.nanoTime() - startedAt) / 1000000L;
+ released.get(); // the failure of the holder, if it had one, rather than a stuck rollback
+
+ if (dialect() == JDBCStorage.Dialect.ORACLE) {
+ assertEquals(attempts.get(), 1, "oracle has no session setting for a row lock enqueue, so the"
+ + " write should have waited the holder out on its first attempt (" + elapsedMillis + " ms)");
+ } else {
+ assertTrue(attempts.get() >= 2, "the write was not replayed: it waited " + elapsedMillis
+ + " ms for a row lock that this engine was told to give up on after " + boundSeconds + " s");
+ assertTrue(elapsedMillis >= boundSeconds * 1000L - JDBCStorage.CLOCK_SLACK_MILLIS,
+ "the write came back after " + elapsedMillis + " ms, before the bound it was given:"
+ + " something other than the row lock ended its first attempt");
+ }
+ }
+ } finally {
+ System.clearProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY);
+ releasing.shutdownNow();
+ try {
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(tree);
+ }
+ });
+ } catch (Exception ignored) {
+ } finally {
+ storage.close();
+ }
+ }
+ }
+
private interface BlockedOperation {
void run(JDBCStorage storage, TreeName tree) throws Exception;
}
@@ -807,6 +900,11 @@
for (final JDBCStorage.StatementBound each : JDBCStorage.StatementBound.values()) {
System.setProperty(each.property, each == bound ? Integer.toString(boundSeconds) : "0");
}
+ // and the row lock bound of a write is off here for the same reason: it is tighter
+ // than the bound under test, so it - and not the statement bound this case is about -
+ // would be what ended the wait, and the failure would name no property of this class
+ // (#915). testAWriteBlockedByARowLockIsReplayedInsideTheWindow covers that layer
+ System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "0");
// the monotonic clock, which is what timedOut() measures the bound with: a step of
// the wall clock can neither lengthen nor shorten what the assertions below allow
final long startedAt = System.nanoTime();
@@ -868,6 +966,7 @@
for (final JDBCStorage.StatementBound each : JDBCStorage.StatementBound.values()) {
System.clearProperty(each.property);
}
+ System.clearProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY);
try {
storage.write(new WriteOperation() {
@Override
--
Gitblit v1.10.0