[#915] Bound the wait of a write transaction for a row lock another session holds - #1010
[#915] Bound the wait of a write transaction for a row lock another session holds#1010vharseko wants to merge 1 commit into
Conversation
|
The two red legs were
Fixed in 5b1db9a by reducing it to the bare Two things worth recording, because the failure reads as platform-specific and is not:
Verified locally under JDK 11 against the same plugin configuration:
The first row is there on purpose: without it a green run only proves the build passed, not that it ran the reference check at all. |
…a row lock another session holds The replays of write() are bounded by a wall clock, and a clock bounds them only while an attempt is shorter than it. What makes an attempt long is the lock wait ahead of the conflict, charged to the attempt that hit it: sql server, postgres and oracle wait for a row lock with no bound of their own, and mysql bounds it at 50 s, five times the window. So an attempt outlasted the window and the operation was left with no replay at all (OpenIdentityPlatform#903). org.openidentityplatform.opendj.jdbc.row.lock.timeout, 3 s by default, is set on the session of a write attempt and taken off again before the connection goes back to the pool - a set local on postgres, innodb_lock_wait_timeout on mysql (never the lock_wait_timeout of a metadata lock), LOCK_TIMEOUT on sql server, and nothing on oracle, which has no session setting for that wait. The failure an engine ends such a wait with is replayed, and only where this backend bounded the wait: 55P03 and error 1222 are no conflict, and a wait nothing bounds is what no clock can govern. The mechanism is OpenIdentityPlatform#885's, split into an arm and a release so a bound can be held around a whole transaction rather than around one statement, with LockBound naming the two waits this backend bounds and what each engine is told about them.
5b1db9a to
9b167eb
Compare
|
Rebased onto master, which now carries both #936 and #904: one commit, 9b167eb, and the diff is the change alone - the "sits on top of #936" preamble of the description is gone with it. What the rebase had to decide is how this bound sits on #904, which landed first. Nothing of #904 is changed or removed. A wait this backend bounded ends in a failure that is no conflict - 55P03, error 1222 - so its verdict is Also carried over: the At this head, JDK 11, javadoc goal on:
sql server and oracle are left to CI at this head; both were green at the one before the rebase. |
Fixes #915, and with it #903 on the three engines that have a setting for this wait.
Rebased onto master now that #936 and #904 are in: the diff is the change alone, in one commit.
armLockBound()is #936'swithDdlLockBound()split into an arm and a release, so that a bound can be held around a whole transaction rather than around one statement; how the change sits on #904 is its own section below.Problem
JDBCStorage.write()bounds its replays twice - byMAX_RETRIESand by a 10 s wall-clock window - and a clock bounds replays only while an attempt is shorter than it. What makes an attempt long is the lock wait ahead of the conflict, charged to the attempt that hit it, and nothing this backend sets ends that wait:LOCK_TIMEOUTis -1 on sql server,lock_timeoutis 0 on postgres, and oracle's row-lock enqueue is unlimited;innodb_lock_wait_timeout- 50 s, five times the window, which as far as the window is concerned is the same thing.So an attempt alone outlasts the window and the operation gets no replay at all, however transient its conflict. That is #903, and the CI failure it was filed on: a sql server deadlock victim picked ~12 s into the first attempt of
testConcurrentWritersInsertingDistinctKeys.StatementBound.OPERATIONdoes bound such a statement today, at 120 s - twelve times the window, and its failure is a cancel thatisConflict()does not match, so it is not replayed either.Change
org.openidentityplatform.opendj.jdbc.row.lock.timeout, in seconds, default 3. The session of a write attempt is told to give up on a row lock at that value, and is given its own value back before the connection goes back to the pool.0or negative leaves the wait exactly where it was; a value that is not a number keeps the default, and one pastMAX_BOUND_SECONDSis clamped, through the sameboundSeconds()every bound of this backend reads.The default is a third of the window on purpose: an attempt that ends at this bound spends that much of it, so the replays a conflicted write gets are the window divided by this value - three at both defaults. A bound at or past the window leaves exactly one attempt, which is the shape #903 describes; the suite asserts that invariant rather than leaving the two constants to drift apart.
Per engine, in the unit its own setting takes:
set local lock_timeout = <ms>set localgoes with the transactionselect @@session.innodb_lock_wait_timeoutset session innodb_lock_wait_timeout = <s>select @@lock_timeoutset lock_timeout <ms>MySQL gets
innodb_lock_wait_timeoutand neverlock_wait_timeout. The first is the row lock a write waits for; the second is the metadata lock a DDL waits for, which is what #936 sets and which bounds nothing of this. Two settings, two defaults - the issue namesDialect.lockTimeoutSqlas if it already carried this statement for all four engines, and on this engine it carries the other one.Oracle is left alone because it has nothing to set.
ddl_lock_timeoutis the DDL lock,distributed_lock_timeoutthe distributed transaction, and the only place the wait of plain DML can be named isselect ... for update wait n, which this backend does not issue. An attempt there stays bounded byStatementBound.OPERATIONalone, whose cancel a session blocked in that enqueue does not act on - what ends such a wait is the socket read timeout, which takes the connection with it. On oracle what answers #903 is therefore not this property but the grant of #904 (see below), and the property's javadoc says so rather than implying four engines are fixed.The bound is scoped to the transaction, never left on the pooled session. That is the placement #936 rejected for the DDL and rejects here for the same reason: postgres and sql server bound every lock wait of a session, and a read borrowing the connection next replays nothing at all, deliberately (
ExportJobandVerifyJobare not idempotent), so it would see the 55P03 or the error 1222 as a hard failure. Armed after the connection is borrowed and taken off in thefinallythat closes the stamp and catalog sessions - after the rollback of a failed attempt, before the release hands the connection on.A lock timeout is replayable only where this backend bounded that wait. 55P03 and error 1222 are no conflict: the engine rolled nothing back and the blocker is still holding the lock. What makes them replayable is the rollback
write()issues before it asks, and what makes them worth replaying is that the wait they cost fits inside the window. Where nothing bounded that wait - the property at 0, oracle, a session that would not take the setting - they stay exactly as unreplayable as they were: a bound an operator set for themselves is not a licence for this loop to take that wait again.ArmedLockBound.boundedis that answer, andreplayReason()reads it rather than re-deriving it from the property. Such a failure is deliberately no class of #904'sConflict: its verdict staysNONE, so it is replayed on the window alone and never granted past it.A mysql lock wait timeout arrives in class 40 and stays the conflict it has been since #867; it is now reported at 3 s instead of 50 s, so it is replayed within the window rather than never.
The readback is paid once per pooled connection. This bound is armed around every write of the server where the DDL bound is armed around an open, so the value a session carries is remembered on the
CachedConnection: only this backend writes that setting, every write puts it back, and a connection whose restore failed is kept out of the pool - so a remembered value cannot outlive the session that answered it. The DDL bound still reads every time, because it runs inside a write that may be carrying this bound on the very same setting (sql server has oneLOCK_TIMEOUTfor both). What a write costs on top of what it cost before is two round trips on mysql and sql server, one plus a savepoint on postgres, none on oracle.One mechanism, two bounds.
LockBoundis the enum of the waits this backend bounds -DDLandROW- carrying the property, the default and what each engine is told;armLockBound()/releaseLockBound()are #936's helper split in two, andwithDdlLockBound()is now three lines over them. The warn-once latches are per bound rather than per storage: reported through one latch, the first open that could not take its bound would silence every write of that backend.What it costs a deployment
A write blocked behind a long writer of another session now fails once the window is spent, where it used to wait as long as it took and then succeed. That is the trade this bound is, and it is the reason the property exists: an operation held for the length of somebody else's transaction cannot be told from one that is never coming back, and the caller of a write is an LDAP client with a timeout of its own. A deployment that would rather wait sets the property to 0 and keeps the old behaviour - #903 along with it.
What it does not change
read(), which arms no bound and replays nothing;StatementBound.BULKstatement -clearTree()'sdelete fromamong them - which take row locks and are deliberately left waiting;MAX_RETRIES,RETRY_WINDOW_NANOSand the retry delay, all unchanged.RETRY_WINDOW_NANOSis package-private now, so the suite can assert the one invariant tying the two constants together.Relationship to #904
#904 is the other answer to #903, and it landed first: it grants the first replay of a prompt conflict because "no clock can bound a wait that nothing else bounds". This PR sits on its machinery rather than beside it and leaves all of it in place - the
Conflictclasses, the one walk ofconflictVerdict(),replayableWithin()andgrantedPastTheWindow().The two fit together like this. A wait this backend bounded ends in a failure that is no conflict - 55P03, error 1222 - so its verdict is
Conflict.NONE:replayReason()replays it on the strength ofArmedLockBound.bounded, andreplayableWithin()then holds it to the window with no grant, since the grant exists for the waits nothing bounds and this one is not. A mysql lock wait timeout staysAFTER_LOCK_WAIT, refused the grant as before, and now arrives at 3 s rather than 50 s, so the window governs it. What the grant is left with is exactly what this bound does not reach - oracle, the property at 0, a session that would not take the setting, and a driverdialectOf()does not recognise, under which nothing is armed - where the wait is as unbounded as it was when #904 was written, and one replay past the window is what remains of #903's answer there.#904's comments described #915 as a future change - "#915 adds one" class, "#915 removes the trade", "#915 retires this method" - and they are brought up to date here rather than left describing a plan that did not happen: the row lock wait is no
Conflictclass, the trade ofUNKNOWN_ENGINEstays because the bound arms nothing under an unrecognised driver, and the grant is kept rather than retired, for the engines and settings above. Nothing of #904's behaviour changes: its cases inJDBCStorageRetryTestpass as they are, the test-sidereplayReason()helper handing the widened signature the bound its cases were written under.Testing
JDBCRowLockBoundTestCase- 26 cases, no database: what each engine is told and in what order, the property and every misconfiguration of it, a session already tighter (mysql at 1, sql server at 0 against its -1), a session that will not say what it carries, a setting refused, a restore that fails and the connection kept out of the pool, postgres in auto-commit and the savepoint in front of the setting, the readback paid once per pooled connection and every time off it, and the replay verdict for a lock timeout with and without a bound of ours - including one reported bycommit(), one on an attempt that had committed part of its work, and one read from the release of the connection.JDBCStorageRetryTest- two cases drivingwrite()end to end through the stub driver: a 55P03 replayed with the bound armed once per attempt, and the same failure not replayed with the property at 0. Its existing cases, #904's among them, pass the bound they were written under (ArmedLockBound.none) through the test's ownreplayReason()helper, which composes the verdict the waywrite()does.JDBCDdlLockBoundTestCase- one expectation updated: the drop of a tree now runs inside a write that arms this bound first, so theset local lock_timeout = 3000of the attempt precedes the search path and the DDL bound of that case.TestCase- the container suites of all four engines gettestAWriteBlockedByARowLockIsReplayedInsideTheWindow: another session takes an exclusive lock on the row a write wants and lets it go after twice the bound. The write must give up on its first attempt and go through on its replay, both inside the window - and on oracle, which has no setting for that wait, it must go through on the first attempt instead, which is the one place the difference this bound makes can be read.assertBoundedWhileRowsAreLocked- the harness of the two statement-bound cases of #877 - turns this property off while it runs, since a bound tighter than the one under test is what would end their wait.The javadoc goal is deliberately not skipped in that command.
attach-javadocsruns on every leg of the build workflow, for every module, under the root pom'sdoclint=all,-missingandfailOnWarnings=true- and at an earlier head a@linkstill naming the five-parameterreplayReason()this branch widened is what tookbuild-maven (macos-latest, 11)and(windows-latest, 26)down. Those two legs only failed first:-P precommitis set on Linux alone, so they reach this module'spackagephase in ~2.5 min while the ubuntu legs are still in integration tests, which would have failed on the same line.At the rebased head, with the javadoc goal on: the four suites above, 210 run, 0 failures - and the postgres and mysql container suites, the case above giving up at the bound and replaying on both:
All four container suites were run at the head before the rebase, oracle waiting the holder out on its first attempt; sql server and oracle are left to CI at this one:
Four mutants, each measured at the head before the rebase - the code they mutate is what the rebase carried over unchanged:
lock_wait_timeoutforinnodb_lock_wait_timeout) -> 3 failures, among themmysql bounds the row lock, not the metadata lock;replayReasonignoringbounded-> 3 failures, includinga wait this backend put no bound on was replayed on a clock that cannot bound itand the end-to-endtestARowLockWaitNothingBoundedIsNotReplayed;write()arming nothing -> 2 failures, the end-to-end replay and the statement order of the drop case;a connection left carrying our bound was handed back to the poolamong them.