/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2026 3A Systems, LLC.
*/
package org.opends.server.backends.jdbc;
import org.forgerock.opendj.server.config.server.JDBCBackendCfg;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.backends.jdbc.JDBCStorage.ArmedLockBound;
import org.opends.server.backends.jdbc.JDBCStorage.Dialect;
import org.opends.server.backends.jdbc.JDBCStorage.LockBound;
import org.opends.server.backends.pluggable.spi.StorageRuntimeException;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Savepoint;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
/**
* What a transaction of {@code JDBCStorage.write()} is told to do about a row lock another session
* holds (#915), and what the replay makes of the failure that ends such a wait.
*
* It needs no database: the connection is a mock, so what each engine is told - and what it is told
* to put back before the connection goes to the next borrower - is pinned wherever the build runs,
* while the container suites cover a write really queued behind another session's row lock. The
* replay that this bound exists for is driven end to end in {@code JDBCStorageRetryTest}.
*/
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "jdbc" }, sequential = true)
public class JDBCRowLockBoundTestCase extends DirectoryServerTestCase {
/** A driver name of an engine none of the settings fit, which is what the null dialect stands for. */
private static final String UNKNOWN_ENGINE = "com.example.jdbc.Connection";
private static final String MYSQL = "com.mysql.cj.jdbc.ConnectionImpl";
/** What the connection of a case was asked to run, in the order it was asked to run it. */
private final List issued = new ArrayList<>();
private JDBCStorage storage;
@BeforeMethod
public void createStorage() {
storage = new JDBCStorage(backendCfg(), null);
issued.clear();
}
private static JDBCBackendCfg backendCfg() {
final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class);
when(cfg.getBackendId()).thenReturn("rowLockBound");
return cfg;
}
@AfterMethod
public void clearProperties() {
System.clearProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY);
// the cases arming a DDL bound inside this one raise it: the two are decided against each other
System.clearProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY);
}
/**
* The window bounding the replays is a clock, and a clock only bounds them while an attempt is
* shorter than it: the default is a third of that window, so a write meeting a lock gets three
* attempts inside it rather than spending the whole window on one and being refused a replay
* (#903). A default at or past the window would put this back exactly as it was.
*/
@Test
public void testTheDefaultLeavesTheReplayWindowRoomForReplays() {
assertEquals(JDBCStorage.rowLockBoundSeconds(), 3);
assertTrue(JDBCStorage.rowLockBoundSeconds() * 1000L * 1000L * 1000L * 3 <= JDBCStorage.RETRY_WINDOW_NANOS,
"the default bound leaves the replay window room for fewer than three attempts");
}
/** Zero is what a deployment that would rather wait for its row lock sets, and so is a negative value. */
@Test
public void testTheBoundIsTurnedOffByZero() {
System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "0");
assertEquals(JDBCStorage.rowLockBoundSeconds(), 0);
System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "-1");
assertEquals(JDBCStorage.rowLockBoundSeconds(), 0);
}
/** A value that is not a number keeps the default, so a typo cannot silently unbound the wait. */
@Test
public void testAValueThatIsNotANumberKeepsTheDefault() {
System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "three seconds");
assertEquals(JDBCStorage.rowLockBoundSeconds(), JDBCStorage.ROW_LOCK_TIMEOUT_SECONDS);
}
/** And a value past what a bound of this backend can hold is taken down to it, not read as no bound. */
@Test
public void testAValueBeyondTheCeilingIsClamped() {
System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, String.valueOf(Integer.MAX_VALUE));
assertEquals(JDBCStorage.rowLockBoundSeconds(), JDBCStorage.MAX_BOUND_SECONDS);
}
/**
* What each engine is told around the transaction, in the unit its own setting takes: the row lock
* of mysql is innodb_lock_wait_timeout and never the lock_wait_timeout of a metadata lock, sql
* server has one LOCK_TIMEOUT for every lock wait of a session, postgres takes a set local that the
* transaction discards, and oracle has no setting for this wait at all.
*/
@DataProvider
public Object[][] engines() {
return new Object[][] {
{ "postgres takes a set local and needs no value back", Dialect.POSTGRES, "0",
singletonList("set local lock_timeout = 3000"), true },
{ "mysql bounds the row lock, not the metadata lock", Dialect.MYSQL, "50",
asList("select @@session.innodb_lock_wait_timeout", "set session innodb_lock_wait_timeout=3",
"set session innodb_lock_wait_timeout=50"), true },
{ "sql server replaces the -1 that waits forever", Dialect.MICROSOFT, "-1",
asList("select @@lock_timeout", "set lock_timeout 3000", "set lock_timeout -1"), true },
{ "oracle has no session setting for a row lock", Dialect.ORACLE, "0", emptyList(), false },
{ "an engine this backend does not know is told nothing", null, "0", emptyList(), false },
};
}
@Test(dataProvider = "engines")
public void testWhatEachEngineIsToldAroundTheTransaction(String name, Dialect dialect, String carries,
List expected, boolean bounded) throws Exception {
final Connection con = recording(mock(Connection.class), carries);
final ArmedLockBound armed = storage.armLockBound(con, dialect, LockBound.ROW);
storage.releaseLockBound(con, dialect, armed);
assertEquals(issued, expected, name);
assertEquals(armed.bounded, bounded, name + ": the wait was reported as bounded when it is not, or the other"
+ " way round - which is what decides whether the replay may take that wait again");
}
/**
* A session that gives up sooner than this bound keeps exactly what it has - the bound is never
* loosened to ours - and the wait is bounded all the same, which is the whole reason for leaving
* that value alone. The replay reads that, not whether a statement of ours was issued.
*/
@DataProvider
public Object[][] sessionsAlreadyTighter() {
return new Object[][] {
{ "mysql giving up after a second", Dialect.MYSQL, "1" },
{ "sql server told not to wait at all", Dialect.MICROSOFT, "0" },
};
}
@Test(dataProvider = "sessionsAlreadyTighter")
public void testASessionAlreadyTighterKeepsWhatItHasAndIsStillBounded(String name, Dialect dialect,
String carries) throws Exception {
final Connection con = recording(mock(Connection.class), carries);
final ArmedLockBound armed = storage.armLockBound(con, dialect, LockBound.ROW);
storage.releaseLockBound(con, dialect, armed);
assertEquals(issued, singletonList(dialect == Dialect.MYSQL
? "select @@session.innodb_lock_wait_timeout" : "select @@lock_timeout"), name);
assertTrue(armed.bounded, name + ": a wait the session itself bounds was reported as unbounded");
}
/** While a session looser than this bound is given ours, and gets its own value back afterwards. */
@Test
public void testASessionLooserThanTheBoundIsGivenOursAndGetsItBack() throws Exception {
final Connection con = recording(mock(Connection.class), "5000");
final ArmedLockBound armed = storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
storage.releaseLockBound(con, Dialect.MICROSOFT, armed);
assertEquals(issued, asList("select @@lock_timeout", "set lock_timeout 3000", "set lock_timeout 5000"));
}
/** Turned off, nothing is asked of the session and nothing is claimed about the wait. */
@Test
public void testTheBoundTurnedOffAsksTheSessionNothing() throws Exception {
System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "0");
final Connection con = recording(mock(Connection.class), "50");
final ArmedLockBound armed = storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
storage.releaseLockBound(con, Dialect.MYSQL, armed);
assertEquals(issued, emptyList());
assertFalse(armed.bounded, "a wait nothing bounds was reported as bounded");
}
/**
* A session answering the readback with something no SET of it would take back is left alone
* altogether: nothing of ours is ever set where it could not be taken off again.
*/
@Test
public void testASessionThatWillNotSayWhatItCarriesIsLeftAlone() throws Exception {
final Connection con = recording(mock(Connection.class), "unlimited");
final ArmedLockBound armed = storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
storage.releaseLockBound(con, Dialect.MYSQL, armed);
assertEquals(issued, singletonList("select @@session.innodb_lock_wait_timeout"));
assertFalse(armed.bounded, "a session that never took the bound was reported as bounded");
}
/**
* A setting the session refuses leaves the write running as it ran before this bound existed - the
* bound is an improvement on a wait and never a reason to fail a write that would have gone
* through - and the wait is not claimed to be bounded, so the replay does not take it again.
*/
@Test
public void testASettingTheSessionRefusesLeavesTheWaitUnbounded() throws Exception {
final Connection con = recording(mock(Connection.class), "50");
final Statement statement = con.createStatement();
doAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
throw new SQLException("this session takes no such setting", "42000");
}).when(statement).execute(anyString());
final ArmedLockBound armed = storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
storage.releaseLockBound(con, Dialect.MYSQL, armed);
assertFalse(armed.bounded, "a setting the session refused was reported as bounding the wait");
// the value is given back all the same: a setting can reach the server and fail only as the
// statement carrying it is closed, and giving back a value the session may never have left
// costs a round trip and changes nothing
assertEquals(issued, asList("select @@session.innodb_lock_wait_timeout",
"set session innodb_lock_wait_timeout=3", "set session innodb_lock_wait_timeout=50"));
}
/**
* A connection left carrying a bound this backend could not take off again does not go back into
* the pool: on sql server that setting would cut every lock wait of the next borrower, and a read -
* which replays nothing, deliberately - would see the error 1222 it ends with.
*/
@Test
public void testAConnectionWhoseBoundCouldNotBeTakenOffIsKeptOutOfThePool() throws Exception {
final AtomicBoolean keptOut = new AtomicBoolean();
final Connection parent = refusingToGiveTheValueBack(mock(Connection.class), "-1");
try (final CachedConnection con = new CachedConnection("jdbc:mock", parent) {
@Override
void keepOutOfThePool() {
keptOut.set(true);
super.keepOutOfThePool();
}
}) {
storage.releaseLockBound(con, Dialect.MICROSOFT,
storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW));
}
assertTrue(keptOut.get(), "a connection left carrying our bound was handed back to the pool");
}
/**
* Postgres needs a transaction block for a set local to mean anything: outside one the server
* answers it with a warning no driver raises, so the write would run unbounded while the log read
* exactly like a bounded one.
*/
@Test
public void testPostgresInAutoCommitIsToldNothing() throws Exception {
final Connection con = recording(mock(Connection.class), "0");
when(con.getAutoCommit()).thenReturn(true);
final ArmedLockBound armed = storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
assertEquals(issued, emptyList());
assertFalse(armed.bounded, "a set local that reaches no transaction was reported as bounding the wait");
}
/**
* A failed setting takes the transaction back to the savepoint in front of it: a statement that
* fails inside a postgres transaction aborts it, and the write would then fail with 25P02 rather
* than running as unbounded as it ran before this bound existed.
*/
@Test
public void testPostgresTakesASavepointInFrontOfTheSetting() throws Exception {
final Connection con = recording(mock(Connection.class), "0");
storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
verify(con).setSavepoint();
}
/**
* And lets go of it once the setting is on: a savepoint is a subtransaction of the write, and one
* left open spans the whole attempt - every row that attempt writes would then carry the
* subtransaction's own xid, which readers of those rows resolve through {@code pg_subtrans}. The
* {@code set local} survives the release, so the bound it was taken in front of stays on.
*/
@Test
public void testPostgresLetsGoOfThatSavepointOnceTheSettingIsOn() throws Exception {
final Connection con = recording(mock(Connection.class), "0");
final Savepoint beforeTheBound = mock(Savepoint.class);
when(con.setSavepoint()).thenReturn(beforeTheBound);
storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
assertEquals(issued, singletonList("set local lock_timeout = 3000"),
"the bound itself was not issued, or was taken back");
verify(con).releaseSavepoint(beforeTheBound);
verify(con, never()).rollback(beforeTheBound);
}
/**
* A setting that failed is taken back to that point instead, and the point is not let go of in front
* of a rollback that still has to reach it.
*/
@Test
public void testASettingThatFailedIsTakenBackToThatSavepoint() throws Exception {
final Connection con = refusingTheSetting(mock(Connection.class), "0");
final Savepoint beforeTheBound = mock(Savepoint.class);
when(con.setSavepoint()).thenReturn(beforeTheBound);
final ArmedLockBound armed = storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
assertFalse(armed.bounded, "a setting the session refused was reported as bounding the wait");
verify(con).rollback(beforeTheBound);
verify(con, never()).releaseSavepoint(beforeTheBound);
}
/**
* The readback is a round trip, and this bound is armed around every write of the server where the
* DDL bound is armed around an open: it is paid once per pooled connection. Only this backend
* writes that setting on a connection of this pool, every write puts the value back, and a
* connection whose restore failed is kept out of the pool - so what was read cannot go stale.
*/
@Test
public void testTheReadbackIsPaidOncePerPooledConnection() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
recording(mock(Connection.class), "50"))) {
storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
}
assertEquals(issued, asList("select @@session.innodb_lock_wait_timeout",
"set session innodb_lock_wait_timeout=3", "set session innodb_lock_wait_timeout=50",
"set session innodb_lock_wait_timeout=3", "set session innodb_lock_wait_timeout=50"),
"the value a pooled session carries was read back more than once");
}
/**
* A connection that is not one of this pool is asked every time: the memo above is a property of a
* session this backend owns for its life, and the catalog and stamp connections are not that.
*/
@Test
public void testAConnectionOutsideThePoolIsAskedEveryTime() throws Exception {
final Connection con = recording(mock(Connection.class), "50");
storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
assertEquals(issued.stream().filter("select @@session.innodb_lock_wait_timeout"::equals).count(), 2L,
"a connection outside the pool was asked for the value it carries only once");
}
/**
* The failure an engine ends a bounded wait with is replayable, and only where this backend bounded
* that wait: 55P03 and error 1222 are no conflict - the engine rolled nothing back and the blocker
* is still holding the lock - and what makes them worth replaying is that the wait they cost fits
* inside the replay window. Where nothing bounded the wait they stay exactly as unreplayable as
* they were: a bound an operator set for themselves is not a licence for this loop to take that
* wait again, and a wait nothing bounds is the one thing the window cannot govern (#903).
*/
@DataProvider
public Object[][] lockTimeouts() {
return new Object[][] {
{ "postgres lock_timeout", Dialect.POSTGRES, new SQLException("canceling statement due to lock timeout",
"55P03") },
{ "sql server LOCK_TIMEOUT", Dialect.MICROSOFT, new SQLException("Lock request time out period exceeded.",
"HY000", 1222) },
};
}
@Test(dataProvider = "lockTimeouts")
public void testALockTimeoutIsReplayedOnlyWhereThisBackendBoundedTheWait(String name, Dialect dialect,
SQLException failure) {
assertEquals(replayReason(failure, dialect, ArmedLockBound.alreadyTighter(LockBound.ROW, 3)),
"a lock wait of an attempt this backend bounded", name);
assertNull(replayReason(failure, dialect, ArmedLockBound.none(LockBound.ROW)),
name + ": a wait this backend put no bound on was replayed on a clock that cannot bound it");
}
/**
* A mysql lock wait timeout arrives in class 40, which is the conflict this loop has replayed since
* #867: it keeps that reason whether or not this bound is armed, since the line should name the
* strongest thing that can be said of the failure.
*/
@Test
public void testAMysqlLockWaitTimeoutStaysTheConflictItWas() {
final SQLException lockWait = new SQLException("Lock wait timeout exceeded", "40001", 1205);
assertEquals(replayReason(lockWait, MYSQL, false, false, false, Dialect.MYSQL,
ArmedLockBound.none(LockBound.ROW)), "a conflict");
assertEquals(replayReason(lockWait, MYSQL, false, false, false, Dialect.MYSQL,
ArmedLockBound.alreadyTighter(LockBound.ROW, 3)), "a conflict");
}
/**
* Not while committing, for the reason a dropped connection is not replayed there: a commit that
* did not answer leaves the outcome unknown, and this loop must not apply a write twice.
*/
@Test
public void testALockTimeoutReportedByTheCommitIsNotReplayed() {
final SQLException lockTimeout = new SQLException("Lock request time out period exceeded.", "HY000", 1222);
assertNull(replayReason(lockTimeout, UNKNOWN_ENGINE, true, false, false, Dialect.MICROSOFT,
ArmedLockBound.alreadyTighter(LockBound.ROW, 3)));
}
/** And never once the attempt has committed part of its own work, whatever the failure says. */
@Test
public void testALockTimeoutOfAnAttemptThatCommittedPartOfItsWorkIsNotReplayed() {
final SQLException lockTimeout = new SQLException("Lock request time out period exceeded.", "HY000", 1222);
assertNull(replayReason(lockTimeout, UNKNOWN_ENGINE, false, true, false, Dialect.MICROSOFT,
ArmedLockBound.alreadyTighter(LockBound.ROW, 3)));
}
/**
* Read from the failure of the operation only, never from the release of the connection: the
* rollback that gives a connection back runs after the outcome was decided, so a lock timeout
* reported there says nothing about the statement that failed.
*/
@Test
public void testALockTimeoutOfTheReleaseIsNotReplayed() {
final SQLException rejected = new SQLException("duplicate key", "23000", 2627);
rejected.addSuppressed(new SQLException("Lock request time out period exceeded.", "HY000", 1222));
assertNull(replayReason(rejected, Dialect.MICROSOFT, ArmedLockBound.alreadyTighter(LockBound.ROW, 3)));
}
/**
* sql server has one {@code LOCK_TIMEOUT} for both waits, and every DDL of this backend but the
* off-write catalog drop is issued from inside a write - which armed this bound one statement
* earlier. Read live, that DDL would see the 3 s of the row bound, answer "already tighter" to its
* own 5 s and run at the row bound instead: {@code DDL_LOCK_TIMEOUT_PROPERTY} would govern no DDL of
* a write at all. What decides it is the value the deployment set, which the row bound remembered on
* the connection when it displaced it.
*
* The readback itself is still paid, and paid live: what the DDL has to put back is the value the
* session carried a statement ago - the row bound of this very write - rather than the one the
* connection was borrowed with, which is why only the row bound is remembered per pooled connection.
*/
@Test
public void testTheDdlBoundInsideAWriteIsDecidedAgainstWhatTheDeploymentSet() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
liveLockTimeout(mock(Connection.class), "-1"))) {
final ArmedLockBound row = storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
issued.add("the ddl");
return null;
});
storage.releaseLockBound(con, Dialect.MICROSOFT, row);
}
assertEquals(issued, asList(
// the row bound of the write, against what the session carried
"select @@lock_timeout", "set lock_timeout 3000",
// the DDL inside it, read live and armed at its own property rather than left at the row bound
"select @@lock_timeout", "set lock_timeout 5000",
"the ddl",
// what the session carried a statement before the DDL, which is the row bound of this write
"set lock_timeout 3000",
// and the value the deployment set, once the write is through
"set lock_timeout -1"));
}
/**
* And a DDL that gives up at that bound is reported as what it is, naming its own property - the
* rename of #885, which a DDL left at the row bound would lose along with the bound: it would arrive
* as the bare error 1222, sending an operator to neither property.
*/
@Test
public void testADdlInsideAWriteThatGivesUpNamesItsOwnProperty() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
liveLockTimeout(mock(Connection.class), "-1"))) {
storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
try {
storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
throw new SQLException("Lock request time out period exceeded.", "HY000", 1222);
});
fail("a DDL that gave up on its lock went through");
} catch (SQLException e) {
assertTrue(e.getMessage().contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY), e.getMessage());
}
}
}
/**
* And that value is put on the session, not merely decided against. A deployment giving up sooner
* than the DDL bound keeps its own figure - the argument that leaves oracle alone - but "keeps what
* it has" is not what the session has once the row bound of this write is on that very setting: at a
* {@code LOCK_TIMEOUT} of 4 s the DDL would run at the 3 s of the row bound, tighter than either
* property, and give up as the bare error 1222. What the deployment set goes on for the length of
* the DDL, and the row bound of the write goes back on behind it.
*/
@Test
public void testADdlInsideAWriteRunsAtWhatTheDeploymentSetWhereThatIsTighter() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
liveLockTimeout(mock(Connection.class), "4000"))) {
final ArmedLockBound row = storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
issued.add("the ddl");
return null;
});
storage.releaseLockBound(con, Dialect.MICROSOFT, row);
}
assertEquals(issued, asList(
// the row bound of the write, over a deployment looser than it
"select @@lock_timeout", "set lock_timeout 3000",
// the DDL inside it, at the value the deployment set rather than at the row bound it met
"select @@lock_timeout", "set lock_timeout 4000",
"the ddl",
// the row bound of the write back, and the deployment's value once the write is through
"set lock_timeout 3000", "set lock_timeout 4000"));
}
/**
* The same where an operator raised the DDL bound for an index build and the deployment bounds every
* lock wait of its sessions: 30 s asked for, 10 s allowed, and the row bound of the write is neither.
*/
@Test
public void testTheSameWhereTheDdlBoundWasRaisedForAnIndexBuild() throws Exception {
System.setProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY, "30");
try (final CachedConnection con = new CachedConnection("jdbc:mock",
liveLockTimeout(mock(Connection.class), "10000"))) {
storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
issued.add("the ddl");
return null;
});
}
assertEquals(issued, asList(
"select @@lock_timeout", "set lock_timeout 3000",
"select @@lock_timeout", "set lock_timeout 10000",
"the ddl",
"set lock_timeout 3000"));
}
/**
* And a DDL that gives up under that value is left exactly as it arrived: what ended the wait is the
* deployment's own {@code LOCK_TIMEOUT}, and naming this property for it would send an operator to
* raise a value that governs nothing while the deployment's own is the tighter one.
*/
@Test
public void testADdlThatGaveUpAtTheDeploymentsValueIsNotNamedByThisProperty() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
liveLockTimeout(mock(Connection.class), "4000"))) {
storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
try {
storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
throw new SQLException("Lock request time out period exceeded.", "HY000", 1222);
});
fail("a DDL that gave up on its lock went through");
} catch (SQLException e) {
assertFalse(e.getMessage().contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY),
"a wait the deployment's own value ended was reported as this property's doing: " + e.getMessage());
assertEquals(e.getErrorCode(), 1222, "the failure of the engine was not handed through as it arrived");
}
}
}
/**
* And the same of a lookup, which is the other kind of work this bound is armed around: the one
* deciding each drop of a clear wraps whatever it sees in a {@code StorageRuntimeException}
* ({@code isExistsTable}), so it reaches the rename by the unchecked arm rather than the checked
* one. Both arms read the same thing - whether the session is carrying this bound's own figure -
* and keying either of them on "a setting of ours was issued" instead would name this property for
* a wait the deployment's own value ended.
*/
@Test
public void testALookupThatGaveUpAtTheDeploymentsValueIsNotNamedByThisPropertyEither() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
liveLockTimeout(mock(Connection.class), "4000"))) {
storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
try {
storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
throw new StorageRuntimeException(
new SQLException("Lock request time out period exceeded.", "HY000", 1222));
});
fail("a lookup that gave up on its lock went through");
} catch (StorageRuntimeException e) {
assertFalse(String.valueOf(e.getMessage()).contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY),
"a wait the deployment's own value ended was reported as this property's doing: " + e.getMessage());
}
}
}
/**
* On mysql the two waits are two variables - {@code innodb_lock_wait_timeout} for the row lock,
* {@code lock_wait_timeout} for the metadata lock a DDL waits for - so what the row bound displaced
* describes neither the other's session nor its default. Reading it there would leave a DDL waiting
* a year because a deployment had tightened the row lock to a second.
*/
@Test
public void testTheDdlBoundOfAMysqlWriteIsDecidedAgainstItsOwnVariable() throws Exception {
try (final CachedConnection con = new CachedConnection("jdbc:mock",
answering(mock(Connection.class),
"select @@session.innodb_lock_wait_timeout", "1",
"select @@session.lock_wait_timeout", "31536000"))) {
storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
storage.withDdlLockBound(con, Dialect.MYSQL, () -> {
issued.add("the ddl");
return null;
});
}
assertEquals(issued, asList(
// the row bound: this session gives up sooner than ours would, so it keeps what it has
"select @@session.innodb_lock_wait_timeout",
// and the metadata lock is bounded all the same, against the variable that bounds it
"select @@session.lock_wait_timeout", "set session lock_wait_timeout=5",
"the ddl",
"set session lock_wait_timeout=31536000"));
}
/**
* The latches these bounds warn through are per bound, not per storage: they are armed by different
* code on different paths, and an open whose DDL bound this session would not take says nothing
* about the writes behind it. Through one latch, the first open of a backend would silence every
* write of it - which is what these two assertions, taken together, keep from happening.
*/
@Test
public void testTheWarningLatchesAreOnePerBound() throws Exception {
final Connection con = refusingTheSetting(mock(Connection.class), "31536000");
storage.armLockBound(con, Dialect.MYSQL, LockBound.DDL);
assertTrue(storage.lockBoundNotSetWarned.get(LockBound.DDL).get(),
"a setting the session refused was not reported for the bound that was armed");
assertFalse(storage.lockBoundNotSetWarned.get(LockBound.ROW).get(),
"the DDL bound of an open silenced the row lock bound of every write behind it");
storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
assertTrue(storage.lockBoundNotSetWarned.get(LockBound.ROW).get(),
"the row lock bound of a write said nothing of its own");
}
/**
* The same, for the moment throttling what a bound left behind says - and asked of the row bound,
* which is the one a single shared moment would leave unsaid: what the two of them have to be is
* one per bound, in both directions.
*/
@Test
public void testTheLatchOfABoundLeftBehindIsOnePerBoundToo() throws Exception {
final Connection con = refusingToGiveTheValueBack(mock(Connection.class), "31536000");
storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
assertTrue(storage.lockBoundLeftBehindWarned.get(LockBound.ROW).get() != 0,
"a bound that could not be taken off was not reported for the bound that was armed");
assertEquals(storage.lockBoundLeftBehindWarned.get(LockBound.DDL).get(), 0L,
"the row lock bound of a write silenced the DDL bound of every open of this backend");
}
private static String replayReason(SQLException failure, Dialect dialect, ArmedLockBound rowLock) {
return replayReason(failure, UNKNOWN_ENGINE, false, false, false, dialect, rowLock);
}
/**
* The two questions {@code write()} asks after a failed attempt, composed here the way it composes
* them: the conflict class is read off the failure once and handed to the reason, rather than being
* asked for again.
*/
private static String replayReason(SQLException failure, String driver, boolean committing,
boolean partlyCommitted, boolean connectionClosed, Dialect dialect, ArmedLockBound rowLock) {
return JDBCStorage.replayReason(JDBCStorage.conflictVerdict(failure, driver).conflict, failure, committing,
partlyCommitted, connectionClosed, dialect, rowLock);
}
/**
* A connection recording every session statement it is given, and answering the readback of the
* setting with the value a session of that engine carries.
*/
private Connection recording(final Connection con, final String carries) throws SQLException {
final Statement statement = mock(Statement.class);
when(statement.execute(anyString())).thenAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
return false;
});
when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
final ResultSet carried = mock(ResultSet.class);
when(carried.next()).thenReturn(true, false);
when(carried.getString(1)).thenReturn(carries);
return carried;
});
when(con.createStatement()).thenReturn(statement);
return con;
}
/**
* A connection answering each readback its own value, which is what an engine whose two waits are
* two variables does: every other fixture here answers one value to every query, so a case over one
* of them cannot tell the value of one setting from the value of the other.
*
* @param answers the query and the value it is answered with, in pairs
*/
private Connection answering(final Connection con, final String... answers) throws SQLException {
final Statement statement = mock(Statement.class);
when(statement.execute(anyString())).thenAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
return false;
});
when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
final String query = (String) invocation.getArguments()[0];
issued.add(query);
final ResultSet carried = mock(ResultSet.class);
when(carried.next()).thenReturn(true, false);
for (int i = 0; i < answers.length; i += 2) {
if (answers[i].equals(query)) {
when(carried.getString(1)).thenReturn(answers[i + 1]);
return carried;
}
}
throw new SQLException("this fixture answers no " + query, "42000");
});
when(con.createStatement()).thenReturn(statement);
return con;
}
/**
* A sql server connection answering the readback with what the last {@code set lock_timeout} left on
* it - a live session rather than a fixed value, which is what a bound armed inside another one
* meets.
*/
private Connection liveLockTimeout(final Connection con, final String initially) throws SQLException {
final AtomicReference carried = new AtomicReference<>(initially);
final Statement statement = mock(Statement.class);
when(statement.execute(anyString())).thenAnswer(invocation -> {
final String sql = (String) invocation.getArguments()[0];
issued.add(sql);
if (sql.startsWith("set lock_timeout ")) {
carried.set(sql.substring("set lock_timeout ".length()));
}
return false;
});
when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
final ResultSet rows = mock(ResultSet.class);
when(rows.next()).thenReturn(true, false);
when(rows.getString(1)).thenReturn(carried.get());
return rows;
});
when(con.createStatement()).thenReturn(statement);
return con;
}
/** A connection that says what it carries and will not take the setting of a bound at all. */
private Connection refusingTheSetting(final Connection con, final String carries) throws SQLException {
final Statement statement = recording(con, carries).createStatement();
doAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
throw new SQLException("this session takes no such setting", "42000");
}).when(statement).execute(anyString());
return con;
}
/** A connection that takes the bound and will not take back the value that bound displaced. */
private Connection refusingToGiveTheValueBack(final Connection con, final String carries) throws SQLException {
final Statement statement = recording(con, carries).createStatement();
final AtomicBoolean bound = new AtomicBoolean();
doAnswer(invocation -> {
issued.add((String) invocation.getArguments()[0]);
if (!bound.compareAndSet(false, true)) {
throw new SQLException("the connection went before the value could be given back", "08006");
}
return false;
}).when(statement).execute(anyString());
return con;
}
}