Repository navigation
Conversation
…activate and delete UpdateUser, UpdateUserAdminFields and DeleteUser now run in a transaction whose first statement takes adminInvariantAdvisoryLockKey, the same point CreateAdminIfNone takes it, so every write that can lower the active-admin count orders the advisory lock before the users row lock. Before, these writes locked the row first and only reached the lock in the deferred min-one-admin trigger at commit. The trigger already kept the floor, so this is defense in depth: a mix of the two orders would deadlock, and the new real-Postgres deadlock test catches a partial conversion (40P01 in round 0). Closes LeanerCloud#63 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
Member
|
Independent review 2 (claude-fleet, cold): APPROVE cad1a4b Findings (none blocking):
Completeness: every
Error mapping:
Attacked:
Verified locally, with no cloud credentials, under the heavy lock, using the repo's testcontainers Postgres:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
This branch has not been deployed
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
Every store write that can lower the active-admin count now takes
adminInvariantAdvisoryLockKeyas the first statement of its transaction. That is the same pointCreateAdminIfNonetakes it: before anyusersrow lock.Main was already safe. The deferred
check_min_one_admin()trigger (migration 000065) already serializes the final count, and the new race tests pass on unmodified main. This PR is defense in depth and makes the lock order consistent, as the issue asks. The one test that fails before the fix is the lock-order test.Root cause
BEGIN → pg_advisory_xact_lock(key) → INSERT → COMMITUPDATE/DELETE (row lock) → COMMIT → trigger takes pg_advisory_xact_lock(key)So admin-demoting writes held the row lock before the advisory lock, which is the opposite order from the bootstrap. Today nothing deadlocks. The bootstrap only inserts and never waits on an existing admin row, and every demoting path uses the same row→lock order. But a fix that converted only some paths would deadlock. The mutation probe below shows this (40P01 in round 0).
Fix
PostgresStore.withAdminInvariantLock(ctx, op, fn):BEGIN → pg_advisory_xact_lock(adminInvariantAdvisoryLockKey) → fn(tx) → COMMIT, with a deferred rollback. It reuses the existing constant. The trigger takes the key again at commit, which is a no-op re-acquire.UpdateUserAdminFields: the fix(auth): write only the admin-editable columns from UpdateUser #581 narrow replacement, used byService.UpdateUserfor both deactivate and group removal.UpdateUser: the whole-row update. It has no production caller left, but it is still onStoreInterfaceand can flipactive/group_ids.DeleteUser.adminInvariantAdvisoryLockKeynow states the convention: any transaction that may demote, deactivate or delete an Administrators-group member, or create the first admin, must take this lock as its first statement. It also explains the deadlock rule.COMMIT, wrapped with%w.isLastAdminConstraintViolationstill finds thePgError, and a unit test pins this, so the service still maps it toErrLastAdmin/ 409.Paths checked and deliberately not locked
These never take admin standing away, so the trigger never fires and they only ever hold the row lock. They cannot complete a cycle with a lock holder:
RecordSuccessfulLoginandRecordFailedLogin.SetPendingMFASecret,EnableMFA,DisableMFA,ReplaceMFARecoveryCodes,ConsumeMFARecoveryCode,ClaimTOTPCounter,ClearPendingMFASecret).UpdateUserCredentials,SetPasswordResetTokenandConsumePasswordResetToken.CompletePasswordReset. Itsactive = $7is alwaystruefrom its only caller (ConfirmPasswordResetactivates invites and returns early for deactivated accounts).CreateUser. A plain INSERT is not watched by the trigger.DeleteGroup. It refuses system-managed groups, Administrators included, and does not touchusers.group_ids.migrate.go. It only adds the group.Verification
Run locally on macOS. Integration tests use a real PostgreSQL 16 testcontainer. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. No cloud APIs are involved.New real-Postgres tests (
internal/auth/store_postgres_admin_lock_db_test.go,integrationtag):TestIntegration_AdminDemotingWrites_LockBeforeRowLockUpdateUserAdminFieldsdeactivate,UpdateUserAdminFieldsdrop the group,UpdateUserdeactivate,DeleteUser), a test tx holds the advisory lock the way a bootstrap would. The write must queue on that lock (checked inpg_locks) before it locks the row, so the holder'sSELECT … FOR UPDATE NOWAITon the target row succeedscould not obtain lock on row in relation "users" (SQLSTATE 55P03)TestIntegration_ConcurrentDemotionOfLastTwoAdminsService, on the last two admins, 15 barrier-synced rounds per pair. Exactly one succeeds, the other getsErrLastAdmin, and exactly one active admin remainsTestIntegration_ConcurrentBootstrapVersusDemotionCreateAdminIfNoneracing each demotion, with 1 or 2 existing admins, 15 rounds each. The bootstrap never inserts. The demotion getsErrLastAdminwith 1 admin and succeeds with 2. Exactly one active admin remainsTestIntegration_AdminWrites_NoDeadlocklock_timeout=10s. The test asserts no40P01and no55P03, only expected outcomes (trigger refusal,ErrUserChanged, user not found), and ≥1 active admin after every roundMutation probe (partial fix: the lock removed from
DeleteUseronly, so it uses the old row→lock order while the other paths use lock→row):TestIntegration_AdminWrites_NoDeadlockfails in round 0 withfailed to update user: ERROR: deadlock detected (SQLSTATE 40P01). The lock-order test fails on itsDeleteUsersubtest. The fix has to cover all paths, and the tests catch one that is missed.New pgxmock unit tests (
internal/auth/store_postgres_admin_lock_test.go) replace the three testifyMockDBConnectiontests forUpdateUser/DeleteUser, which mockeddb.Execdirectly and cannot express a transaction. For each of the 3 methods they check:BEGIN→ lock with the exact key → the write →COMMIT, in that order.user not foundorErrUserChanged.PgErrorraised atCOMMITstill satisfiesisLastAdminConstraintViolation.UpdateUserAdminFieldsstill returns the storedupdated_at.All 7 fail against main's store code and pass with this PR. Unit/mock evidence.
Commands run:
Behavior notes / out of scope
UpdateUserAdminFields, any target) and user deletes now serialize on one global advisory lock for the length of a single-row write. These are rare admin actions. Login, MFA and password writes are not affected.checkLastAdminConstraint/CountGroupMembers) still runs before and outside the lock, so it can still miss a concurrent demotion. The deferred trigger remains the hard backstop and maps to the sameErrLastAdmin. Moving the soft check under the lock would mean restructuring the service, and the issue does not ask for that.CreateAdminIfNonekeeps its own inline begin/lock/commit, which is already in the right order. I left it unchanged to keep its existing error messages and tests.internal/auth/store_postgres.go, in different functions (sessions). Expect at most a trivial merge.Maintainer: please mirror the issue's triage labels on this PR:
impact/internal,type/chore,triaged,priority/p3,severity/low,urgency/eventually,effort/s.Closes #63
🤖 Generated with Claude Code