Skip to content

fix: serialize UserStore.UpdateAsync under the exclusive stream lock (#70)#74

Merged
andregoepel merged 1 commit into
mainfrom
fix/updateasync-exclusive-lock
Jul 4, 2026
Merged

fix: serialize UserStore.UpdateAsync under the exclusive stream lock (#70)#74
andregoepel merged 1 commit into
mainfrom
fix/updateasync-exclusive-lock

Conversation

@andregoepel

Copy link
Copy Markdown
Owner

Addresses #70 (safe-hardening scope; see note below).

Problem

UserStore.UpdateAsync read the current user from an unlocked query and then appended a full-state UserUpdated in a separate session. Between that read and the append, a concurrent lockout increment (from a failed login on another request) could commit — and then get silently overwritten by the stale AccessFailedCount this method carries forward. That is the #22 failed-login accumulation race re-entering through the generic update path.

Fix

Take the exclusive stream lock before reading current state and perform the whole read-modify-write in one session — exactly the pattern already used by PersistLockoutStateAsync:

  • session.Events.AppendExclusive(streamId) first,
  • re-read committed state under the lock (LoadAsync<User>),
  • preserve the lockout counter / LockoutEnd and the root-Deletable invariant from that in-lock read,
  • no-op short-circuit still releases the lock without churning the stream.

Concurrent UpdateAsync calls now serialize on the same stream lock as the atomic lockout methods instead of racing.

Tests

  • New integration test UpdateAsync_ConcurrentWithLockoutIncrements_LosesNoIncrements interleaves 15 failed-login increments with 15 concurrent profile updates and asserts every increment accumulated.
  • Existing coverage still green: UpdateAsync_DoesNotRegressConcurrentlyChangedLockoutState, UpdateAsync_RootUser_StaysNonDeletable, UpdateAsync_PersistsChangedSecurityStamp, etc.
  • Full suite: 187 unit + 68 Blazor + 61 integration. Build 0 warnings; CSharpier clean.

Scope note (agreed)

This hardens the lockout race on the generic path and serializes writers. It intentionally does not add field-level optimistic concurrency — i.e. rejecting a stale full-state write that reverts an unrelated concurrent change (such as a 2FA toggle overwritten by a stale profile save). Doing that correctly requires base-version/3-way tracking that conflicts with the deliberate "merge lockout forward" behavior and with ASP.NET Identity flows that call UpdateAsync twice on one object; it's a larger change left as follow-up.

…70)

UpdateAsync read current state from an unlocked query and then appended a
full-state UserUpdated in a separate session. That leaves a window in which a
concurrent lockout increment can land between the read and the append and be
silently overwritten by the stale counter — the #22 accumulation race
re-entering through the generic update path.

Take the exclusive stream lock (AppendExclusive) before reading current state
and do the whole read-modify-write in one session, matching the pattern already
used by PersistLockoutStateAsync. The lockout counter and root-deletable
invariant are now preserved from state read under the lock, and concurrent
updates serialize instead of racing. Adds an integration test interleaving
profile updates with failed-login increments and asserting no increment is lost.

Note: this hardens the lockout race on the generic path and serializes writers;
it does not add field-level optimistic concurrency (rejecting a stale write that
reverts an unrelated concurrent change, e.g. a 2FA toggle). That is a larger,
separate change and remains tracked as follow-up.
@andregoepel
andregoepel merged commit 2c9271e into main Jul 4, 2026
3 checks passed
@andregoepel
andregoepel deleted the fix/updateasync-exclusive-lock branch July 6, 2026 13:44
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