Here's a race-condition fix that looks completely correct, passes its unit test, and does absolutely nothing in production. We shipped it. Twice. Let me save you the trouble.
The setup
You have a "find or create" — one row per key, created on demand, possibly from two threads or two pods at once:
@Transactional
public Review findOrCreate(Long urlId, String message) {
return repository.findByUrlId(urlId)
.orElseGet(() -> {
try {
return repository.save(new Review(urlId, message));
} catch (DataIntegrityViolationException race) {
// Someone else inserted between our find and our save — re-read.
return repository.findByUrlId(urlId).orElseThrow();
}
});
}
This is the textbook pattern: attempt the insert, and if a concurrent insert won the unique constraint, catch the violation and re-read. It's even recommended in a lot of "how to handle find-or-create races" answers. It's what we wrote.
Our unit test mocked repository.save(...) to throw DataIntegrityViolationException, asserted the re-read path returned the winner's row, and went green. Ship it.
The problem
In production, under real concurrency, the catch block never runs. Both callers sail straight through save() without an exception, and one of them blows up later — from somewhere else entirely.
The root cause: save() doesn't flush
For a new entity, Spring Data's save() calls EntityManager.persist(). And persist() does not execute an INSERT. It schedules one. With the default FlushMode.AUTO, the actual SQL is flushed to the database either before the next query that needs it, or — if nothing forces it sooner — at transaction commit.
In our method, nothing forces it. After save() there's no follow-up query, so the INSERT is deferred to commit. And commit happens after your method returns, inside Spring's transaction interceptor — which is outside your try/catch.
So the timeline for the losing thread is:
findByUrlId→ empty.save()→ schedules an insert, returns a managed entity, no SQL yet, no exception.- Method returns normally. Your
catchis now out of scope. - Spring commits the transaction → the deferred
INSERThits the unique index → Postgres raises the violation → it propagates from the proxy, not from yoursave().
Your carefully written recovery code is unreachable. The mock lied because it threw synchronously from save() — a thing real JPA never does here.
"Fine, I'll flush it myself"
Tempting:
return repository.saveAndFlush(new Review(urlId, message)); // now it throws inside the try!
Now the violation does surface inside your try. Except you've traded one dead-end for another: once Postgres raises an error inside a transaction, that transaction is marked rollback-only. Your catch block runs, your re-read runs... and then commit throws UnexpectedRollbackException anyway, because you can't keep working in a poisoned transaction. Recovering in the same transaction is not a thing Postgres will let you do.
The fix
Do the conflict resolution in the database, in one atomic statement, so there's no "catch and recover" to get wrong:
INSERT INTO review (url_id, message, created_at, updated_at)
VALUES (:urlId, :message, :now, :now)
ON CONFLICT (url_id) DO NOTHING
RETURNING id;
@Transactional
public Optional<Long> createIfAbsent(Long urlId, String message) {
List<Number> inserted = em.createNativeQuery("""
INSERT INTO review (url_id, message, created_at, updated_at)
VALUES (:urlId, :message, :now, :now)
ON CONFLICT (url_id) DO NOTHING
RETURNING id
""")
.setParameter("urlId", urlId)
.setParameter("message", message)
.setParameter("now", System.currentTimeMillis())
.getResultList();
return inserted.stream().findFirst().map(Number::longValue);
}
ON CONFLICT DO NOTHING makes the concurrent-insert case a silent no-op — the loser gets an empty result and knows it lost, with no exception and no poisoned transaction. If you genuinely need the existing row, do the read in a separate transaction after this returns.
If you're stuck on a database without upsert, the other correct shape is: attempt the insert in its own short transaction, let it fail, then re-read in a fresh transaction. The critical word is "fresh" — never recover inside the transaction that failed.
How to not get fooled by the test
The tell was the mock. when(repository.save(any())).thenThrow(new DataIntegrityViolationException(...)) encodes an assumption — "save throws synchronously" — that is false for the exact scenario under test. If a concurrency fix's only proof is a Mockito stub, it hasn't been tested. For this class of bug you need a real database with two real connections. A Testcontainers Postgres test that fires two threads at the same key and asserts exactly one row is created is worth more than a dozen mocked ones.
Lessons learned
save()≠INSERT. For new entities it'spersist(), and the SQL is deferred to flush/commit.- Exceptions from deferred SQL escape your
try/catch— they come out of the commit, not the call. - You cannot recover inside a transaction Postgres has marked rollback-only. Resolve conflicts in SQL (
ON CONFLICT) or in a separate transaction. - Mock a race and you've tested your mock. Concurrency bugs need real connections.
Ever shipped a "fix" that turned out to be unreachable code? The JPA flush model catches a lot of people — what got you?
Building jo4.io — a URL shortener with analytics for developers, running on a Spring Boot + Postgres stack that keeps us honest.