Skip to content

🐛 Serialise first-login user provisioning behind an advisory lock - #10

Closed
awais786 wants to merge 1 commit into
foss-mainfrom
fix/sso-provision-race-condition
Closed

🐛 Serialise first-login user provisioning behind an advisory lock#10
awais786 wants to merge 1 commit into
foss-mainfrom
fix/sso-provision-race-condition

Conversation

@awais786

Copy link
Copy Markdown
Collaborator

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
});
```

  • `pg_advisory_xact_lock` is transaction-scoped — released automatically on commit/rollback, no leak risk
  • `hashtextextended` maps the email to a stable bigint, exactly what `pg_advisory_xact_lock` expects
  • Re-check inside the lock catches the case where another request committed while we waited

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:

  • Doesn't depend on the constraint (cleaner separation: schema describes data shape, code handles concurrency)
  • Doesn't generate a logged error per losing request — the losers wait and read, no failure surface
  • Matches the approach used in the companion fix in `Pressingly/outline` (no unique constraint there by design, so advisory lock was the only option). Same code shape across forks.

Tests

Three new tests in `sso-user-provisioning.service.spec.ts`:

  • `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

All existing tests pass unchanged.

🤖 Generated with Claude Code

…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>
@awais786 awais786 closed this May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant