diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java index fcf3737df4..3ead059ad2 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java @@ -47,6 +47,27 @@ public class JDBCStorage implements org.opends.server.backends.pluggable.spi.Sto private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass(); + /** Number of attempts a {@link #write} makes before it propagates the conflict to the caller. */ + private static final int MAX_RETRIES = 10; + + /** Upper bound of the random delay before the second attempt, in milliseconds; it doubles with every attempt. */ + private static final double BASE_SLEEP_ON_RETRY_MS = 50.0; + + /** Upper bound the doubled delay is capped at, in milliseconds. */ + private static final double MAX_SLEEP_ON_RETRY_MS = 1000.0; + + /** Number of {@link Throwable#getCause()} hops walked when classifying a failure, also a guard against a cycle. */ + private static final int MAX_CAUSE_HOPS = 16; + + /** SQL Server error number of the transaction picked as the deadlock victim: "Rerun the transaction". */ + private static final int MSSQL_DEADLOCK_VICTIM = 1205; + + /** MySQL error number of a lock wait that timed out: ER_LOCK_WAIT_TIMEOUT, reported with SQLState HY000. */ + private static final int MYSQL_LOCK_WAIT_TIMEOUT = 1205; + + /** Oracle error number of a detected deadlock: ORA-00060, reported with SQLState 61000 rather than class 40. */ + private static final int ORACLE_DEADLOCK_DETECTED = 60; + private JDBCBackendCfg config; public JDBCStorage(JDBCBackendCfg cfg, ServerContext serverContext) { @@ -170,6 +191,18 @@ public void removeStorageFiles() throws StorageRuntimeException { } //operation + /** + * {@inheritDoc} + *
+ * A rolled back read is not replayed, although
+ * {@link org.opends.server.backends.pluggable.spi.Storage#read(ReadOperation)} asks for it: two of the read
+ * operations of this server are not idempotent, and replaying them corrupts their result rather than repairing
+ * it. {@code ExportJob} runs the whole export inside a single read and its LDIF writer is opened once, so a
+ * replay appends the entries already written instead of truncating the file; {@code VerifyJob} accumulates its
+ * counters in instance fields that no attempt resets, so a replay reports twice the entry count of the backend.
+ * Both are reachable while the server is online, since an export holds no more than a shared backend lock.
+ * A conflict therefore fails the read here, exactly as it did before the retry of {@link #write} was added.
+ */
@Override
public
+ * {@link org.opends.server.backends.pluggable.spi.Storage#write(WriteOperation)} requires an implementation to
+ * retry a rolled back operation until it succeeds, and {@link WriteOperation} is documented as idempotent for
+ * exactly that reason; {@link org.opends.server.backends.pdb.PDBStorage#write(WriteOperation)} already does so
+ * on the conflict exception of its own engine. The loop is bounded here, unlike PDBStorage: the database may be
+ * shared with writers outside this server, so a conflict is not guaranteed to clear and failing the operation is
+ * better than never returning.
+ *
+ * Only the operation itself is replayed: a failure of {@link #getConnection()} or of the implicit
+ * {@link Connection#close()} - which returns the connection to the pool after a rollback - leaves the loop, so
+ * that a completed write is never replayed because releasing its connection failed.
+ */
@Override
public void write(WriteOperation writeOperation) throws Exception {
- try (final Connection con=getConnection()) {
- try {
- writeOperation.run(new WriteableTransactionTransactionImpl(con));
- con.commit();
- } catch (Exception e) {
+ for (int attempt=1;;attempt++) {
+ Exception failure=null;
+ String driver=null;
+ try (final Connection con=getConnection()) {
+ driver=getDriverName(con);
try {
- con.rollback();
- } catch (SQLException ex) {}
- throw e;
+ writeOperation.run(new WriteableTransactionTransactionImpl(con));
+ con.commit();
+ return;
+ } catch (Exception e) {
+ try {
+ con.rollback();
+ } catch (SQLException ex) {}
+ //rethrown, so that a failure of the implicit close() is suppressed into the failure being
+ //replayed rather than replacing it
+ failure=e;
+ throw e;
+ }
+ } catch (Exception e) {
+ //anything the operation did not throw comes from getConnection() or from the implicit close(),
+ //which returns the connection to the pool: neither belongs to the replayed region
+ if (e!=failure) {
+ throw e;
+ }
+ }
+ if (attempt>=MAX_RETRIES || !isRetryableConflict(failure,driver)) {
+ throw failure;
+ }
+ //logged rather than silently absorbed, so that a deployment retrying most of its writes stays observable
+ logger.warn(LocalizableMessage.raw("jdbc: replaying the transaction after a conflict, attempt %d of %d: %s",
+ attempt, MAX_RETRIES, stackTraceToSingleLineString(failure)));
+ try {
+ //randomized to spread the retries of the transactions that collided, growing to outlast contention
+ Thread.sleep(retryDelayMillis(attempt));
+ } catch (InterruptedException e) {
+ //sleep cleared the interrupt flag: restore it, and report the failure being retried rather than the
+ //interrupt, which would hide from the caller what actually went wrong
+ Thread.currentThread().interrupt();
+ failure.addSuppressed(e);
+ throw failure;
+ }
+ }
+ }
+
+ /** Returns the randomized delay before the given attempt is replayed, doubling with each attempt up to a cap. */
+ static long retryDelayMillis(int attempt) {
+ final double bound=Math.min(MAX_SLEEP_ON_RETRY_MS, BASE_SLEEP_ON_RETRY_MS * (1 << Math.min(attempt-1, 5)));
+ return (long) (Math.random() * bound);
+ }
+
+ /** Returns the class name of the driver behind the given connection, which names the engine it talks to. */
+ static String getDriverName(Connection con) {
+ return ((con instanceof CachedConnection) ? ((CachedConnection) con).parent : con).getClass().getName();
+ }
+
+ /**
+ * Returns whether the given failure carries a transaction conflict that replaying the operation can resolve.
+ *
+ * The conflict is looked up along the whole cause chain because it reaches this class wrapped: a deadlock in
+ * {@code put} arrives as {@code StorageRuntimeException(SQLException)}, and a caller such as
+ * {@code EntryContainer.addEntry} may wrap it once more.
+ *
+ * The standard class 40 states carry the conflict of most engines - 40P01 for PostgreSQL, 40001 for MySQL and
+ * for SQL Server - but not of all of them, so the vendor error numbers are consulted as well, keyed by the
+ * driver in the same way {@code getTableDialect} keys the column types. They cannot be matched
+ * driver-independently: Oracle reports a deadlock as ORA-00060 with SQLState 61000, and gives 1205 to a fatal
+ * "not a data file" error that no replay can resolve, while 1205 is exactly the deadlock victim of SQL Server
+ * and the lock wait timeout of MySQL. The MySQL timeout is not a deadlock, but it is transient in the same way
+ * and is equally resolved by a replay. The SQL Server number is matched beyond its class 40 state because a
+ * deployment may add {@code xopenStates=true} to its connection URL, which reports the same deadlock as 42000.
+ */
+ static boolean isRetryableConflict(Throwable t, String driver) {
+ for (int hop=0; t!=null && hop
+ * Runs without a database: the failures the drivers report are reproduced as synthetic
+ * {@link SQLException}s carrying the same vendor error number and SQLState.
+ */
+@Test(sequential = true)
+@SuppressWarnings("javadoc")
+public class JDBCStorageRetryTest extends DirectoryServerTestCase
+{
+ /** Driver class names, which is what the classification keys the vendor error numbers off. */
+ private static final String MSSQL = "com.microsoft.sqlserver.jdbc.SQLServerConnection";
+ private static final String MYSQL = "com.mysql.cj.jdbc.ConnectionImpl";
+ private static final String ORACLE = "oracle.jdbc.driver.T4CConnection";
+ private static final String POSTGRES = "org.postgresql.jdbc.PgConnection";
+
+ /** A failure whose cause chain is a cycle, to check that walking it terminates. */
+ private static final class SelfCausedException extends RuntimeException
+ {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public synchronized Throwable getCause()
+ {
+ return this;
+ }
+ }
+
+ @DataProvider
+ public Object[][] failures()
+ {
+ return new Object[][] {
+ // SQL Server picking a transaction as the deadlock victim: the failure this retry exists for
+ { "mssql deadlock victim", sql(1205, "40001"), MSSQL, true },
+ // a deployment may add xopenStates=true to its connection URL, which reports the same deadlock as 42000
+ { "mssql deadlock victim, xopenStates", sql(1205, "42000"), MSSQL, true },
+ // the conflict of most other engines is carried by the SQLState, under a vendor number of their own
+ { "postgres serialization failure", sql(0, "40001"), POSTGRES, true },
+ { "postgres deadlock detected", sql(0, "40P01"), POSTGRES, true },
+ { "mysql deadlock", sql(1213, "40001"), MYSQL, true },
+ // not a deadlock, but transient in the same way and equally resolved by a replay
+ { "mysql lock wait timeout", sql(1205, "HY000"), MYSQL, true },
+ // Oracle maps ORA-00060 to SQLState 61000, so only its error number identifies the deadlock
+ { "oracle deadlock detected", sql(60, "61000"), ORACLE, true },
+
+ // the conflict reaches JDBCStorage.write() wrapped, so the whole cause chain has to be walked
+ { "wrapped once", new StorageRuntimeException(sql(1205, "40001")), MSSQL, true },
+ { "wrapped twice",
+ new DirectoryException(OTHER, raw("unchecked"), new StorageRuntimeException(sql(1205, "40001"))), MSSQL,
+ true },
+
+ // the vendor numbers collide across engines, so they must not be matched driver-independently:
+ // ORA-01205 "not a data file" is fatal, and no replay resolves it
+ { "oracle not a data file", sql(1205, "64000"), ORACLE, false },
+ // and a lock wait timeout is a MySQL number: 1205 means nothing of the kind to PostgreSQL
+ { "postgres unrelated 1205", sql(1205, "22001"), POSTGRES, false },
+
+ // nothing a replay can resolve
+ { "primary key violation", sql(2627, "23000"), MSSQL, false },
+ { "syntax error", sql(102, "S0001"), MSSQL, false },
+ { "no SQLState", sql(0, null), MSSQL, false },
+ { "not a SQLException", new IllegalStateException("connection closed"), MSSQL, false },
+ { "wrapped, not a conflict", new StorageRuntimeException(sql(2627, "23000")), MSSQL, false },
+ { "no failure at all", null, MSSQL, false },
+ { "unknown driver", sql(1205, "HY000"), null, false },
+ { "cyclic cause chain", new SelfCausedException(), MSSQL, false },
+ };
+ }
+
+ @Test(dataProvider = "failures")
+ public void testIsRetryableConflict(String name, Throwable failure, String driver, boolean expected)
+ {
+ assertEquals(JDBCStorage.isRetryableConflict(failure, driver), expected, name);
+ }
+
+ /** The delay grows with the attempt, so that the replays outlast a contention lasting more than a few ms. */
+ @Test
+ public void testRetryDelayGrowsAndStaysBounded()
+ {
+ long previousBound = 0;
+ for (int attempt = 1; attempt <= 10; attempt++)
+ {
+ long bound = 0;
+ for (int i = 0; i < 100; i++)
+ {
+ final long delay = JDBCStorage.retryDelayMillis(attempt);
+ assertTrue(delay >= 0, "attempt " + attempt + " waited " + delay + " ms");
+ assertTrue(delay < 1000, "attempt " + attempt + " waited " + delay + " ms");
+ bound = Math.max(bound, delay);
+ }
+ assertTrue(bound >= previousBound / 2, "attempt " + attempt + " did not grow past attempt " + (attempt - 1));
+ previousBound = bound;
+ }
+ }
+
+ private static SQLException sql(int errorCode, String sqlState)
+ {
+ return new SQLException("synthetic failure", sqlState, errorCode);
+ }
+}