Retry registration serialization failures instead of misreporting them as duplicates - #357
Conversation
…eporting them as duplicates Concurrent registrations of DIFFERENT emails can deadlock on index gap locks under the SERIALIZABLE registration transaction (MariaDB 1213, Postgres serialization_failure). persistNewUserAccount translated any ConcurrencyFailureException into UserAlreadyExistException, so the deadlock victim's caller rendered the anti-enumeration success page while no account was created and no verification email sent — a silently lost registration. Found via the demo app's concurrent Playwright suite during 5.3.0 release validation; reproduced on 5.2.0. persistNewUserAccount now translates only DataIntegrityViolationException (a true duplicate) and lets serialization failures propagate to a new bounded retry in registerNewUserAccount (5 attempts, growing jittered backoff). Each retry persists a fresh entity copy — a rolled-back attempt leaves the original instance carrying a generated id and Hibernate collection state, which would otherwise fail the retry with optimistic-locking/orphan-delete errors — and re-runs the emailExists pre-check in a fresh transaction, so a genuine same-email race still returns the 409/anti-enumeration response. Exhausted retries surface the ConcurrencyFailureException (500) rather than a false success. SERIALIZABLE isolation itself is unchanged. Tests: unit coverage for transient-failure retry, duplicate-on-retry, and retry exhaustion; new Testcontainers test racing 6 distinct-email registrations on MariaDB and PostgreSQL (failed against the old code with the misreport, and caught both entity-state bugs in the retry).
ReviewSolid fix for a real bug, and the root-cause analysis in the PR description is convincing (SERIALIZABLE + index gap locks deadlocking across unrelated rows is a well-known MariaDB/Postgres phenomenon, and translating any Correctness
Minor: CHANGELOG/code mismatch
Minor: style nits (per CLAUDE.md conventions)
Design considerations (not blockers)
Test coverageGood coverage: transient-failure-then-success, duplicate-found-on-retry, retry-exhaustion (unit), plus a new Testcontainers test racing 6 distinct emails across MariaDB/Postgres that reportedly caught two real entity-state bugs during development. The unit tests correctly verify Nice fix overall — the design commentary in the Javadoc is unusually thorough and will help future maintainers avoid regressing this. |
There was a problem hiding this comment.
Pull request overview
This PR fixes a long-standing concurrency bug in the library’s SERIALIZABLE registration flow where serialization failures/deadlocks (often between different emails) were incorrectly translated into “user already exists”, causing silently lost registrations. The change adjusts exception translation and introduces a controlled retry loop so transient serialization failures succeed while true duplicates still produce the anti-enumeration 409 response.
Changes:
- Stop translating
ConcurrencyFailureExceptiontoUserAlreadyExistException; only translate true duplicates (DataIntegrityViolationException). - Add a SERIALIZABLE persist retry loop with jittered backoff and fresh entity instances per attempt.
- Expand unit + Testcontainers concurrency tests to cover transient failures, retry-found duplicates, and exhausted retries.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/service/UserService.java | Adds serialization retry wrapper and narrows duplicate translation to integrity violations only. |
| src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java | Adds unit tests validating retry success, duplicate detection on retry, and exhaustion behavior. |
| src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java | Adds real-DB concurrent distinct-email registration test to ensure retries prevent lost registrations. |
| CHANGELOG.md | Documents the fixed misreporting/lost-registration behavior (needs attempt count alignment). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| - Remember-me enabled without a signing key, and `usePersistentTokens=true` without a `PersistentTokenRepository` bean, now log explicit warnings instead of silently skipping/downgrading. | ||
| ### Fixed | ||
| - Concurrent registrations of **different** emails could deadlock under the SERIALIZABLE registration transaction (MariaDB error 1213) and the victim was misreported as "user already exists": the person saw the registration-pending page while no account was created and no verification email sent. Serialization failures are now retried in a fresh transaction (up to 3 attempts) — a genuine same-email race still returns the 409/anti-enumeration response, and exhausted retries surface as an error instead of a false success. Affects all prior versions; found via the demo app's concurrent Playwright suite. |
| } catch (InterruptedException interrupted) { | ||
| Thread.currentThread().interrupt(); | ||
| throw lastFailure; | ||
| } |
…n tests The @async RegistrationListener (default application executor, not the dsMailExecutor the CAPTCHA test drains) can commit a verification token between the cleanup transaction's deleteByUser and the user delete, failing tearDown on the FK constraint — seen on PR #357 CI. Retry the cleanup transaction so a fresh attempt sees and deletes the late token.
Review: Retry registration serialization failures instead of misreporting them as duplicatesSolid fix for a real bug — good root-cause diagnosis (deadlock victim swallowed as a false "already exists"), and the reproduction story via Testcontainers racing 6 distinct emails is convincing evidence the fix works. A few things worth a look before merge: Bugs / inconsistencies
Design considerations (not blockers)
Correctness spot-checks (all looked right)
Test coverageGood breadth: unit tests cover transient-failure-then-succeed, duplicate-found-on-retry, and exhausted-retries-propagate, and the new Testcontainers test ( One smaller note on the test-infra changes ( Overall: correct fix for a genuinely nasty bug, well-tested, and the tradeoffs (bounded retry, blocking sleep, hardcoded constants) are reasonable defaults. The CHANGELOG attempt-count mismatch is the one thing I'd fix before merge. |
Uh oh!
There was an error while loading. Please reload this page.
Problem
Concurrent registrations of different emails can deadlock on index gap locks under the SERIALIZABLE registration transaction (MariaDB error 1213, Postgres
serialization_failure).UserService.persistNewUserAccounttranslated anyConcurrencyFailureExceptionintoUserAlreadyExistException, so the deadlock victim was shown the anti-enumeration "Thank you for registering!" page while no account was created and no verification email sent — a silently lost registration.Found during 5.3.0 release validation via the demo app's concurrent Playwright suite (
registration.spec.tsflaking with1213-40001: Deadlockimmediately followed byUser already existsfor a fresh unique email). Reproduced on released 5.2.0 — affects all prior versions.Fix
persistNewUserAccountnow translates onlyDataIntegrityViolationException(a true duplicate → 409) and lets serialization failures propagate.registerNewUserAccountretries the SERIALIZABLE write (5 attempts, growing jittered backoff). Each attempt persists a fresh entity copy — a rolled-back attempt leaves the original instance with a generated id and Hibernate collection state that would fail the retry — and re-runs theemailExistspre-check in a fresh transaction, so a genuine same-email race still yields the 409/anti-enumeration response.ConcurrencyFailureException(500) instead of a false success.The passwordless path is untouched: a deadlock there already surfaces as a 500 (honest, just not retried); it has no misreport bug.
Testing
UserAlreadyExistException; exhausted retries propagateConcurrencyFailureException(all watched fail first).ObjectOptimisticLockingFailureException; collection state → orphan-delete error). Existing same-email race tests unchanged and passing.