🐛 Serialise first-login user provisioning behind an advisory lock - #10
Closed
awais786 wants to merge 1 commit into
Closed
🐛 Serialise first-login user provisioning behind an advisory lock#10awais786 wants to merge 1 commit into
awais786 wants to merge 1 commit into
Conversation
…lock
On first-ever SSO login the SPA fires multiple parallel API requests
with no session cookie set yet, so every request reaches the proxy-
login flow, every request hits the findOne→save below, and before the
row is committed the others have already passed the findOne miss. The
losers of the race throw a unique-constraint 500 instead of returning
the winner's row.
Real production occurrence (in Outline, same class of bug): 5
duplicate users for one Cognito identity within ~800ms.
Twenty's UserEntity DOES have a unique constraint on email, but the
current code does NOT catch the resulting DB error. So even though we
won't store duplicates, the losing requests fail with a 500 — and on
retry, both the request that succeeded and any subsequent ones go
through the same race window.
Fix: serialise the find+create on a Postgres advisory lock keyed by
the email hash. Different emails take different lock keys; concurrent
first-logins for distinct users don't contend.
Mechanism:
- dataSource.transaction wraps the lookup + create
- SELECT pg_advisory_xact_lock(hashtextextended($1, 0)) inside the
transaction makes concurrent requests for the same email block on
the lock; the second one waits until the first commits
- Re-check findOne inside the lock so the waiting request sees the
just-created row and skips its own create
- Lock is transaction-scoped (xact variant), released automatically
on commit/rollback — no leak risk
Tests:
- should-acquire-an-advisory-lock-keyed-by-email-before-find-create
pins the lock pattern (call shape + parameter)
- should-take-the-advisory-lock-before-any-user-lookup pins the
ordering invariant (lock first, then findOne) — guards against a
refactor that moves the findOne above the lock
- should-skip-create-when-another-request-committed-a-row-first
simulates the race outcome: lock serialises, findOne under the
lock sees the winning row, we return it without creating
This matches the openspec contract "Concurrent creation races SHALL
fall back to read" requirement — the advisory lock IS the runtime
serialisation point; reads under the lock observe the winner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.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.
Summary
On first-ever SSO login the SPA fires multiple parallel API requests (workspaces, members, …) with no session cookie set yet. Every request reaches `SsoUserProvisioningService.findOrCreateUser`, every request hits the `findOne`→`save` pair, and before the row is committed the others have already passed the `findOne` miss. The losers of the race throw a unique-constraint 500 instead of returning the winner's row.
Same root cause as the Outline incident where a user reported 5 duplicate User rows for one Cognito identity within ~800ms — different fork, same first-login parallel-request pattern.
Twenty's `UserEntity` does have a unique-on-email constraint, but the current code does NOT catch the resulting DB error. The losing requests fail with a 500. The user retries; the same race window reopens.
Fix
Serialise the find+create on a Postgres advisory lock keyed by the email hash. Different emails take different lock keys; concurrent first-logins for distinct users don't contend.
```ts
return await this.dataSource.transaction(async (manager) => {
await manager.query(
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
[email],
);
const userRepo = manager.getRepository(UserEntity);
const existing = await userRepo.findOne({ where: { email } });
if (existing) return existing;
// … create + save under the lock
});
```
Why not just rely on the unique constraint?
Catching `QueryFailedError` on unique violation and falling back to `findOne` would also work — that's the pattern Plane and SurfSense use in the equivalent flow. But it requires the constraint to exist, and it surfaces the race as an error path rather than serialising at the source. The advisory-lock approach:
Tests
Three new tests in `sso-user-provisioning.service.spec.ts`:
All existing tests pass unchanged.
🤖 Generated with Claude Code