Symptom
UserServiceBase.GetUserAsync(ClaimsPrincipal) is a check-then-act against the user repository:
var user = await _userRepository.GetAsync(identity);
if (user == null)
{
user = new UserEntity { ... };
await _userRepository.AddAsync(user);
}
Two near-simultaneous requests for an identity that doesn't yet exist (a fresh login OR a first login after a fresh-environment database is created) both pass the null check and both AddAsync. The User collection has no unique index on Identity (or Key), so both inserts succeed — leaving the collection with two UserEntity rows for the same identity.
Two user-visible failures follow:
NavMenu and any other code path through UserServiceBase.GetUserAsync — IUserRepository<UserEntity>.GetAsync(identity) calls SingleOrDefaultAsync under the hood, so subsequent reads throw System.InvalidOperationException: Sequence contains more than one element.
- The Tharga.Team.Blazor
/developer/user admin page — the page binds the user list to a RadzenDataGrid keyed by UserViewModel.Key. Two duplicate rows with the same Key produce a render-tree exception: "More than one sibling of component 'Radzen.Blazor.RadzenDataGridRow`1[Tharga.Team.Blazor.Features.User.UserViewModel]' has the same key value, ... Key values must be unique." — page is unrenderable until duplicates are deleted.
Fixing the race here resolves both. (The RadzenDataGrid crash is technically a separate fragility — the page should also dedupe in-memory before keying — but in practice nobody hits it once the race is closed.)
Observed in
Tharga.Team.MongoDB 2.0.17 / Tharga.Team.Blazor 2.0.17, consumed from Quilt4Net.Server v4.2.17.0.
Reproducer
We observed the race across three per-environment databases on the same MongoDB cluster (Quilt4Net_Development.User, Quilt4Net_Production.User, Quilt4Net_Test.User) — each containing exactly two UserEntity rows with identical Key/Identity/EMail and sequential _id ObjectIds born milliseconds apart (classic concurrent-insert timing, not user error).
The duplicates re-appear after manual mongosh dedupe whenever the affected identity has a concurrent first-page-load against a previously-clean per-env database. We've cleaned the same identity by hand twice already this session.
Suggested fix (two layers, both belong here)
1. Unique index on User.Identity. Declare it on Tharga.Team.MongoDB's internal User repository collection's Indices so Tharga.MongoDB's AssureIndex creates and maintains it. MongoDB then rejects the racing AddAsync and IUserRepository.AddAsync surfaces a MongoWriteException with Category = DuplicateKey.
2. Race-proof user creation in UserServiceBase.GetUserAsync. Either:
- Replace the check-then-act with
FindOneAndUpdate(filter, setOnInsert: ..., options: { IsUpsert: true, ReturnDocument: After }) — race-proof, single round-trip.
- Or keep the current shape but catch
MongoWriteException with DuplicateKey from AddAsync and re-read to return the winning row.
The upsert is the cleaner long-term fix; the catch-and-retry is acceptable as a minimal change.
Why this can't be fixed downstream
Tried in our consumer (details in comment below):
- An
IHostedService that creates the index via raw MongoClient at startup — Tharga.MongoDB's AssureIndex reconciles indexes against what each IRepositoryCollection class declares and drops anything it doesn't recognize. The index would be re-dropped on every startup. Reverted.
- Subclassing
UserServiceRepositoryBase<TUserEntity> and overriding Indices — doesn't compile: that type is a service-level abstraction (it has an abstract CreateUserEntityAsync(ClaimsPrincipal, string) for user-from-claims construction), not a Tharga.MongoDB collection contract. Its Indices member isn't virtual/override-able. The actual IRepositoryCollection<UserEntity, ObjectId> that owns the User collection's indexes is internal to Tharga.Team.MongoDB and not exposed.
So in addition to landing the upstream fix, please also add an extension point for consumers to manage per-deployment indexes (useful even after the fix lands — e.g. for tenant-specific indexing). One of:
- Make the User repository collection class
public and its Indices virtual, with RegisterUserRepository<TUserEntity, TCollection> accepting an explicit collection-type override.
- Or add an
Indices (collection or callback) member to ThargaTeamOptions, alongside the existing UserCollectionName.
Current downstream coverage (mitigations only — race still happens)
UserService.GetUserAsync override (commit 0b1c84a on Quilt4Net.Server v4.2.17.0) catches the Sequence contains more than one element InvalidOperationException and recovers via OrderBy(u => u.Id).FirstOrDefault() — preserves user navigation through NavMenu, but does nothing for /developer/user (which doesn't go through UserService).
MongoWriteException/DuplicateKey catch added to the same method on the feature/fix-log-views-masked-credentials branch — defensive code, no-op without an upstream unique index, becomes load-bearing the moment Tharga ships one. Forward-compatible — no change needed downstream when this issue lands.
Neither prevents the creating race. Until upstream fixes it, every fresh (environment, identity) is exposed.
Related
- #59 —
TeamComponent.RemoveUserFromTeam brittle against duplicate TeamMember rows (fixed in 2.0.17).
- #64 — comprehensive
.Single(predicate) sweep across Tharga.Team / Tharga.Team.Blazor. The /developer/user RadzenDataGrid duplicate-key crash is the same bug-class layer up: even with the race fixed here, the page should also dedupe its in-memory list before keying the grid (defence in depth).
Happy to PR the upstream fix if useful.
Symptom
UserServiceBase.GetUserAsync(ClaimsPrincipal)is a check-then-act against the user repository:Two near-simultaneous requests for an identity that doesn't yet exist (a fresh login OR a first login after a fresh-environment database is created) both pass the
nullcheck and bothAddAsync. The User collection has no unique index onIdentity(orKey), so both inserts succeed — leaving the collection with twoUserEntityrows for the same identity.Two user-visible failures follow:
NavMenuand any other code path throughUserServiceBase.GetUserAsync—IUserRepository<UserEntity>.GetAsync(identity)callsSingleOrDefaultAsyncunder the hood, so subsequent reads throwSystem.InvalidOperationException: Sequence contains more than one element./developer/useradmin page — the page binds the user list to aRadzenDataGridkeyed byUserViewModel.Key. Two duplicate rows with the sameKeyproduce a render-tree exception: "More than one sibling of component 'Radzen.Blazor.RadzenDataGridRow`1[Tharga.Team.Blazor.Features.User.UserViewModel]' has the same key value, ... Key values must be unique." — page is unrenderable until duplicates are deleted.Fixing the race here resolves both. (The
RadzenDataGridcrash is technically a separate fragility — the page should also dedupe in-memory before keying — but in practice nobody hits it once the race is closed.)Observed in
Tharga.Team.MongoDB2.0.17 /Tharga.Team.Blazor2.0.17, consumed fromQuilt4Net.Serverv4.2.17.0.Reproducer
We observed the race across three per-environment databases on the same MongoDB cluster (
Quilt4Net_Development.User,Quilt4Net_Production.User,Quilt4Net_Test.User) — each containing exactly twoUserEntityrows with identicalKey/Identity/EMailand sequential_idObjectIds born milliseconds apart (classic concurrent-insert timing, not user error).The duplicates re-appear after manual mongosh dedupe whenever the affected identity has a concurrent first-page-load against a previously-clean per-env database. We've cleaned the same identity by hand twice already this session.
Suggested fix (two layers, both belong here)
1. Unique index on
User.Identity. Declare it onTharga.Team.MongoDB's internal User repository collection'sIndicesso Tharga.MongoDB'sAssureIndexcreates and maintains it. MongoDB then rejects the racingAddAsyncandIUserRepository.AddAsyncsurfaces aMongoWriteExceptionwithCategory = DuplicateKey.2. Race-proof user creation in
UserServiceBase.GetUserAsync. Either:FindOneAndUpdate(filter, setOnInsert: ..., options: { IsUpsert: true, ReturnDocument: After })— race-proof, single round-trip.MongoWriteExceptionwithDuplicateKeyfromAddAsyncand re-read to return the winning row.The upsert is the cleaner long-term fix; the catch-and-retry is acceptable as a minimal change.
Why this can't be fixed downstream
Tried in our consumer (details in comment below):
IHostedServicethat creates the index via rawMongoClientat startup — Tharga.MongoDB'sAssureIndexreconciles indexes against what eachIRepositoryCollectionclass declares and drops anything it doesn't recognize. The index would be re-dropped on every startup. Reverted.UserServiceRepositoryBase<TUserEntity>and overridingIndices— doesn't compile: that type is a service-level abstraction (it has an abstractCreateUserEntityAsync(ClaimsPrincipal, string)for user-from-claims construction), not a Tharga.MongoDB collection contract. ItsIndicesmember isn'tvirtual/override-able. The actualIRepositoryCollection<UserEntity, ObjectId>that owns the User collection's indexes is internal toTharga.Team.MongoDBand not exposed.So in addition to landing the upstream fix, please also add an extension point for consumers to manage per-deployment indexes (useful even after the fix lands — e.g. for tenant-specific indexing). One of:
publicand itsIndicesvirtual, withRegisterUserRepository<TUserEntity, TCollection>accepting an explicit collection-type override.Indices(collection or callback) member toThargaTeamOptions, alongside the existingUserCollectionName.Current downstream coverage (mitigations only — race still happens)
UserService.GetUserAsyncoverride (commit0b1c84aon Quilt4Net.Server v4.2.17.0) catches theSequence contains more than one elementInvalidOperationExceptionand recovers viaOrderBy(u => u.Id).FirstOrDefault()— preserves user navigation throughNavMenu, but does nothing for/developer/user(which doesn't go throughUserService).MongoWriteException/DuplicateKeycatch added to the same method on thefeature/fix-log-views-masked-credentialsbranch — defensive code, no-op without an upstream unique index, becomes load-bearing the moment Tharga ships one. Forward-compatible — no change needed downstream when this issue lands.Neither prevents the creating race. Until upstream fixes it, every fresh
(environment, identity)is exposed.Related
TeamComponent.RemoveUserFromTeambrittle against duplicateTeamMemberrows (fixed in 2.0.17)..Single(predicate)sweep acrossTharga.Team/Tharga.Team.Blazor. The/developer/userRadzenDataGridduplicate-key crash is the same bug-class layer up: even with the race fixed here, the page should also dedupe its in-memory list before keying the grid (defence in depth).Happy to PR the upstream fix if useful.