GH-10847: Fix putIfAbsent() loop in JdbcMetadataStore - #11247
Merged
Conversation
lsh1215
force-pushed
the
GH-10847
branch
3 times, most recently
from
August 9, 2026 00:01
f605f9c to
0e08bb7
Compare
artembilan
requested changes
Aug 9, 2026
Fixes: spring-projects#10847 `putIfAbsent()` mixes a locking statement with a non-locking one inside a single transaction. The `INSERT ... SELECT ... HAVING COUNT(*)=0` uses the most recent state of the database, while the `SELECT` that re-reads the value is served from the transaction's read view. Under MySQL's default `REPEATABLE READ` those two disagree whenever the read view predates the commit that added the row: the insert observes the row and reports zero affected rows, the re-read finds nothing, and the loop starts over with no terminating branch. MySQL documents mixing the two in one `REPEATABLE READ` transaction as something to avoid. The read view can predate that commit in two ways. The caller's broader transaction may have read before delegating to the store, which is how the reporter reaches it from a channel adapter. A deadlock between two concurrent `INSERT ... SELECT` statements also rolls the transaction back; the next plain `SELECT` then establishes a read view that can still predate that commit. Re-read with the locking query when, and only when, the non-locking one comes back empty. A locking read is not served from the read view, so it observes the same state the insert did. Keeping the non-locking query as the first attempt matters: it serves nearly every call and adds no contention, whereas always locking makes a hundred threads contending for one key serialize on it. This also lines `putIfAbsent()` up with `put()`, which already uses the locking query when it re-reads. A row that exists with a null `METADATA_VALUE` stays distinguishable from an absent one, since conflating them would leave the loop with nowhere to exit. The extra query is skipped when the lock hint is configured as an empty string, because the two queries are then the same. This addresses MySQL under `REPEATABLE READ`. PostgreSQL defaults to `READ COMMITTED` and appends `ON CONFLICT DO NOTHING`, so it does not reach the branch; under an explicit `REPEATABLE READ` its locking reads are confined to the snapshot and the limitation remains. Signed-off-by: Sanghun Lee <vitash1215@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes: gh-10847
Follow-up to my analysis comment on the issue.
The defect
putIfAbsent()mixes a locking statement with a non-locking one inside a single transaction:INSERT ... SELECT ... HAVING COUNT(*) = 0to insert, then a plainSELECT(getValueQuery) to re-read. The MySQL 8.0 manual advises against this underREPEATABLE READ: a non-lockingSELECTis served from the transaction's read view, while locking statements use the most recent state of the database.Whenever the read view was created before the commit that added the row, the insert sees the row and reports 0 affected rows, the re-read cannot see it, and the loop has no branch left that can terminate. Instrumented, every logged pass from 2 to 22,000 of one stuck call reports
INSERT affected=0with no exception next toSELECT -> EMPTY.put()has the same insert-then-re-read shape and is unaffected, because it re-reads withgetValueForUpdateQuery.The change
Re-read with the locking query when, and only when, the non-locking one comes back empty.
Keeping the non-locking query as the first attempt is deliberate. Switching the re-read to
FOR UPDATEunconditionally was tried in #10980 and breaksverifyJdbcMetadataStoreConcurrency: 99 of the 100 threads were never blind to begin with, and making all of them take an exclusive lock serialises them on one key. Applied only on the empty path the lock fires on roughly 5% of re-reads in that same test.No new SQL is introduced.
getValueForUpdateQueryis the statementput()andremove()already issue, so every vendor already executes it in production.@Transactionalstays, no isolation level is forced from inside the store, andtryToPutIfAbsent()is untouched.Tests
MySqlMetadataStoreTests.verifyPutIfAbsentSeesConcurrentlyCommittedValue- the reproducing case. Fails with aTimeoutExceptionbefore the change. It pinsISOLATION_REPEATABLE_READon theTransactionTemplaterather than trusting the server default, otherwise it would also pass against unfixed code if that default ever changed../gradlew :spring-integration-jdbc:checkpasses; the existing 100-thread test was also run over 50 rounds with no failures.Scope and known limitations
Please weigh these; I kept the change narrow rather than deciding them on my own.
Swallowed deadlocks in
tryToPutIfAbsent().CannotAcquireLockExceptionis aTransientDataAccessException, so a deadlock is caught there and returned as0, which the loop reads as "the row already exists". A deadlock means the whole transaction was rolled back, so the code then keeps working inside a dead transaction and the transaction manager commits it none the wiser. This PR ends the loop; that silent partial rollback stays. It touches the broader transactional unit of work directly, so I left it alone - happy to take it separately.The locking re-read can throw. The old empty path took no locks and could not block.
SELECT ... FOR UPDATEcan, so a lock-wait timeout or deadlock on that read now propagates out ofputIfAbsent(). I chose propagation over catch-and-retry becauseput()andremove()already issue the same locking read without catching, and retrying inside a transaction MySQL has already rolled back is what causes the problem above. Worth confirming this is the behaviour you want.Configurations still unbounded. With
setLockHint("")the two queries are the same, so the guard skips the second one and MySQLREPEATABLE READloops exactly as before -PersistentAcceptOnceFileListFilterExternalStoreTestsuses that setting. PostgreSQL under an explicitREPEATABLE READalso still loops, since its locking reads stay inside the snapshot. There is no loop bound anywhere; whether a bounded retry that fails with a diagnosable exception would be better than spinning is your call.New gap lock. Under MySQL
REPEATABLE READaSELECT ... FOR UPDATEmatching nothing takes a gap lock held until the caller's transaction commits. Previously this path took no locks.remove()already gap-locks a missing key, so it is not a new class of behaviour, but it is new forputIfAbsent().