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. |
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.