Skip to content

GH-10847: Fix putIfAbsent() loop in JdbcMetadataStore - #11247

Merged
artembilan merged 1 commit into
spring-projects:mainfrom
lsh1215:GH-10847
Aug 10, 2026
Merged

GH-10847: Fix putIfAbsent() loop in JdbcMetadataStore#11247
artembilan merged 1 commit into
spring-projects:mainfrom
lsh1215:GH-10847

Conversation

@lsh1215

@lsh1215 lsh1215 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

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(*) = 0 to insert, then a plain SELECT (getValueQuery) to re-read. The MySQL 8.0 manual advises against this under REPEATABLE READ: a non-locking SELECT is 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=0 with no exception next to SELECT -> EMPTY.

put() has the same insert-then-re-read shape and is unaffected, because it re-reads with getValueForUpdateQuery.

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 UPDATE unconditionally was tried in #10980 and breaks verifyJdbcMetadataStoreConcurrency: 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. getValueForUpdateQuery is the statement put() and remove() already issue, so every vendor already executes it in production.

@Transactional stays, no isolation level is forced from inside the store, and tryToPutIfAbsent() is untouched.

Tests

  • MySqlMetadataStoreTests.verifyPutIfAbsentSeesConcurrentlyCommittedValue - the reproducing case. Fails with a TimeoutException before the change. It pins ISOLATION_REPEATABLE_READ on the TransactionTemplate rather than trusting the server default, otherwise it would also pass against unfixed code if that default ever changed.

./gradlew :spring-integration-jdbc:check passes; 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(). CannotAcquireLockException is a TransientDataAccessException, so a deadlock is caught there and returned as 0, 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 UPDATE can, so a lock-wait timeout or deadlock on that read now propagates out of putIfAbsent(). I chose propagation over catch-and-retry because put() and remove() 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 MySQL REPEATABLE READ loops exactly as before - PersistentAcceptOnceFileListFilterExternalStoreTests uses that setting. PostgreSQL under an explicit REPEATABLE READ also 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 READ a SELECT ... FOR UPDATE matching 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 for putIfAbsent().

@lsh1215
lsh1215 force-pushed the GH-10847 branch 3 times, most recently from f605f9c to 0e08bb7 Compare August 9, 2026 00:01
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>
@lsh1215
lsh1215 marked this pull request as ready for review August 9, 2026 00:25
@artembilan
artembilan merged commit df30ff1 into spring-projects:main Aug 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants