Skip to content

fix: race-proof user creation + unique Identity index + collection extension point#67

Merged
poxet merged 2 commits into
masterfrom
feature/user-upsert-race-fix
May 11, 2026
Merged

fix: race-proof user creation + unique Identity index + collection extension point#67
poxet merged 2 commits into
masterfrom
feature/user-upsert-race-fix

Conversation

@poxet
Copy link
Copy Markdown
Contributor

@poxet poxet commented May 11, 2026

Summary

Resolves #65. Closes the check-then-act race in UserServiceRepositoryBase.GetUserAsync that produces duplicate UserEntity rows when two near-simultaneous first-time logins for the same identity hit a fresh per-environment User collection.

  • Unique Identity index declared on UserRepositoryCollection.Indices. Tharga.MongoDB's AssureIndex creates and maintains it.
  • Race-proof GetUserAsync — catches MongoWriteException with WriteError.Category == ServerErrorCategory.DuplicateKey from AddAsync, re-reads by Identity, and returns the winning row.
  • Consumer-facing index extension pointUserRepositoryCollection<TUserEntity> is now public; subclass it to declare per-deployment indices. Register the subclass via the new ThargaTeamOptions.RegisterUserRepository<TUserEntity, TCollection>() overload. See the new "Adding per-deployment User indices" section in Tharga.Team.MongoDB/README.md.

⚠️ Migration prerequisite

Existing deployments with duplicate User rows must dedupe before upgrading. The unique Identity index will fail to create against conflicting data; Tharga.MongoDB's AssureIndex surfaces this in startup logs and refuses to apply the index until the data is clean.

Recommended cleanup (mongosh, per affected database):
```js
db.User.aggregate([
{ $group: { _id: "$Identity", ids: { $push: "$_id" }, count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } }
])
```
Then delete all but the earliest `_id` per group (or whichever row is canonical).

Behaviour change for consumers

  • UserServiceRepositoryBase constructor unchanged. Existing subclasses (UserService in consumer projects) compile without change.
  • ThargaTeamOptions.RegisterUserRepository<TUserEntity>() continues to register the built-in collection. The new <TUserEntity, TCollection>() overload is opt-in.
  • UserRepositoryCollection<TUserEntity> is now public (was internal). Source-compatible promotion.

Consumer cleanup unlocked (separate downstream bumps)

  • Quilt4Net.Server can remove its UserIdentityIndexHostedService (it created the index out-of-band) and its MongoWriteException/DuplicateKey catch in UserService.GetUserAsync (defensive code that becomes redundant). Tracked downstream.

Out of scope

  • FindOneAndUpdate \$setOnInsert upsert path — both approaches close the race; catch-DuplicateKey is the chosen minimal change.
  • Built-in dedupe-on-startup migration — admins clean duplicates manually.
  • Symmetric fix for `TeamRepository` user-key races — the Team collection already has a unique `UniqueTeamMemberKey` index.

Test plan

  • `dotnet build -c Release` — 0 warnings, 0 errors
  • `dotnet test -c Release` — 296 / 296 passing (7 new tests)
  • New `Tharga.Team.MongoDB.Tests` project added to the solution
  • `UserServiceRepositoryBaseRaceTests` (4 tests) — no-existing-user, existing-user, add-throws-DuplicateKey-re-reads-winner, add-throws-non-DuplicateKey-propagates
  • `RegisterUserRepositoryTests` (2 tests) — default overload registers built-in collection; subclass overload registers consumer subclass
  • `UserRepositoryCollectionIndicesTests` (1 test) — `Indices` contains exactly the expected unique `Identity` index

poxet added 2 commits May 11, 2026 13:55
…tension point

Resolves #65 — closes the check-then-act race in
UserServiceRepositoryBase.GetUserAsync that produces duplicate UserEntity
rows when two near-simultaneous first-time logins for the same identity hit
a fresh per-environment User collection.

- Added unique index on User.Identity declared in UserRepositoryCollection.Indices.
- Added MongoWriteException/DuplicateKey catch in UserServiceRepositoryBase.GetUserAsync:
  on conflict, re-read by Identity and return the winning row.
- Promoted UserRepositoryCollection<TUserEntity> to public so consumers can subclass
  it to declare per-deployment indices.
- Added ThargaTeamOptions.RegisterUserRepository<TUserEntity, TCollection>() overload
  for registering the consumer subclass.

New Tharga.Team.MongoDB.Tests project with 7 tests. 296 / 296 passing.

Migration note for the PR: existing deployments with duplicate User rows must
dedupe before upgrading; the unique index will fail to create against conflicting
data and Tharga.MongoDB's AssureIndex surfaces this in startup logs.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@poxet poxet merged commit 5f959fc into master May 11, 2026
5 of 6 checks passed
@poxet poxet deleted the feature/user-upsert-race-fix branch May 11, 2026 12:05
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.

UserServiceBase.GetUserAsync race creates duplicate UserEntity rows; needs upsert + unique-Identity index

1 participant