Skip to content

[#903] Grant the first replay of a conflict, and bound the rest by its class - #904

Open
maximthomas wants to merge 6 commits into
OpenIdentityPlatform:masterfrom
maximthomas:issues/903-jdbc-deadlock-retry-window
Open

[#903] Grant the first replay of a conflict, and bound the rest by its class#904
maximthomas wants to merge 6 commits into
OpenIdentityPlatform:masterfrom
maximthomas:issues/903-jdbc-deadlock-retry-window

Conversation

@maximthomas

@maximthomas maximthomas commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #903.

Problem

JDBCStorage.write() bounds its replay loop twice — by MAX_RETRIES (10) and by a 10 s wall-clock window
added in #867. The window is started before the first attempt and checked only after an attempt has failed:

// opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:883 (master)
final long giveUpAt=System.nanoTime()+MAX_RETRY_WINDOW_NANOS;
...
if (attempt>=MAX_RETRIES || System.nanoTime()-giveUpAt>=0 || !isRetryableConflict(failure,driver)) {
    throw failure;
}

So an attempt that alone outlasts the window leaves the loop with zero replays, whatever the conflict.
The window's javadoc said why that was believed safe — "every engine reports one in well under a second"
and that is the part that does not hold. Detection is prompt; time to victim is not. The wait that precedes
a conflict is charged to the attempt that hit it, and it is unbounded on three of the four engines here:
Dialect.lockTimeoutSql is issued on the stamp session only, never on a transaction connection.

Run 33010633197
lost MsSqlTestCase>TestCase.testConcurrentWritersInsertingDistinctKeys to exactly this: a SQL Server victim
picked ~12 s into the first attempt, one failure out of 31989 tests. The run's own error log holds two replay
lines for the whole class, both 40001 / 1205, both succeeding on the first replay — so classification was
right, MAX_RETRIES (which logs nine lines) had not fired, and the failing transaction logged nothing at all.
The window was the only guard left, and it had been spent inside one attempt.

The fix

One window, 10 s, unchanged from master — and one grant on top of it. The first replay of a conflict the
engine reports promptly is never denied by the clock. The wait ahead of such a conflict belongs to the
attempt and is unbounded, so there is no window that some wait does not outlast: measuring one against it does
not bound the wait, it only leaves the operation with no replay at all. That is #903.

The grant stops there. It does not extend to a conflict the engine reported only after a lock wait timeout of
its own, because that wait is already bounded and replaying it costs the same wait again — which is the one
thing the window exists to refuse. Telling the two apart is the whole of the new classification:

engine conflict number / state class first replay
SQL Server deadlock victim 1205 / 40001 (42000 under xopenStates) PROMPT granted
PostgreSQL serialization failure, deadlock 40001, 40P01 PROMPT granted
Oracle ORA-00060 60 / 61000 PROMPT granted
MySQL deadlock 1213 / 40001 PROMPT granted
MySQL lock wait timeout 1205 / 40001 AFTER_LOCK_WAIT governed by the window
any driver none of the four is recognised in whatever class 40 carries 40001, 40P01, ... UNKNOWN_ENGINE governed by the window

The last row is the MySQL-wire-compatible case: MariaDB Connector/J, an Aurora- or Percona-branded driver.
It reports innodb_lock_wait_timeout as 1205 under class 40 exactly as Connector/J does, and a backend
created under com.mysql.cj.jdbc opens through it unharmed — every create table and create index of
openTree(createOnDemand) is guarded by a catalog read, so an existing backend issues no DDL at all, and its
writes go down the ANSI branch of upsert. Reading 1205 only under a name carrying mysql would hand that
deployment the grant, and with it a second full 50 s wait. The grant rests on knowing the engine bounded
nothing, so an engine this backend does not recognise is refused it; the cost is one replay of the window's
length for an engine whose conflicts are in fact prompt, which is the direction worth being wrong in.

Which engine a driver is, is now one answer rather than five: dialectOf(String driverName) — the
Connection-taking overload delegates to it — keys the column types, the upsert, the paging clause, whether a
DDL statement commits, and the class of a conflict. Five contains(...) cascades over the same string is how
a deployment ends up given one engine's SQL and another engine's conflict class.

The decision is extracted whole into two pure functions, which is what lets the regression be tested without a
database:

static boolean replayableWithin(int attempt, long elapsedNanos, Conflict conflict) {
    if (attempt>=MAX_RETRIES) {
        return false;
    }
    if (grantedPastTheWindow(attempt, elapsedNanos, conflict)) {
        return true;
    }
    return elapsedNanos<RETRY_WINDOW_NANOS;
}

static boolean grantedPastTheWindow(int attempt, long elapsedNanos, Conflict conflict) {
    return attempt==1 && conflict==Conflict.PROMPT && elapsedNanos>=RETRY_WINDOW_NANOS;
}

grantedPastTheWindow is a question of its own rather than a branch, so that the line reporting a replay can
name the bound that was applied instead of re-deriving it from the clock.

The class and the link it was read from are one answer, ConflictVerdict, produced by a single walk that keeps
the strongest class it meets — Conflict is declared in order of how much each class restricts the replay, so
"strongest wins" is a property of the list rather than a rule the reader of one call site has to reconstruct.
write() asks for it once and hands it to both decisions and to the line reporting them, and does not ask at
all where the answer is discarded: a partly committed attempt is refused its replay by that flag alone, and it
is the path most likely to carry deeply wrapped chains, since RootContainer.open() commits DDL and raises the
flag for the rest of the write.

What it costs

What the write() javadoc says, which is the accurate statement: a conflicted operation holds its caller for
the window plus one attempt, and a prompt conflict for two attempts when that is longer.

conflict caller held for, worst case on master
prompt conflict, attempt shorter than the window 10 s + one attempt the same
prompt conflict, attempt longer than the window two attempts — and while #915 is open, two attempts is not bounded one attempt, and zero replays
MySQL lock wait timeout one innodb_lock_wait_timeout, 50 s by default the same
an unrecognised engine's conflict the window plus one attempt the same

The second row is the trade, stated plainly. The wait ahead of a prompt conflict is charged to the attempt
that hit it and nothing bounds it: SQL Server's LOCK_TIMEOUT is -1 by default and Oracle's enqueue wait is
unlimited, and Dialect.lockTimeoutSql is issued on the stamp session only, never on a transaction
connection. So where master released the worker thread after one such wait, this holds it for two — a victim
picked ~12 s in costs ~24 s, and a long write() picked as victim at its end costs twice its own length. That
is deliberate: the operation fails either way on master, and the replay is what resolves it. #915 removes the
trade by bounding the attempt itself, and retires the grant with it.

No replay budget is widened. What changes is that a prompt conflict whose attempt outlasted the window now gets
one replay instead of none.

The other side of that trade is stated in the RETRY_WINDOW_NANOS javadoc rather than left to a test row: at
the stock innodb_lock_wait_timeout=50 a MySQL lock wait timeout is reported past the window on the first
check and gets no grant, so it is never replayed — the conflict class a MySQL deployment sees most. Master
behaved identically; this PR is the first place that says so. A deployment that tunes the timeout below 10 s
gets its replays back.

What it does not change

The matching predicate. isConflict matches the same failures it did on master, and classOf returns
NONE iff isConflict does not match, so a failure is replayable for exactly the same inputs as before —
nulls, non-SQLExceptions and a cyclic cause chain included. That matters because the vendor numbers collide
across engines: 1205 is a SQL Server deadlock victim, a MySQL lock wait timeout, and a fatal Oracle
"not a data file". The number added here refines a match already made by the state; it never makes one.

All 23 pre-existing rows of JDBCStorageRetryTest.failures() keep their old replayability. Two of them —
the two with no driver at all — change class, from PROMPT to UNKNOWN_ENGINE: replayed as before, no longer
granted the replay past the window.

The one thing the walk of the chains does change: the conflict class is now read from every link rather than
from MAX_CHAIN_LINKS of them, the way failureScope() already read its own. That budget guards a question
truncation can only leave unanswered; here it weakens the verdict instead — an AFTER_LOCK_WAIT link past the
64th visited Throwable, with a bare class 40 link inside the budget, would come back PROMPT and be granted
the replay the class exists to refuse. The seen set terminates the walk either way.

Tests

JDBCStorageRetryTest — 95 cases, no database, synthetic SQLExceptions carrying the real numbers and states.
Written before the fix, and each stage was watched failing first. The mutants, re-checked at this head:

  • delete the grant (grantedPastTheWindow returns false) → 5 failures, among them
    deadlock reported after a long lock wait expected [true] but found [false] — the CI regression itself — and
    the first replay is granted past the window: attempts made expected [2] but found [1], which pins it through
    write() rather than only through the pure function;
  • move startedAt inside the retry loopthe window bounds the run, not the attempt: attempts made expected [3] but found [10];
  • stop handing conflictSummary the link the class was read from (named = null, so it falls back) → 2
    failures, testConflictSummaryNamesTheLinkTheClassWasDecidedOn on SQLState 40001, error 0;
  • bound the conflict walk by MAX_CHAIN_LINKS (as it was, and as failureScope() deliberately is not) →
    a lock wait timeout past MAX_CHAIN_LINKS came back as a conflict the window does not bound expected [AFTER_LOCK_WAIT] but found [PROMPT];
  • hand an unrecognised engine the grant (classOf returns PROMPT for a null dialect) → 5 failures: the
    two failures() rows with no driver, the MARIADB row of both providers, and
    an unrecognised engine is granted no replay: attempts made expected [1] but found [2] through write().

The scripted clock advances per attempt, not per read, so a clock read added anywhere in write() leaves
every row answering as it does now.

MsSqlTestCase, MySqlTestCase, PgSqlTestCase, OracleTestCase — no Docker on this machine, so all four
green-skip (390 run / 232 skipped over the JDBC suites, 0 failures). Read honestly: they confirm nothing about
the fix, and even with a database testConcurrentWritersInsertingDistinctKeys finishes locally in ~0.2 s
without reaching a conflict at all. The unit rows are the evidence.

Out of scope

Bounding the lock wait where it is taken — SET LOCK_TIMEOUT and its equivalents on the transaction
connection — is the only change that would bound an attempt itself, and it would retire the grant, the
Conflict enum and the driver-keyed vendor read along with it. It alters the failure every deployment sees
(SQL Server turns the wait into error 1222, HY000, which isConflict does not match today) and needs a
property and container coverage of its own. Filed as #915 with the cases it has to cover.

BackendImpl.applyConfigurationChange performs global, non-transactional side effects inside a WriteOperation
that every storage engine may replay. Pre-existing on master, and PDBStorage.write() reaches it identically,
so it is not a JDBC defect. Filed as #907 with the trace.

@maximthomas
maximthomas requested a review from vharseko August 28, 2026 07:59

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnosis here is right, and extracting replayable() into a pure, table-tested function is a real improvement. The lock wait an engine spends before reporting a conflict genuinely is charged to the attempt that hit it, so the window was already spent by the time it was first consulted — that was a real bug.

My concern is that the fix over-corrects: three of the four engines pay for the one that needs it. Two changes would resolve that, plus a guard for a pre-existing bug this PR makes materially more likely to fire.

What a conflicted write now costs the caller

Path master this PR
MySQL lock wait timeout (innodb_lock_wait_timeout = 50 s, the default this PR cites) 50 s 100 s
SQL Server deadlock victim, picked ~12 s in (the CI case cited) 12 s ~72 s
Long write() — e.g. subtree delete — picked as victim at the end 6 min 12 min

Each row is a write() that fails either way; the replays don't rescue it. What changes is how long the caller and its worker thread are held before the error comes back.


Blocking

1. The free replay goes to the one conflict class that doesn't need it — JDBCStorage.java:1021

The stated rationale for skipping the clock on attempt 1 is that the preceding wait "is unbounded on three of the four engines here". That's true — and MySQL is the fourth. Its lock wait is bounded, by innodb_lock_wait_timeout, and LOCK_WAIT_RETRY_WINDOW_NANOS exists specifically to stop a second 50-second wait from being taken.

Because attempt==1 returns before the window is ever consulted, that window can never prevent the single most expensive replay it was introduced to prevent. A deployment that raised the timeout to 120 s or 300 s is held for twice that.

// the engine asked for the transaction to be rerun: no clock denies that first
// rerun — but only where the wait preceding it is not bounded by the engine itself
-if (attempt==1) {
+if (attempt==1 && conflict==Conflict.PROMPT) {
     return true;
 }
 return elapsedNanos<windowOf(conflict);

One line, and the 10 s window is live again. It also resolves the companion problem at JDBCStorage.java:911: an attempt that alone outlasts every window — a multi-minute EntryContainer.deleteEntry under a subtree-delete control, or EntryContainer.clear() — is currently guaranteed a second full run before the window is consulted on attempt 2. The database does the work twice and the caller waits twice as long as it did on master.

2. Bound the attempt rather than compensating with a wider window — JDBCStorage.java:77

Widening PROMPT_CONFLICT_RETRY_WINDOW_NANOS from 10 s to 60 s multiplies by six how long a conflicted write pins an OpenDJ worker thread. The failure mode it's tuned for — a deadlock storm between concurrent writers — hits every worker at once, so with the pool sized to the CPU count the server can appear hung for a minute where it previously returned errors in twelve seconds. Reads queue behind the same pool.

The root cause isn't the window; it's that the wait is charged to the attempt and nothing caps the attempt. That's fixable with one statement on the transaction connection: SET LOCK_TIMEOUT on SQL Server, SET LOCAL lock_timeout on PostgreSQL, and MySQL already has innodb_lock_wait_timeout. Only Oracle lacks a session-level equivalent for plain DML — so the unconditional replay is really needed for one engine out of four, and all four currently pay for it. Once an attempt is bounded, the elapsed-time window works exactly as originally designed.

The PR defers that work, which is what leaves the trailing attempt uncapped and forces the free replay in the first place. Suggestion: either bring the session timeout into this PR, or keep the window at 10 s until it lands.

3. A replayed config change dies on a null EntryContainerBackendImpl.java:856

Storage.write()'s contract requires an idempotent WriteOperation. The one in BackendImpl.applyConfigurationChange isn't: removeDeletedBaseDNs performs global, non-transactional side effects that a rollback can't undo, and it does them before the transactional work.

Traced through: deregisterBaseDN(baseDN), then rootContainer.unregisterEntryContainer(baseDN) — which is entryContainers.remove(baseDN) at RootContainer.java:247 — then ec.close(), and only then ec.delete(txn). If the commit deadlocks, the rollback restores the tables but the deregistration and the close are permanent. cfg is reassigned at the very end of run(), so the replay still sees the removed DN in the old config, calls remove on a map that no longer holds it, gets null, and NPEs in ec.close(). The operator sees a NullPointerException instead of a deadlock, with the backend already half-deregistered.

This is reachable on master too — the PR doesn't introduce it. What changes is the odds: this PR strictly increases how often a conflict is replayed, and the first replay is now unconditional. The durable fix is to move the deregistration and close() outside write(), after a successful commit; the minimum is a null guard plus deriving the set to remove from rootContainer rather than from cfg.


Worth fixing in this PR

Eight low-cost items — two of them are traps a future change would fall into silently
  • conflictOf should prefer the most specific classification in the chainJDBCStorage.java:981. It returns the class of the first matching SQLException, so a wrapper carrying a class 40 state with errorCode 0 downgrades a real 1205 from AFTER_LOCK_WAIT to PROMPT and hands it the wide window. Walk the whole chain and keep the most specific answer found.

  • Make windowOf refuse Conflict.NONEJDBCStorage.java:1028. Today it silently returns the widest window, and the "never gets here" guarantee lives entirely in one early return in replayable. Throw, or return 0, so a future reordering fails loudly instead of granting a non-conflict the longest retry budget in the class.

  • One constant for 1205, not two names for itJDBCStorage.java:89, :101. MYSQL_LOCK_WAIT_TIMEOUT and MSSQL_DEADLOCK_VICTIM are the same literal, so substituting one for the other in classOf or isConflict leaves all 66 test cases green while inverting the intent.

  • Resolve the driver onceJDBCStorage.java:1002. classOf is the third independent driverName.contains(...) cascade in the file, after dialectOf and isConflict, and no test cross-checks them for agreement. MariaDB (org.mariadb.jdbc.*) already gets different answers from two of them: classOf classifies its lock wait timeout as PROMPT while dialectOf returns null for the same connection. Extracting a dialectOfDriverName(String) and keying all three off it makes them consistent by construction.

  • Log the bound that actually appliesJDBCStorage.java:916. The warning still reads attempt %d of %d against MAX_RETRIES, but the effective cap is now the class window — a MySQL lock wait timeout stops at attempt 2 of a promised 10, with nothing in the log saying why. Naming the conflict class and the window makes the two bounds distinguishable from outside.

  • isRetryableConflict no longer has a caller in src/mainJDBCStorage.java:959. Since write() switched to replayable() it survives as a thin wrapper kept alive by the test and one javadoc link — while carrying the longest doc block in the file, which now documents behaviour reached only through conflictOf. Either fold the doc onto conflictOf and delete the wrapper, or have write() keep calling it.

  • The javadoc's own example no longer distinguishes the two windowsJDBCStorage.java:56. At the default it names (50 s), both windows now yield exactly two attempts: AFTER_LOCK_WAIT gives attempt 1 @50 s granted, attempt 2 @100 s → 100 >= 10 → stop; PROMPT gives the same with 100 >= 60. So the Conflict enum and the MYSQL_LOCK_WAIT_TIMEOUT classifier are inert in exactly the configuration they document. Fixing item 1 above makes the comment true again.

  • TimeUnit.SECONDS.toNanos(10)JDBCStorage.java:64, :77. The hand-expanded 10L * 1000L * 1000L * 1000L needs a trailing //10 s comment to be readable, and that comment is the only thing standing between a dropped factor and a 60 ms window that still compiles. It also retires the test's private seconds() helper.


Test coverage

The line that held the bug is the one line not under testJDBCStorage.java:880. replayable() is covered by 66 cases, and that's the right thing to have extracted. But the original defect was in write()'s wiring of the clock — that startedAt is taken once, before the first attempt, and never reset — and no test touches it. A refactor that moves final long startedAt=System.nanoTime(); inside the for loop would keep all 66 cases green while restoring an unbounded retry loop: every attempt would see an elapsed time near zero and replay to MAX_RETRIES regardless of the window. Driving write() with an injectable clock and a scripted failing WriteOperation would anchor it; at minimum, assert that the elapsed argument grows monotonically across attempts.

The two tests stopped checking each otherJDBCStorageRetryTest.java:134. testIsRetryableConflict now derives its expectation from the same column testConflictClass asserts (expected != NONE), so the claim that every pre-existing row keeps its old answer isn't pinned by anything. Flip any failures() row from NONE to PROMPT, deliberately or by fat-finger, and both tests move together and the suite stays green — silently widening the set of failures that get replayed. Keeping the boolean as its own independent column, or asserting the retryability rows against a literal list, restores the cross-check.


Reviewed against pull/904/head vs master. The BackendImpl finding and the dead-code finding were traced through the source and confirmed; the rest are read from the diff and its surrounding call sites. Durations are derived from the constants in the diff and the engine defaults the PR's own javadoc cites — no benchmark was run.

…cts alone, and keep one window

The first replay was granted to every conflict, which handed it to the one
class that does not need it. A MySQL lock wait timeout is reported only once
innodb_lock_wait_timeout has elapsed - the engine has already bounded that
wait - so a free replay buys a second wait of the same length, 100 s at the
50 s default where master released the worker after 50 s. Worse, the window
was never consulted for it: the grant returned first, so the 10 s bound could
not fire against the single most expensive replay it was introduced to stop.

The grant now goes to Conflict.PROMPT only, whose preceding wait SQL Server,
Oracle and PostgreSQL all leave unbounded - no window survives it, and
measuring one against it is what left issue OpenIdentityPlatform#903 with zero replays.
AFTER_LOCK_WAIT is measured against the window from the first attempt, which
is what the class was introduced to do.

With the grant narrowed, the 60 s widening of the prompt window was
compensating for a defect rather than for anything real, and it multiplied by
six how long a conflicted write pins a worker thread - a deadlock storm hits
every worker at once. Reverted to 10 s. That leaves the two window constants
bound to the same literal, a name apiece for one value, so they are collapsed
into RETRY_WINDOW_NANOS and windowOf() is deleted. The Conflict enum survives
and is no longer inert at the documented default: it decides the grant.

Also:

- conflictOf() walks the whole cause chain and keeps the most specific class
  found, so a wrapper carrying a bare class 40 state no longer downgrades the
  AFTER_LOCK_WAIT of the SQLException it wraps.
- isRetryableConflict() is deleted; write() has called replayable() since the
  previous commit and nothing in src/main called the wrapper. Its doc moves
  onto conflictOf().
- The replay warning names the conflict class and both bounds, in ms - whole
  seconds read "0 s" for most of a burst - since the effective cap is usually
  the window and a log naming only MAX_RETRIES said nothing about why an
  operation gave up at attempt 2 of a promised 10.

The line that held the original bug - startedAt read once outside the retry
loop - had no test: replayable() is handed an elapsed time rather than
measuring one, so moving that read inside the loop kept every case green
while restoring an unbounded retry. testWriteDrivesTheRetryLoop drives write()
through a scripted clock over a new nanoTime() seam and pins both lines. The
step size is load-bearing in each direction: at 4 s the run must stop on the
third attempt, which the relocated read turns into ten, and at 12 s only the
grant can produce a second attempt, so removing it stops the run on the first.

Claude-Session: https://claude.ai/code/session_0123YLkjSmyp15GynenKE9vP
@maximthomas

Copy link
Copy Markdown
Contributor Author

Addressed in 52adad3.

Diagnosis accepted on the core of it: the free replay was granted to the one conflict class that does not need it,
and the 60 s widening was compensating for that rather than for anything real. Both are fixed. Details, including
two places the review's own reasoning does not hold up, below.

1. The free replay is now PROMPT only — done

Applied as suggested. The arithmetic is exactly as you put it: at the default innodb_lock_wait_timeout a MySQL
lock wait timeout cost 50 s on master and 100 s here, and the 10 s window could never fire against it. It now
gets no grant, and the window governs it from attempt 1 — back to master's cost for that class, with the window
live again for a deployment that tuned the timeout below it.

But the fix does not resolve the companion problem you attach to it. You write that gating on PROMPT "also
resolves the companion problem at JDBCStorage.java:911" for a multi-minute EntryContainer.deleteEntry under a
subtree-delete control. It does not: a SQL Server deadlock victim is PROMPT, so that write still gets its
guaranteed second full run and still costs 12 min — row 3 of your own table is unchanged by the one-line fix.
Only the MySQL AFTER_LOCK_WAIT case (row 1) is resolved by it.

That is not an argument against the fix, but the remaining half of row 3 is real and is not addressed here. It is
the same root cause as item 2 — an uncapped attempt — and I have left it there rather than pretending otherwise.

2. Window back to 10 s; session lock timeout deferred — done

Reverted, and the session timeout is deferred rather than brought in. Reasoning: item 1 alone fixes #903. The
issue is that an attempt outlasting the window left the loop with zero replays; a slow-reported deadlock now
gets one. The 60 s widening bought extra replays on top of that, at a concrete 6x worker-thread hold, for an
unmeasured benefit. Shipping the narrow fix and taking the window question together with the thing that makes it
coherent is the better trade.

On bringing SET LOCK_TIMEOUT / SET LOCAL lock_timeout into this PR — it is a bigger change than one statement.
On SQL Server it converts the wait into error 1222, "Lock request time out period exceeded", which is
SQLState HY000 and is not class 40: isConflict does not match it today, so a bounded wait would turn a
retryable conflict into a hard failure unless 1222 is classified in the same change. That deserves its own PR and
its own table of cases.

One consequence of reverting: with both windows at 10 s, LOCK_WAIT_RETRY_WINDOW_NANOS and
PROMPT_CONFLICT_RETRY_WINDOW_NANOS become two names for one literal — the exact smell you flag further down
about 1205. So they are collapsed into a single RETRY_WINDOW_NANOS, and windowOf is deleted. The Conflict
enum survives and is no longer inert: it now decides whether the first replay is granted, which is a live
distinction at every innodb_lock_wait_timeout, not only below 10 s.

That also disposes of "make windowOf refuse Conflict.NONE" — by deletion rather than by a throw.

3. BackendImpl — real, but the symptom is not an NPE

Traced it. The defect is real and the reordering you describe is the right fix. The reported failure mode is not:

serverContext.getBackendConfigManager().deregisterBaseDN(baseDN);   // <- throws here, first
EntryContainer ec = rootContainer.unregisterEntryContainer(baseDN); // <- never reached on a replay
ec.close();

Registry.deregisterBaseDN throws DirectoryException(UNWILLING_TO_PERFORM, ERR_DEREGISTER_BASEDN_NOT_REGISTERED) when backendsByName.get(baseDN) is null, one line before the null
EntryContainer can be dereferenced. So the operator sees "unwilling to perform" against a DN they just removed,
not a NullPointerException — and your "minimum" null guard is unreachable dead code.

Everything else about it holds: the deregistration and the close() are permanent, cfg is stale on the replay,
and the backend is left half-deregistered with its trees restored by the rollback.

Not fixed here. It is pre-existing on master, it lives in the base class of every pluggable backend, and
PDBStorage.write() replays on its own conflict exception, so JE/PDB reach it identically — this is not a JDBC
bug and does not belong in a JDBC PR. Filed as #907 with the trace and the suggested reordering.

Worth fixing in this PR

Applied:

  • Most specific classification in the chain. conflictOf now walks to the end of the chain and returns
    AFTER_LOCK_WAIT if any hop carries it, rather than the first conflict found. Two cases added:
    sql(0,"40001", sql(1205,"40001")) under MySQL is AFTER_LOCK_WAIT, and the same wrapper over a 1213 deadlock
    stays PROMPT.
  • Log the bound that applies. The warning now names the conflict class and both bounds:
    replaying the transaction after a PROMPT conflict, attempt 2 of 10, 4210 ms elapsed of the 10000 ms window: ....
    Milliseconds rather than seconds, since the engines report a deadlock in a few of them and whole seconds would
    read 0 s for most of a contention burst; and the one line that replays past its own window appends
    (the first replay, granted past it) so it does not read as a bound going unhonoured. elapsedNanos is hoisted
    into a local for it, which the test below also needed.
  • Delete isRetryableConflict. Confirmed zero src/main callers. Deleted, doc folded onto conflictOf, and
    its test with it — that test asserted conflictOf(...) != NONE == (expected != NONE) against the same column
    testConflictClass asserts, so it could not fail unless testConflictClass already had.
  • TimeUnit.SECONDS.toNanos(10), and the test's seconds() helper now delegates to it rather than being
    retired — it is still what keeps the data provider rows readable.

Not applied:

  • "One constant for 1205, not two names for it." This one is backwards. MSSQL_DEADLOCK_VICTIM and
    MYSQL_LOCK_WAIT_TIMEOUT are two different vendor errors from two different engines that collide on one
    number, and that collision is the entire reason the match is keyed by the driver — Oracle's ORA-01205 "not a
    data file" is a third meaning for it, and is fatal. Merging them into one constant deletes the documentation
    that makes the driver-keying legible, in exchange for a mutation-testing property ("substituting one for the
    other stays green") that holds for any two constants sharing a value. The javadoc on both already says which
    engine each belongs to.

  • "Resolve the driver once." The underlying point is fair but the example is not: there is no mariadb
    anywhere in this repository — no dependency, no dialect, no test case (MsSqlTestCase, MySqlTestCase,
    OracleTestCase, PgSqlTestCase are the four). And it is not three cascades but seven: dialectOf,
    isConflict, classOf, hashParam, and three more in the stamping and upsert paths. A single
    dialectOfDriverName(String) keyed off by all of them is worth doing and I will take it, but as its own change
    touching all seven, not as a fourth partial cascade added here. classOf is deliberately left keyed the same
    way isConflict is so the two agree today.

  • "The javadoc's own example no longer distinguishes the two windows." Correct, and fixed by items 1 and 2
    together rather than by a doc edit: with the grant gated on PROMPT and one window, the classifier changes the
    outcome at the documented 50 s default (one attempt, not two). The javadoc on RETRY_WINDOW_NANOS and
    replayable was rewritten accordingly.

Test coverage

Clock wiring — added. testWriteDrivesTheRetryLoop drives write() with a mocked Connection, a
WriteOperation that always throws a synthetic 40001, and a clock scripted through a new overridable
nanoTime() seam. Two rows:

  • 4 s per read → 3 attempts, 4 clock reads. Grant at 4 s, inside the window at 8 s, past it at 12 s. Moving
    startedAt inside the loop makes it 10 attempts — verified: attempts made expected [3] but found [10]. That
    is exactly the mutation you describe.
  • 12 s per read → 2 attempts, 3 clock reads. Only the grant can produce a second attempt at that step, so
    this row is what pins the grant end-to-end rather than only through the pure function. Verified against the
    mutant: deleting the grant gives attempts made expected [2] but found [1].

The step size matters and is not arbitrary — at 12 s the startedAt mutant also stops at 2 attempts, so the 4 s
row is what kills it; at 4 s the window alone already permits the attempt-1 replay, so the 12 s row is what kills
the grant removal. Neither row is redundant.

"The two tests stopped checking each other" — the premise does not hold. They never checked each other. On
master the expectation was a literal boolean in the same single data provider; flipping a row from false to
true moved the one assertion that read it, with nothing else pinning it. Deriving the boolean from a Conflict
column changed nothing about that — a data provider is not a cross-check, and was not one before this PR either.

The real content of the item is that testIsRetryableConflict had become redundant, which is the same finding as
the dead-wrapper one above. Both are resolved by deleting the wrapper and its test. If you want retryability
pinned against something independent of the class column, that is a separate ask and I would rather do it as an
explicit literal list than by keeping a tautological assertion around.


Net effect on your table, with the changes above:

Path master previous PR now
MySQL lock wait timeout (50 s default) 50 s 100 s 50 s
SQL Server victim picked ~12 s in 12 s ~72 s ~24 s
Long write() victim at the end (6 min attempts) 6 min 12 min 12 min

The last row is the one item 1 does not fix and item 2 defers; it needs the bounded attempt.

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for 52adad385c — it takes the previous round where it needed to go. The grant is now attempt==1 && conflict==Conflict.PROMPT, the two windows are back to one 10 s RETRY_WINDOW_NANOS, conflictOf walks the chain to its end and keeps the most specific class, windowOf and the caller-less isRetryableConflict are gone, TimeUnit.SECONDS.toNanos replaces the hand-expanded literal, the warning names both bounds, and write()'s clock is finally reachable from a test. That closes items 1 and 2 and most of the low-cost list.

Two things still block, and two design questions are worth settling before this merges rather than after.


Blocking

1. The BackendImpl finding from the last round is untouched — BackendImpl.java:856

The PR still touches only JDBCStorage.java and JDBCStorageRetryTest.java, so this stands exactly as written before: in applyConfigurationChange's WriteOperation, removeDeletedBaseDNs deregisters the base DN and closes the EntryContainer before the transactional ec.delete(txn), and neither side effect is undone by a rollback. A replayed config change re-enters with the DN already gone from RootContainer.entryContainers, so unregisterEntryContainer returns null and ec.close() throws — the operator sees a NullPointerException where a deadlock happened, with the backend half-deregistered.

It is reachable on master too; what this PR changes is the probability, since the first replay of a prompt conflict is now unconditional. A null guard plus deriving the removal set from rootContainer rather than from cfg is the minimum; moving the deregistration and close() after a successful commit is the fix.

2. classOf keys the lock-wait number off contains("mysql"), so a MySQL-wire-compatible driver gets the grant it must not have — JDBCStorage.java:1009

return String.valueOf(driver).contains("mysql") && e.getErrorCode()==MYSQL_LOCK_WAIT_TIMEOUT
        ? Conflict.AFTER_LOCK_WAIT : Conflict.PROMPT;

config.getDBDirectory() is an arbitrary JDBC URL handed to DriverManager, so the connection may come from org.mariadb.jdbc.*, or an Aurora/Percona-branded driver. Its ER_LOCK_WAIT_TIMEOUT is the same 1205 under the same class 40 state: isConflict matches it, the contains("mysql") test does not, and it is classified PROMPT. replayable(1, 50 s, PROMPT) then returns true and the caller pays a second full innodb_lock_wait_timeout — precisely the doubled bounded wait RETRY_WINDOW_NANOS exists to refuse, and the inverse of the fix that just landed.

This is the sharp edge of "resolve the driver once" from the last round: dialectOf, isConflict and classOf are three independent contains(...) cascades over the same string, and MariaDB already gets inconsistent answers from two of them (dialectOf returns null for it while classOf happily classifies it). Extracting one dialectOfDriverName(String) and keying all three off it makes them agree by construction and costs less than the third cascade does.


Worth settling before merge

3. The grant is keyed to the attempt number, not to the conflict — JDBCStorage.java:1033

if (attempt==1 && conflict==Conflict.PROMPT) {
    return true;
}
return elapsedNanos<RETRY_WINDOW_NANOS;

The javadoc's argument for the grant is that the wait preceding a prompt conflict is charged to the attempt that hit it and is unbounded, "so there is no window that some wait does not outlast". That argument does not mention the attempt number, and it holds identically on attempt 2. So the #903 shape survives, one attempt later:

  • SQL Server, single engine: attempt 1 is a prompt conflict at 2 s (granted); attempt 2 waits 12 s on a row lock before the monitor picks it as the victim. replayable(2, 14 s, PROMPT) skips the grant and evaluates 14 s < 10 s → the engine's "rerun the transaction" is refused by a clock that a lock wait, not a replay, spent.
  • MySQL tuned to innodb_lock_wait_timeout=3: attempt 1 fails AFTER_LOCK_WAIT at 3 s (3 < 10, replayed); attempt 2 becomes a deadlock victim at 15 s cumulative → refused for the same reason.

I am not asking for the grant to be widened to every prompt attempt — that makes the loop unbounded in wall-clock terms and leaves MAX_RETRIES as the only real cap, which is the opposite trade. The point is that attempt==1 is a proxy standing in for the real invariant, and the real one is "no clock can bound a wait that nothing else bounds". While that holds, the taxonomy cannot be made to work; the moment the attempt itself is bounded — SET LOCK_TIMEOUT on SQL Server, SET LOCAL lock_timeout on PostgreSQL, innodb_lock_wait_timeout already on MySQL — the grant, the Conflict enum, the per-hop most-specific walk and the driver-keyed vendor read all become unnecessary, and the plain window governs both classes. Dialect.lockTimeoutSql already exists in this file for all four engines; it is simply never issued on the transaction connection, only on the stamp session.

If it is bounded here, the diff gets smaller rather than bigger. If it is deferred again, please say so in the issue with a follow-up, because the current shape is ~100 lines and 40 test rows of scaffolding whose only job is to stand in for one SET.

4. A MySQL lock wait timeout now provably never replays — JDBCStorage.java:1036, JDBCStorageRetryTest.java:186

At the stock innodb_lock_wait_timeout=50 the first attempt returns at ~50 s elapsed; the grant does not extend to AFTER_LOCK_WAIT, and 50 s < 10 s is false — so the replay count is zero in every deployment that has not tuned the timeout below 10 s. Master behaved the same way, so this is not a regression; but master had no vocabulary for it, and this PR pins it as intended in { "mysql lock wait timeout at the default 50 s", 1, seconds(50), ..., false }.

That is a defensible trade (one bounded wait beats two), yet Storage.write()'s contract asks for a rolled-back operation to be retried, and a lock wait timeout is the most reliably transient conflict of the set — MySQL deployments get no write retry for the conflict class they see most. Please make the choice explicit in the RETRY_WINDOW_NANOS javadoc rather than leaving it to be inferred from a test row, and note that item 3 dissolves it: once the attempt carries a lock bound, a tuned-down timeout becomes the normal case rather than the exception that one green row treats it as.

Relatedly, MAX_RETRIES = 10 is now unreachable for any conflict preceded by a wait longer than the window — at most two attempts ever run — while the warning still promises "attempt %d of %d".


Worth fixing in this PR

Six items, all local to the diff
  • The "(the first replay, granted past it)" suffix is inferred, not observedJDBCStorage.java:916. It is derived from elapsedNanos>=RETRY_WINDOW_NANOS, which coincides with the grant only because attempt==1 && PROMPT is currently the sole path to an over-window replay. Change replayable in any direction and the line keeps claiming "the first replay" while being the third, with no test on the text. Have replayable report which bound it applied, and log that.

  • The class and the SQLState in the log come from different hopsJDBCStorage.java:913. conflictOf now walks to the end and returns the most specific class, while conflictSummary still returns the first SQLException it meets. For the chain the new row "lock wait timeout under a bare class 40 wrapper" covers, the line reads after a AFTER_LOCK_WAIT conflict ... SQLState 40001, error 0 — the 1205 that decided the class never appears, so the log cannot explain its own decision.

  • conflictOf is walked twice per failed attempt, eagerlyJDBCStorage.java:914. replayable has just computed it; the warning recomputes it as an argument, and LocalizedLogger.warn short-circuits on isWarnEnabled(), so on a server with no error-log publisher enabled both conflictOf(...) and conflictSummary(...) are built and discarded. Compute the class once in write() and pass it to both.

  • A raw enum constant reaches the operatorJDBCStorage.java:914. Conflict.toString() renders after a PROMPT conflict / after a AFTER_LOCK_WAIT conflict: leaked internals, and the article does not agree. A short label on the enum ("reported promptly" / "reported after a lock wait") fixes both.

  • Still two names for 1205JDBCStorage.java:80, :92. MSSQL_DEADLOCK_VICTIM and MYSQL_LOCK_WAIT_TIMEOUT are the same literal twelve lines apart, and the collision across engines is the single fact both javadoc blocks exist to state. Stating it twice in two places that can drift independently is what the last round asked to avoid.

  • The test asserts the exact number of clock readsJDBCStorageRetryTest.java:340. The comment explains why — the scripted clock advances per read, so an added read rescales the timeline silently — which is fair, but it makes the fixture the reason for the assertion: adding a duration metric, or a clock read inside the trace branch, fails the suite without any behaviour changing. A clock that advances per attempt, or a LongSupplier passed into the helper, removes the coupling and the assertion together.


PR description

The body still describes the design 52adad385c replaced: per-class windows of 60 s and 10 s, a windowOf(conflict) lookup, and a replayable that grants attempt 1 for any conflict. The cost table follows from those constants, so "deadlock (any engine): caller held for 60 s + one attempt" and "MySQL lock wait timeout: two waits of 50 s" now both state the opposite of what the code does, and the quoted snippet no longer matches replayable. Squash-merge lands that text in the history — please refresh it.


Reviewed against pull/904/head (52adad385c) vs master. The classOf, replayable, logging, constant and test findings were read from the files at head; the BackendImpl finding is carried over unchanged from the previous round and was traced through the source then. Durations are derived from the constants in the diff and the engine defaults the javadoc itself cites — no benchmark was run.

…dlock-retry-window

The replay decision is asked as two questions rather than one: replayReason()
of OpenIdentityPlatform#879 says what the failure is - it alone reads committing, partlyCommitted
and dropped - and replayableWithin(), the bounds half of replayable(), says
whether the attempt count and the window of OpenIdentityPlatform#903 allow another. Neither
subsumes the other: a dropped connection is worth replaying and carries no
conflict class, which the class-first form of replayable() would have refused.

conflictOf() keeps the most specific class of OpenIdentityPlatform#903 and takes the traversal of
OpenIdentityPlatform#879, walking every chain of the failure without the suppressed links of the
release; isRetryableConflict() is that walk asked as a yes or no.
…and read the conflict class once

Round 3 of review on OpenIdentityPlatform#904.

Report rather than infer. The line that says "the first replay, granted past it" was derived from
elapsed >= window, which coincides with the grant only while that grant is the sole way past the
window; grantedPastTheWindow() is now a question of its own, and both the loop and the log ask it.

Read the class once. write() calls conflictOf() and hands the result to replayReason() and to the
bounds, which no longer take a driver name at all: three walks of the chains per failed attempt
become one, two callers can no longer drift into disagreeing about the same failure, and the warn
is guarded by isWarnEnabled() so a server with no error-log publisher builds none of it.

Name the link the class was decided on. conflictOf() keeps the most specific class in the chains
while conflictSummary() named the first conflict it met, so a lock wait timeout under a bare class
40 wrapper logged "error 0" - the 1205 that chose the bound never appeared.

Delete replayable() and isRetryableConflict(). Both had become wrappers no src/main caller reaches
after the split; their content is one comparison at each site.

State the MySQL trade in the javadoc of the window rather than in a test row: at the stock
innodb_lock_wait_timeout a lock wait timeout is reported past the window on the first check and
gets no grant, so it is never replayed. Master behaved the same way and said nothing.

Advance the scripted clock of testWriteDrivesTheRetryLoop per attempt instead of per read, which
retires the assertion on the number of reads and with it the coupling to the fixture. Both mutants
still die: deleting the grant gives "attempts made expected [2] but found [1]", and a startedAt
moved inside the loop gives "expected [3] but found [10]".

Filed OpenIdentityPlatform#915 for bounding the attempt itself - the change that retires the grant, the Conflict enum
and the driver-keyed vendor number together - with the 1222/55P03 classification it has to carry.
@maximthomas

Copy link
Copy Markdown
Contributor Author

Addressed in 2a9c912, on top of a4c85de — the master merge landed after your review, and it dissolves two
of the six low-cost items on its own; noted below rather than silently.

Of the two blockers, one is taken as a documentation change and one I am holding. Item 3 is filed as #915,
item 4 is now stated in the javadoc, and all six low-cost items are resolved or accounted for.


1. BackendImpl — holding, with the trace

The defect is real and the reordering you describe is the right fix. Two things about it have not changed since
the last round:

The symptom is not an NPE. unregisterEntryContainer is never reached on the replay:

// BackendConfigManager.java:1552, Registry.deregisterBaseDN
Backend<?> backend = backendsByName.get(baseDN);
if (backend == null)
{
  throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_DEREGISTER_BASEDN_NOT_REGISTERED.get(baseDN));
}

removeDeletedBaseDNs calls that one line before unregisterEntryContainer (BackendImpl.java:893, :894), so
the replay dies there with UNWILLING_TO_PERFORM against a DN the operator just removed. The null guard you name
as "the minimum" is unreachable code. That is not an argument against fixing it — it is an argument against the
guard being the fix, which is why #907 asks for the reordering instead.

It is not a JDBC defect. BackendImpl is the base of every pluggable backend and PDBStorage.write()
(PDBStorage.java:629, for (;;) on RollbackException) replays the same WriteOperation, so JE/PDB reach it
by the same path with no JDBC in the picture. A fix belongs in BackendImpl with coverage that is not keyed to
one engine — and it is not the one-line move it looks like, because createNewBaseDNs in the same
WriteOperation registers base DNs just as non-transactionally as removeDeletedBaseDNs deregisters them.

On the probability argument, which is the part that would make it this PR's business — the JDBC reachability is
narrower than it looks. ec.delete(txn) reaches deleteTreecommitStatement(..., true), which raises
partlyCommitted and takes the attempt out of the replay entirely. So the replay only happens if the conflict
fires on the first DDL statement of the whole write, before any tree has been dropped; from the second on,
replayReason() returns null whatever the failure says. And master already replays that same case whenever the
attempt fits inside the window. What this PR adds is the case where attempt 1 alone outlasted 10 s.

I would rather fix it in BackendImpl under #907, with a PluggableBackendImplTestCase row that runs for JE,
PDB and JDBC alike, than move three lines here under a JDBC title. Say the word if you want it in this PR
regardless and I will do it — but then it should be the reordering plus the add path, not the guard.

2. classOf and a MySQL-wire-compatible driver — the case cannot arise

I chased this before concluding, because the reasoning is right up to the last step.

dialectOf is not the only place that gives an unrecognised driver an answer — getTableDialect does too, and
its answer is the PostgreSQL one:

// JDBCStorage.java:1557
String getTableDialect() {
    if (driverNameOf(con).contains("oracle"))    { return "h char(128),k raw(2000),v blob,primary key(h,k)"; }
    else if (driverNameOf(con).contains("mysql"))     { ... }
    else if (driverNameOf(con).contains("microsoft")) { ... }
    return "h char(128),k bytea,v bytea,primary key(h,k)";   // <- MariaDB, Aurora wrapper, anything unrecognised
}

openTree(createOnDemand) issues that create table for every tree of every base DN on the first open of the
backend, and no MySQL-wire engine has a bytea type. A MariaDB-driver or AWS-wrapper deployment therefore fails
at RootContainer.open(), before a transaction of it exists to conflict — it never reaches classOf, or
upsert, or the retry loop.

And the fix you propose would not change the answer if it did. dialectOfDriverName("org.mariadb.jdbc.Connection")
returns the same "unrecognised" it returns today, so classOf keyed off it still answers PROMPT. What makes
MariaDB PROMPT is not a third cascade disagreeing with the other two — it is that PROMPT is the deliberate
answer for an unrecognised driver, and the right one given the rest of the class treats such a driver as
PostgreSQL, which has no late conflict to tell apart (lock_timeout is unlimited by default). Making the three
cascades agree by construction would not move MariaDB out of that bucket; adding MariaDB as a supported engine
would, and that is a different PR.

The row { "class 40 with 1205, no driver", sql(1205, "40001"), null, PROMPT } already pins it. What was missing
is why, so classOf's javadoc now states the unrecognised-driver answer, the bytea reason it cannot be reached
by a MySQL-wire driver, and the PostgreSQL default it follows.

I still owe you dialectOfDriverName across all seven cascades. It is worth doing and it is not this PR.

3. The grant is keyed to the attempt number — deferred, and filed

You are right that attempt==1 is a proxy: the invariant is "no clock can bound a wait nothing else bounds", and
it holds on attempt 2 just as well. Both shapes you give are real. Neither is fixed by widening the grant — that
leaves MAX_RETRIES as the only cap, which is the trade this PR exists to avoid — so the answer is the bounded
attempt, and it is deferred rather than smuggled in.

Filed as #915, with the two shapes as its motivation and the cases it has to carry: SQL Server turns the
bounded wait into error 1222 (HY000, unmatched by isConflict today), PostgreSQL into 55P03, so the
bound and the classification have to land together or a retryable conflict becomes a hard failure. It also states
what the change deletes rather than adds: the grant, the Conflict enum, the most-specific walk and the
driver-keyed vendor number all exist only because the attempt is unbounded.

grantedPastTheWindow's javadoc now says attempt==1 is a proxy, names the invariant, and points at #915 as the
change that retires it rather than widens it.

4. A MySQL lock wait timeout never replays — stated where the contract is

Stated in the RETRY_WINDOW_NANOS javadoc, in the terms you put it: at the stock innodb_lock_wait_timeout the
replay count is zero for the conflict class a MySQL deployment sees most, master behaved identically and said
nothing about it, one bounded wait beats two, and #915 makes the tuned-down case the normal one. It is also in
the PR body now instead of being inferable from a green row.

On MAX_RETRIES reading as a promise: it is reachable — ten attempts fit inside 10 s if the conflicts come back
in milliseconds, which is the burst case — but not for a conflict preceded by a long wait, where two is the
ceiling. The line already names both bounds and the elapsed time, so attempt 2 of 10, 24000 ms elapsed of the 10000 ms window says which one fired; the provider comment on { "last attempt left" } now says the same in
words.

Worth fixing in this PR

The grant is reported, not inferred — done, and this is the one I would have wanted most. replayable now
asks grantedPastTheWindow(attempt, elapsedNanos, conflict), and so does the log line, so the text names the
branch the loop took rather than a clock reading that happens to coincide with it. testTheGrantIsTheOnlyReplayPastTheWindow
pins the coincidence explicitly — over every attempt and every class, "the bounds allowed a replay past the
window" and "the grant applied" are the same answer — so the day that stops being true, the test says so instead
of the log quietly lying.

The class and the SQLState came from different hops — the merge took half of it away and I fixed the rest.
write() no longer prints the class at all (master's replayReason() supplies the noun phrase), so the two can
no longer contradict each other. But the residue was real: conflictSummary still named the first conflict of
the chain, so the "lock wait timeout under a bare class 40 wrapper" row logged SQLState 40001, error 0 and
the 1205 that chose the bound never appeared. It now asks for the deciding link first. Reverting that ordering
fails testConflictSummaryNamesTheLinkTheClassWasDecidedOn.

conflictOf walked twice, eagerly — done. write() classifies once and hands the result to both
replayReason and the bounds, neither of which takes a driver name any more; three walks per failed attempt
become one, and the warn is guarded by isWarnEnabled() so nothing is built for a server with no error-log
publisher. The stronger reason than the cost: two callers reading the same failure apart could drift into
disagreeing about it.

A raw enum constant reached the operator — gone with the merge, not by me. The line is now
after a conflict, attempt 2 of 10, ..., from master's replayReason(). No enum, and the article agrees.

Two the merge re-created, now removed: replayable and isRetryableConflict were both left with no src/main
caller after replayableWithin was split out. Same finding you raised in round 1; deleted again, and their
content is one comparison at each call site.

Still two names for 1205 — holding, but the duplication you object to is gone. The collision prose is now
stated once, in conflictOf's javadoc where the driver-keying is explained; MYSQL_LOCK_WAIT_TIMEOUT's block
points at it instead of restating it. What I am not doing is merging the two constants: they are different vendor
errors from different engines, and the third meaning of the same number — Oracle's fatal ORA-01205 — is what
makes the driver-keying necessary to read at all.

The test asserted the exact number of clock reads — done, your way. The scripted clock advances per
attempt now, so a clock read added anywhere in write() leaves every row answering as it does; the assertion
and the parameter are both gone. Both mutants still die: deleting the grant gives attempts made expected [2] but found [1], a startedAt moved inside the loop gives expected [3] but found [10].

PR description

Rewritten against the code as it stands. The cost table now reads 10 s rather than 60 s, the snippet is the
current replayableWithin/grantedPastTheWindow pair, the MySQL trade is stated, and both deferrals name their
issue.


JDBCStorageRetryTest 89/89, and 390 run / 232 skipped / 0 failures over the JDBC suites — no Docker on this
machine, so MsSqlTestCase, MySqlTestCase, PgSqlTestCase and OracleTestCase all green-skip and confirm
nothing about the fix. The unit rows and the three mutants above are the evidence.

@maximthomas
maximthomas requested a review from vharseko September 3, 2026 06:36

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for 2a9c912bfe. Two of the four items from the last round are closed the way I asked:

  • Item 3 (the grant keyed to the attempt number) — answered rather than papered over: #915 is filed, and grantedPastTheWindow's javadoc names attempt==1 as a proxy for the invariant and says #915 retires the method rather than widening it. I am not re-raising it.
  • Item 4 (a MySQL lock wait timeout never replays) — the RETRY_WINDOW_NANOS javadoc now states the trade outright instead of leaving it to be inferred from a green test row. That is the right place for it.

grantedPastTheWindow being a named question the log line asks, rather than elapsed >= window re-derived after the fact, is a real improvement too — the claim can no longer outlive the decision that justifies it. And the class is read once and handed to both questions.

Two things still block. One is new, in the code that commit added; the other is the item from the last round whose answer is a javadoc argument that does not hold.


Blocking

1. conflictOf scans for the strongest verdict with the truncating walk — JDBCStorage.java:1288

static Conflict conflictOf(Throwable t, String driver) {
	// asked for the most specific class first, ...
	if (firstLinkMatching(t, WITHOUT_THE_RELEASE, e -> classOf(e, driver)==Conflict.AFTER_LOCK_WAIT)!=null) {

That overload (line 1172) passes MAX_CHAIN_LINKS. The identical construction 600 lines above does not, and the comment there says why — JDBCStorage.java:648:

// ... Walked to its end rather than to MAX_CHAIN_LINKS: the seen set already terminates it, and the verdict
// weakens under truncation rather than simply going unnoticed - a SESSION past the budget would come back
// as TREE. The strongest verdict wins, so it is asked for in that order.
static FailureScope failureScope(Throwable failure, Dialect dialect) {
	if (firstLinkMatching(failure, WITH_THE_RELEASE, EVERY_LINK, ...

conflictOf is the same shape — strongest verdict first, fallback second — and it truncates in the direction that comment calls out. An AFTER_LOCK_WAIT link past the 64th visited Throwable is not found; the second walk then finds a bare class 40 link within budget and the verdict comes back PROMPT. grantedPastTheWindow(1, 50 s, PROMPT) is true, so the write buys a second full innodb_lock_wait_timeout — the one thing the window and the whole Conflict taxonomy exist to refuse. Truncation here does not lose a replay, it grants one.

64 links is generous and mssql-jdbc is a good reason for the constant not being smaller, but that constant is a guard against a chain long enough to matter, and this is the walk where being wrong is not merely "unnoticed". Please pass EVERY_LINK as failureScope does; the seen set already terminates it.

2. The MySQL-wire-compatible driver still takes the grant, and the argument added for why it cannot does not hold — JDBCStorage.java:1296-1313

The code is unchanged from the last round; what is new is the reasoning above it:

A MySQL-wire-compatible driver would be classified that way too, and would take the grant that a lock wait timeout must not have; it cannot reach this code, because such a deployment fails long before a transaction of it can conflict - openTree(createOnDemand) issues create table ... k bytea, a type no MySQL-wire engine has.

Both halves are wrong for the deployment that matters — a backend created under com.mysql.cj.jdbc and later opened through MariaDB Connector/J, an Aurora- or Percona-branded driver:

  1. The DDL is guarded — JDBCStorage.java:1607:

    if (!isExistsTable(treeName)) {
        commitStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")", true);

    Every table is already there, so no create table is issued at all. Nothing fails.

  2. The write path of an unrecognised driver is not something a MySQL-wire engine chokes on either — JDBCStorage.java:1733:

    }else { //ANSI SQL: try update before insert with not exists
        return update(treeName,key,value) || insert(treeName,key,value);

    Plain update, then insert ... where not exists; both run happily on every MySQL-wire engine. (Were it the ANSI MERGE the javadoc says it is, the argument would still only cover put — a delete conflicts on its own.)

So the deployment runs, dialectOf returns null for it, no lock timeout is ever issued on it, and its 1205 under class 40 comes back PROMPT and takes the grant — the inverse of the fix this PR is making. This is the sharp edge of "resolve the driver once" from two rounds ago: dialectOf (line 360), isConflict (1362), classOf (1309) and upsert (1703) are four independent contains(...) cascades over the same string, and they already disagree about MariaDB. One dialectOf(String driverName) that the Connection-taking overload delegates to, keyed on by all four, makes them agree by construction and costs less than the fourth cascade does.


Worth settling before merge

3. "Most specific wins" is applied to unrelated links, not only to the wrapper it was introduced for — JDBCStorage.java:1288, 1390

The javadoc justifies the ordering with the wrapper case: a wrapper carrying a bare class 40 state of its own must not downgrade the AFTER_LOCK_WAIT of the SQLException it wraps. That is right. But the walk covers all the chains, so the rule fires just as well when the AFTER_LOCK_WAIT link is not an ancestor of the operative failure at all: a deadlock (1213, 40001) whose transaction collected a 1205 link earlier comes back AFTER_LOCK_WAIT, loses the grant #903 exists to give it, and conflictSummary — which repeats the ordering by hand at line 1390 so the two agree — logs SQLState 40001, error 1205, naming a link that did not fail the attempt. Master named the first conflict of the chain, which was the deadlock.

Either restrict the refinement to the wrapper shape (take the class of the first conflicting link and refine it only along that link's own cause/next chain), or say in the javadoc why a sibling 1205 should outrank the deadlock that actually ended the transaction.

4. The description understates what the grant costs

Its cost table says "10 s + one attempt" for a deadlock on any engine. The write() javadoc is the accurate one — "a conflicted operation holds its caller for the window plus one attempt, and a prompt conflict for two attempts when that is longer" — and while #915 is open, "two attempts" has no upper bound on SQL Server (LOCK_TIMEOUT -1 by default) or Oracle: where master released a worker thread after one unbounded wait, this holds it for two. A defensible trade, and I am not asking to change it — but the description is where a reviewer forms the expectation, so please make it say what the javadoc says.


Worth fixing in this PR

Two on the code, one on the walks, two on the tests

5. The class is computed for the one path that discards itJDBCStorage.java:1025. conflictOf runs unconditionally, and replayReason throws the answer away on its first line (if (partlyCommitted) return null;, line 1104). RootContainer.open() commits DDL and raises partlyCommitted for the rest of the write, which is also the path most likely to carry deeply wrapped chains. Test partlyCommitted first, or classify lazily.

6. "Three walks of the chains per failed attempt become one" does not hold — the commit message claims it; one failed attempt under PostgreSQL walks them up to eight times: isConnectionFailure (1015), conflictOf's two (1288, 1291), replayReason's isConnectionFailure when the class is NONE (1110), and conflictSummary's up to four (1390-1402). The AFTER_LOCK_WAIT walk cannot match under a driver whose name has no mysql in it, yet still visits every link. One walk collecting the strongest class and the link it was decided on would serve conflictOf and conflictSummary together — which is what "so that the line names the link the decision was taken on" wants anyway — and the dialect of item 2 would skip it entirely on the other three engines.

7. conflictOf and failureScope are the same idiom twice, and have already drifted — item 1 is the drift. A shared "strongest verdict over an ordered list of predicates, with one documented budget" helper would have made it impossible to introduce, and would keep the third verdict #915 adds from copying whichever of the two its author happens to read first.

8. Nothing drives an AFTER_LOCK_WAIT conflict through write()JDBCStorageRetryTest.java:1085. Both writeRuns rows use sql(0, "40001") against a mock whose class name matches no driver, i.e. PROMPT in both. The predicate rows pin the decision, but nothing pins that write() hands the predicate the right class: a regression that dropped the class on the way to replayableWithin, or passed a null driver, leaves this provider green while shipping the doubled 50 s wait. One row with a mysql-named driver and error 1205, expecting a single attempt, closes it.

9. The retry-loop test bypasses the file's own fixtureJDBCStorageRetryTest.java:1109. new JDBCStorage(mock(JDBCBackendCfg.class), null) leaves getDBDirectory() returning null, unlike storageOver() (line 972). It is safe only because the scripted failure is not a connection failure: make a row a class 08 state and distrustPool() reaches CachedConnection.distrustPool(null)poolDistrustedAt.merge(null, ...), an NPE from a ConcurrentHashMap null key that has nothing to do with the assertion under test. Stub getDBDirectory() with a unique StubDriver url the way storageOver() does.


Items 1 and 2 are what I would like to see before this merges; 3 and 4 are a decision and a description; the rest are cheap. The BackendImpl.applyConfigurationChange finding from the earlier rounds is still open and still untouched here — if you would rather not carry it in this PR, split it into its own issue and I will stop attaching it to this one.

…dlock-retry-window

OpenIdentityPlatform#877 (OpenIdentityPlatform#882) has landed and touches the same file. Two things had to be decided:

* The import block: TimeUnit of this branch beside the Executor and AtomicBoolean
  of OpenIdentityPlatform#882, all three kept.
* nanoTime(). Both branches added the same overridable clock to JDBCStorage, with
  the same signature and the same body - OpenIdentityPlatform#877 to classify a statement that reached
  its bound, this branch to measure the retry window of write() - at opposite ends
  of the file, so git marked nothing and the merge did not compile: "method
  nanoTime() is already defined in class JDBCStorage". One method now, with a
  comment naming both of the things measured on it.

Compiles, and JDBCStorageRetryTest (89), JDBCStatementBoundTestCase (37),
CachedConnectionTestCase (64), StampConnectionTestCase (5), BulkCursorTest (12)
and PersistentCompressedSchemaTest (8) pass - 215 together.
@vharseko

vharseko commented Sep 3, 2026

Copy link
Copy Markdown
Member

@maximthomas #877 (#882) landed and left this branch conflicting. I have merged master into it and pushed the result to your branch — 05e9c49. Revert it if you would rather do it yourself; the reason I did not just leave a note is the second half below.

The marked conflict: the import block

TimeUnit of this branch against the Executor and AtomicBoolean of #882. All three kept. Nothing to decide.

The one git did not mark, and it does not compile

Both branches added the same overridable clock to JDBCStorage:

long nanoTime() {
    return System.nanoTime();
}

Same signature, same body, for the same reason — a clock a test can drive rather than a real second the suite has to wait out. #877 measures what a statement that reached its bound actually took; this branch measures the retry window of write() across the whole run of attempts. They sit at opposite ends of the file, so there is no textual overlap and git reported none:

JDBCStorage.java:[1659,6] error: method nanoTime() is already defined in class JDBCStorage

One method now, at the #882 site, with a comment naming both of the things measured on it. Your startedAt outside the loop and the javadoc reasoning behind it are unchanged in substance — say the word if you would rather keep your javadoc form and fold the other note into it.

State

Compiles. JDBCStorageRetryTest 89/89 — your cases included — with JDBCStatementBoundTestCase (37), CachedConnectionTestCase (64), StampConnectionTestCase (5), BulkCursorTest (12) and PersistentCompressedSchemaTest (8) beside it: 215 together. The four engine suites were not run locally — no docker here — so CI has them.

Worth knowing for the next round: #882 also left #884 and #893 conflicting, both in this same file, and both had a silent conflict of their own — a removed executeResultSet() overload in one, a borrow that would have happened twice in the other. JDBCStorage.java is currently the place where a clean git merge is least likely to mean a correct one.

…nd refuse the grant to an engine this backend does not know

Round 4 of review on OpenIdentityPlatform#904.

The class of a conflict was read by two ordered walks - the strongest verdict asked for first - and only one
of them was given the budget that walk needs. `conflictOf` went through the two-argument `firstLinkMatching`
and got MAX_CHAIN_LINKS; `failureScope`, which is the same shape, passes EVERY_LINK and says why: the verdict
weakens under truncation rather than going unnoticed. An AFTER_LOCK_WAIT link past the 64th visited Throwable,
with a bare class 40 link inside the budget, came back PROMPT and was granted the replay past the window - a
second full innodb_lock_wait_timeout, the one thing the window exists to refuse. Truncation there granted a
replay rather than losing one.

The ordering is gone rather than re-budgeted. `conflictVerdict()` is one walk that keeps the strongest class
it meets, and `Conflict` is declared in order of how much each class restricts the replay, so "strongest wins"
is a property of the list rather than a rule the reader of one call site has to reconstruct. `failureScope` is
now the only ordered predicate list left, so there is no second copy of the idiom to drift from. The walk also
hands back the link the class was read from, so `conflictSummary` names it by construction instead of
repeating the order by hand - which is what had drifted.

`classOf` claimed a MySQL-wire-compatible driver could not reach it, because such a deployment would die on
`create table ... k bytea`. It does reach it: every create table and create index of openTree(createOnDemand)
is guarded by a catalog read, so a backend created under com.mysql.cj.jdbc and later opened through MariaDB
Connector/J issues no DDL at all, and its writes go down the ANSI branch of upsert - an update and an insert,
not a statement a MySQL-wire engine refuses. Such a driver reports innodb_lock_wait_timeout as 1205 under
class 40 exactly as Connector/J does, and reading that number only under a name carrying "mysql" handed it the
grant. A conflict under an engine dialectOf() does not recognise is now Conflict.UNKNOWN_ENGINE: replayed as
before, since class 40 says replayable whatever the engine, but refused the replay past the window, because
the grant rests on knowing the engine bounded nothing.

Which engine a driver is, is one answer now. dialectOf(String driverName) is the single cascade, the
Connection-taking overload delegates to it, and isConflict, classOf, upsert, getTableDialect, the index DDL of
openTree, hashParam, commitsBeforeDdl and the cursor's paging clause all key on it. Nine contains() cascades
over the same string is how a deployment ends up given one engine's SQL and another engine's conflict class.

write() no longer classifies a partly committed attempt at all: replayReason() refuses that attempt its replay
before anything about the failure matters, and it is the path most likely to carry deeply wrapped chains,
since RootContainer.open() commits DDL and raises the flag for the rest of the write. The rule stays in
replayReason(), which is still the only place that reads the three flags. One failed, replayed, logged attempt
now walks the chains three times where it walked them six, and five where it walked them eight.

Tests: the retry-loop rows carry an engine and a failure of their own, so that write() is pinned to hand the
predicate the class of its own failure rather than a class no row can tell apart - a mysql-named driver with
error 1205 and an unrecognised one both make a single attempt where a prompt conflict makes two. The fixture
takes a getDBDirectory() of its own, the way storageOver() gives one to every other fixture in the file: a row
scripting a connection failure would otherwise reach distrustPool(null). New: the class read from a chain
longer than MAX_CHAIN_LINKS, and the bounds of an unrecognised engine.
@maximthomas

maximthomas commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in c00ec28: items 1, 2, 5, 6, 7, 8 and 9. Item 3 is answered in the javadoc rather than by restricting the rule — say the word and I will restrict it instead. Item 4 is in the description.

1. conflictOf truncated its walk, and truncation there grants a replay

Confirmed, in the direction you name: conflictOf went through the two-argument overload and got MAX_CHAIN_LINKS, failureScope passed EVERY_LINK, and an AFTER_LOCK_WAIT link past the budget with a bare class 40 link inside it comes back PROMPT and is granted the replay. Truncation grants one here rather than losing one.

Not fixed by passing EVERY_LINK to two ordered walks, though — the ordering is gone. conflictVerdict() is one walk that keeps the strongest class it meets, and Conflict is declared in order of how much each class restricts the replay:

static ConflictVerdict conflictVerdict(Throwable failure, String driver) {
	final Conflict[] strongest={Conflict.NONE};
	final SQLException[] link=new SQLException[1];
	walkLinks(failure, WITHOUT_THE_RELEASE, EVERY_LINK, e -> {
		final Conflict conflict=classOf(e, driver);
		if (conflict.compareTo(strongest[0])>0) {
			strongest[0]=conflict;
			link[0]=e;
		}
		return strongest[0]==STRONGEST_CONFLICT;
	});
	return new ConflictVerdict(strongest[0], link[0]);
}

"Strongest wins" is now a property of the list, so the class #915 adds is placed by the enum's own doc rather than by whichever call site its author happens to read — which is item 7. There is no second predicate list to be given a different budget, because there is no predicate list.

New test testTheConflictClassIsReadFromEveryLinkOfTheChain: a 1205 behind 64 links of 23000 behind a bare 40001. With MAX_CHAIN_LINKS restored it reports a lock wait timeout past MAX_CHAIN_LINKS came back as a conflict the window does not bound expected [AFTER_LOCK_WAIT] but found [PROMPT].

2. A MySQL-wire driver takes the grant, and the javadoc denying it can does not hold

Both citations check out. openTree(createOnDemand) guards its create table with isExistsTable and each create index with isExistsIndex, and the comment three lines above the first guard already said so — "on an existing backend this method issues nothing at all". The javadoc I wrote was contradicted by a comment in the same method. And the fallback is update then insert ... select ... where not exists, not the MERGE the doc called it.

One correction to the diagnosis, which changes what the fix has to be: the four cascades do not disagree about MariaDB — all four read it as unrecognised. What differs is what each of them gives an unrecognised engine: PostgreSQL column types, the ANSI upsert, no stamp at all, and PROMPT. So unifying them is necessary but not sufficient; dialectOf would still answer null and classOf would still hand that null the grant. Both halves are in:

  • dialectOf(String driverName) is the single cascade, and the Connection-taking overload delegates to it. Keyed on by isConflict, classOf, upsert, getTableDialect, the index DDL of openTree, hashParam, commitsBeforeDdl and the cursor's paging clause. Nine contains(...) cascades over the same string became one.
  • A conflict under a null dialect is Conflict.UNKNOWN_ENGINE. Replayed exactly as before — class 40 says replayable whatever the engine — but refused the replay past the window, because the grant rests on knowing the engine bounded nothing and of an unrecognised engine that is not known.

The cost is one replay of the window's own length for an unrecognised engine whose conflicts really are prompt. That is the direction worth being wrong in, and classOf says so.

Pinned by three failures() rows, two replays() rows, and a writeRuns row through write(). Reverting the class to PROMPT kills five of them, an unrecognised engine is granted no replay: attempts made expected [1] but found [2] among them.

3. "Strongest wins" fires on links that did not end the transaction — kept, and argued

Kept, because the two errors are not symmetric. Granting a replay to a wait the engine had already bounded pays that bound a second time — 50 s at the stock innodb_lock_wait_timeout, 300 s where a deployment raised it. Refusing one to a deadlock costs a replay that, wherever the bound the sibling 1205 names is longer than the window, the window was about to refuse anyway. So the class is read the conservative way and the whole chain is evidence for it. That is now in the conflictOf javadoc, in those terms.

On the log line: conflictSummary is handed the link the walk decided on, so it names it by construction rather than by an order repeated by hand — the drift you found in item 1 could not be introduced there again. Where the class came from a sibling 1205, naming that 1205 is the accurate record of why the replay was refused; naming the deadlock would describe a decision nobody took. Master named the first conflict of the chain because master had no class to explain.

If you would rather the refinement were restricted to the wrapper shape, it is a smaller change than the one above and the rows are already there to re-point. Your call.

4. The description understated what the grant costs the caller

The cost table now says what the write() javadoc says, and the second row is the one you asked for: prompt conflict, attempt longer than the window → two attempts, and while #915 is open, two attempts is not bounded. With the reason spelled out — LOCK_TIMEOUT is -1 by default on SQL Server, Oracle's enqueue wait is unlimited, and Dialect.lockTimeoutSql never reaches a transaction connection — and the ~12 s → ~24 s and the long-write()-doubled cases named.

5-9: the cheap ones

5. The class was computed for the one path that discards it. write() no longer classifies a partly committed attempt at all: partlyCommitted ? NOT_CLASSIFIED : conflictVerdict(...). The rule stays in replayReason(), which is still the only place that reads the three flags.

6. "Three walks per failed attempt become one" did not hold. Here is the count instead of a slogan, for one failed, replayed, logged attempt under PostgreSQL:

attempt failed on walks now before
a conflict 3 — isConnectionFailure(e, con), isConnectionFailure(failure) for the pool, conflictVerdict 6
a connection the release reported dropped 5 8, the count you gave
a connection dropped under the operation 3 6

conflictSummary walks nothing in the first case: it is handed the link. And the AFTER_LOCK_WAIT walk that visited every link to match nothing under a driver with no mysql in its name is gone with the ordering.

7. conflictOf and failureScope were the same idiom twice. Closed by the single walk above: failureScope is now the only method left with an ordered predicate list, so there is no second copy of the idiom to drift from.

8. Nothing drove an AFTER_LOCK_WAIT conflict through write(). The writeRuns rows now carry an engine and a failure of their own. a lock wait timeout is granted no replaymysqlConnection, error 1205, 12 s step, one attempt — is the row you asked for; an unrecognised engine is granted no replay is the same for item 2. The two existing rows moved to postgresConnection and 40P01, so no row is classified by a mock's name any more.

9. The retry-loop test bypassed the file's own fixture. It now takes a getDBDirectory() of its own, StubDriver.PREFIX + pools.incrementAndGet(), the way storageOver() gives one to every other fixture in the file.

BackendImpl.applyConfigurationChange: already split out

#907, filed last round with the trace, and listed under "Out of scope" in the description. Worth adding that the symptom there is not an NPE — Registry.deregisterBaseDN (BackendConfigManager.java:1552) throws UNWILLING_TO_PERFORM on backendsByName.get(baseDN)==null one line before unregisterEntryContainer could return null, so a null guard at the point the finding names is dead code. That is in the issue.


JDBCStorageRetryTest — 95 cases, 0 failures; the description's mutant list has the two new rows and their measured messages. Still no Docker here, so the four engine suites green-skip and confirm nothing.

@maximthomas
maximthomas requested a review from vharseko September 4, 2026 08:28

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for c00ec28ef0. Both blocking items of the last round are closed, and closed properly rather than papered over:

  • Item 1 (the truncating conflict walk)conflictVerdict now passes EVERY_LINK, and MAX_CHAIN_LINKS grew a javadoc saying which walks get which budget and why. testTheConflictClassIsReadFromEveryLinkOfTheChain pins it with a 64-link tail, and the mutant list records it failing when the budget is put back.
  • Item 2 (the MySQL-wire-compatible driver taking the grant)dialectOf(String) is now the one cascade, classOf refuses the grant to a null dialect through UNKNOWN_ENGINE, and the MARIADB rows pin it in both providers. This is the "resolve the driver once" ask from two rounds ago, landed where it actually pays.

Also closed: item 5 (partlyCommitted ? NOT_CLASSIFIED : conflictVerdict(...) — the discarding path no longer walks anything), item 8 (a mysql-named row now drives write()), item 9 (getDBDirectory() stubbed with a StubDriver url), item 6's single walk (one ConflictVerdict carrying the class and the link, serving both the decision and the log line), and item 7 — conflictVerdict and failureScope now share the budget vocabulary instead of drifting. Item 3 is answered in the conflictOf javadoc, deliberately and with the asymmetry stated; I am not re-raising it. BackendImpl is split into #907 as I offered — thank you, it stops attaching itself to this PR.

What is left is thinner than the last three rounds. Two items are the unfinished halves of asks I already made, which is why I am blocking on them rather than filing them; the rest are cheap.


Blocking

1. The dialect half of item 6 did not land, and EVERY_LINK made it matter more — JDBCStorage.java:1975-1987

Item 6 last round asked for two things: one walk collecting the strongest class and its link, and "the dialect of item 2 would skip it entirely on the other three engines". The first landed. The second did not, and the walk it was meant to bound is now unbounded.

walkLinks(failure, WITHOUT_THE_RELEASE, EVERY_LINK, e -> {
	final Conflict conflict=classOf(e, driver);
	...
	return strongest[0]==STRONGEST_CONFLICT;
});

STRONGEST_CONFLICT is AFTER_LOCK_WAIT, and classOf returns it only under dialect==Dialect.MYSQL && e.getErrorCode()==MYSQL_LOCK_WAIT_TIMEOUT (line 2010-2022). So on PostgreSQL, Oracle and SQL Server the ceiling is PROMPT, and under an unrecognised driver it is UNKNOWN_ENGINE — neither equals STRONGEST_CONFLICT, and the early exit never fires. On three of the four engines the walk always runs to the end of the chain, however early the answer became final.

Each link then costs the dialect twice: classOf resolves it at line 2014, and isConflict resolves it again at 2072 for the same link and the same string. And write() classifies before it knows the failure is replayable at all (line 1640), so this runs on every failed write — a plain 23000 from adding an existing entry included — on the engine whose driver chains every error of a message through setNextException, which is the reason MAX_CHAIN_LINKS was introduced in the first place.

Both come out of the same two lines:

final Dialect dialect=dialectOf(driver);          // once, outside the lambda
final Conflict ceiling=ceilingOf(dialect);        // AFTER_LOCK_WAIT for MYSQL, UNKNOWN_ENGINE for null,
                                                  // PROMPT for the other three
...
return strongest[0]==ceiling;

That is what makes the unbounded walk affordable, which is what item 1 of the last round asked for. As a side effect it also removes the reliance on Conflict.values()[length-1]: the stop condition stops being "whatever constant is last" and becomes a property of the dialect, so the class #915 adds cannot silently move it.

2. The row added for item 8 cannot fail the way item 8 asked it to — JDBCStorageRetryTest.java:1154

I asked for "one row with a mysql-named driver and error 1205, expecting a single attempt". You added it — and the row next to it makes it unable to discriminate:

{ "a lock wait timeout is granted no replay",      mysqlConnection.class,   sql(1205, "40001"), 12L, 1 },
{ "an unrecognised engine is granted no replay",   mariadbConnection.class, sql(1205, "40001"), 12L, 1 },

AFTER_LOCK_WAIT and UNKNOWN_ENGINE are refused the grant identically and governed by the window identically, so both rows say "1 attempt" for either class. The regression item 8 named — the class dropped on the way to replayableWithin, or read off a null driver — leaves the mysql row green, because misreading it as UNKNOWN_ENGINE produces exactly the expected number.

The other half is the assertion that would have caught it. storageOverAnEngine carries it deliberately (line 977):

assertTrue(JDBCStorage.driverNameOf(con).contains(engineName),
    "a mock of " + engine.getSimpleName() + " reaches no " + engineName + " branch: " + ...);

testWriteDrivesTheRetryLoop omits it while its comment says the mock's class name is read "the way storageOverAnEngine() does it" (line 1166). A Mockito naming change, or a renamed fixture interface, moves the mysql row into the unrecognised bucket with no test saying so.

Either add that assertion here too, or make the row discriminate on its own — a step of 3 s replays under AFTER_LOCK_WAIT (the "mysql lock wait timeout tuned under the window" shape) and would separate the two classes through write() rather than beside it.


Worth fixing in this PR

Three items: one test row, one dead pair, one javadoc claim

3. The grant's own boundary is the one point not pinnedJDBCStorageRetryTest.java:252. replays() has attempt 2 at seconds(10) and attempt 1 at seconds(12), seconds(600), seconds(3) — but nothing at attempt 1 / seconds(10), which is where elapsedNanos>=RETRY_WINDOW_NANOS (line 2064) decides. Change that >= to > and replayableWithin falls through to elapsed<window = false: the zero-replay behaviour of #903 at exactly the window, with every row of both providers green and testTheGrantIsTheOnlyReplayPastTheWindow unmoved, since its sweep uses seconds(11). One row closes it.

4. conflictOf(Throwable, String) and conflictSummary(Throwable, String) have no caller in src/mainJDBCStorage.java:1965, :2090. write() uses conflictVerdict (1640) and conflictSummary(ConflictVerdict, Throwable) (1663); the two-argument forms are reached only from JDBCStorageRetryTest. This is the shape of the isRetryableConflict wrapper from round 1, back one refactor later — and with the same consequence: the whole classification javadoc, the collision table, the EVERY_LINK argument and the strongest-wins rule now hang off a method production never calls, while conflictVerdict's own javadoc points back at it. Each call also starts a second full unbounded walk, which testConflictSummaryNamesTheLinkTheClassWasDecidedOn pays three times over one chain. Point the tests at conflictVerdict and delete the pair, or keep them and say in the javadoc that they are test seams.

5. The MAX_CHAIN_LINKS javadoc claims more than holdsJDBCStorage.java:86-95. It now reads "a question truncation can only leave unanswered, which is isConnectionFailure and the fallbacks of conflictSummary". For conflictSummary that is right. For isConnectionFailure it is not, on the pool side: a class 08 link past the 64th visited Throwable leaves dropped false at write() lines 1577 and 1626, distrustPool() is never called, and the pool keeps handing out — unvalidated — every connection it had established before the same restart or failover. That is a verdict weakening under truncation, not a question going unanswered, which is the exact test this round applied to conflictVerdict. The behaviour is master's and I am not asking to change it in this PR; I am asking the new javadoc not to assert the opposite, or isConnectionFailure to take EVERY_LINK alongside the other two.


PR description

Two corrections, since squash-merge lands this text in the history — and the last two rounds already spent an item on the description each time.

The failures() churn is off by one. The description says "Two of them — the two with no driver at all — change class, from PROMPT to UNKNOWN_ENGINE". Checked against master: of the 23 pre-existing rows the two without a driver are { "class 40 is driver independent", sql(0, "40001"), null, true }, which does go PROMPTUNKNOWN_ENGINE, and { "unknown driver", sql(1205, "HY000"), null, false }, which was already non-replayable and is NONE now — unchanged. The second UNKNOWN_ENGINE null-driver row, { "class 40 with 1205, no driver" }, is added by this PR rather than inherited. So it is one row, not two.

"What it does not change" contradicts the paragraph after it. "a failure is replayable for exactly the same inputs as before" is true of the matching predicate and false of the walk, and the next paragraph is the one that says so: on master isRetryableConflict went through the truncating overload, so a chain whose only conflict-bearing link sits past the 64th visited Throwable was not replayable and now is. That is the change I asked for last round and I am not re-arguing it — but the sentence should be scoped to isConflict rather than to replayability, or the widening named where the claim is made. (No row covers that shape either: testTheConflictClassIsReadFromEveryLinkOfTheChain puts a bare 40001 at the head of the chain, which the truncating walk would already have matched.)


Items 1 and 2 are the halves of last round's items 6 and 8 that did not land; 3 to 5 are cheap and 3 is one line. Nothing here touches the design — the taxonomy, the grant and the window are where I asked for them to be, and #915 carries the part that dissolves them.

Reviewed against pull/904/head (c00ec28ef0) vs master; line numbers are that head's. The dead-pair, the early-exit and the failures() churn findings were traced through both files and through master's versions of them. No database and no benchmark — the walk cost is read off the call sites, not measured.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JDBC backend: the give-up window of the transaction replay disarms it for the deadlock it was written for

2 participants