Repository navigation
fix(config): lock a deleted account's plans before disabling them - #1087
Conversation
…s deleted DeleteCloudAccount let the plan_accounts cascade remove an enabled plan's only target, leaving it enabled with zero accounts (the unscoped shape migration 000060 removed). Inside the existing transaction it now locks the account row first, so a writer attaching the account to a plan is waited for, then disables every enabled plan whose only plan_accounts row is this account, before the DELETE. A refused delete rolls the disable back with it. Closes LeanerCloud#264 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng (LeanerCloud#1072) Not pushed: pass-after runs, pgxmock expectations and the lock-wait pattern in TestAccountDeleteWaitsForCommandAccountShare still pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # internal/config/store_postgres.go # internal/config/store_postgres_delete_account_integration_test.go
…rder Completes the WIP lock change for LeanerCloud#1072. lockAccountForDeleteTx takes the deleted account's plans in id order, then the account row, then the plans again, so a plan that gained the account while the delete waited on the account row is locked before the disable decides. Splitting the sequence out keeps DeleteCloudAccount under the gocyclo budget. Adds a lock-gated test for that relock, updates the pgxmock expectations for the plan locks plus a plan-lock failure test, points the editor footprint race test at the plan lock the delete now queues on first, and narrows the disableSoleAccountPlansTx comment to the writers that lock the plan first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 4 billable files and costs up to $1.00. Or wait 59 minutes for your next included review. View limit details
Comment |
|
VERDICT: APPROVE 97015fc Base: merge-base 8caed39, 39 commits behind origin/main 56e226a. Findings
Lock ordering, checked against every lock site in internal/ (non-test):
Local results (macOS, Docker testcontainers, merged tree)
Attacked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE 97015fc Findings:
Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by IAmJSD's agent, at IAmJSD's request, after 2 independent reviews and local verification (see the review and verification comments above, all at |
Closes #1072
Follow-up to #1065. This fixes the BLOCKING finding in the maintainer's review: #1065 (review). #1065 merged at 077fdc2 with that finding still open.
The races
#1065 locked only the deleted account's row before
disableSoleAccountPlansTxdecided which plans to turn off. Under READ COMMITTED that decision could rely onplan_accountsrows that another transaction was about to remove:NOT EXISTS (other account)still sees the other account's committed row, so neither disables the plan. Both cascades then commit, and the plan is left enabled with zero accounts.SetPlanAccountsnarrowing{A, B}to{A}while A is deleted (finding 2). On main this shows up as a deadlock, not a lost update.SetPlanAccountsdeletes every row of the plan and then reinserts(P, A), which needs KEY SHARE on A. The delete already holds A FOR UPDATE and then needs the rowSetPlanAccountsdeleted. Postgres aborts one side (SQLSTATE 40P01).Fix: lock order
DeleteCloudAccountnow calls the newlockAccountForDeleteTxfirst. It takes these locks in order:plan_accountsrow for the account,ORDER BY p.id FOR UPDATE OF p(lockAccountPlansTx).After that come the registration reset, the terminal-execution detach, the disable and the DELETE, all unchanged and all in the same transaction.
plan_accountsagain, sees only its own account, and disables the plan.SetPlanAccountsreads the plan FOR SHARE, which conflicts with FOR UPDATE, so the two run one after the other. The plan is locked before the account, which is the same orderSetPlanAccountsuses (plan, then the account's KEY SHARE when it inserts), so the deadlock is gone. If the delete commits first,SetPlanAccountsfails on the FK. IfSetPlanAccountscommits first, the delete sees{A}and disables the plan.TestDeleteCloudAccount_RelocksPlanThatGainedAccountDuringWaitcovers this.TestAccountDeleteWaitsForCommandAccountSharenow waits on%FOR UPDATE OF p%, because the delete queues on the plan lock first now, behind the footprint's plan FOR UPDATE. Its assertions are unchanged.I chose the plan-row lock over the review's alternative (re-checking after the DELETE) on purpose. With the re-check, two concurrent deletes would each still see the other's uncommitted cascade as committed rows, so neither would disable the plan.
Remaining deadlock window
A plan that gains the account only after step 1 is locked in step 3, after the account row. If a second
SetPlanAccountson that same plan re-adds the account at that moment, it holds the plan FOR SHARE and waits for the account, while the delete holds the account and waits for the plan. Postgres detects this and rolls one side back whole, so nothing is left half-done and the request can be retried. It needs a plan-account write that commits during the delete's account wait, followed by a second one on the same plan.Verification
Unit tests use pgxmock and integration tests use real Postgres through testcontainers. No cloud calls, no deploy. All tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.New tests in
internal/config/store_postgres_delete_account_integration_test.go:TestDeleteCloudAccount_ConcurrentDeletesOfOnePlansAccounts(finding 1): a gate transaction holds the plan'splan_accountsrows FOR UPDATE. Both deletes reach their cascade after deciding, and the test waits for 2 lock waiters before releasing the gate. This forces the race on every run.TestDeleteCloudAccount_ConcurrentSetPlanAccounts(finding 2): 20 iterations of the delete of A racingSetPlanAccounts(P, [A]). Any 40P01, or a plan that ends up enabled with no accounts, fails the test.TestDeleteCloudAccount_RelocksPlanThatGainedAccountDuringWait: lock-gated. A writer moves P from{B}to{A, C}, and A and C are deleted while it is still open. The delete of C is held at its cascade by a gate.TestDeleteCloudAccount_DisablesManagedRampPlanKeepingSchedule(finding 3): adates_managedramp plan is disabled, andramp_schedule,ramp_baseline,ramp_ledger_state,servicesandnext_execution_dateare unchanged.Fail before: origin/main's (8caed39)
store_postgres.gowith the new tests:Pass after: all 13
TestDeleteCloudAccount_*tests,TestAccountDeleteWaitsForCommandAccountShare,TestCountLiveExecutionsForAccountandTestTerminalExecutionStatuses_CoverSchemapass. The relock test passed 3/3 with-count=3.Mutation probes (integration delete tests plus the footprint race test):
ConcurrentSetPlanAccounts(40P01),TestAccountDeleteWaitsForCommandAccountShareRelocksPlanThatGainedAccountDuringWaitFOR UPDATEchanged toFOR SHAREDisablesPlanThatGainedAccountConcurrently,RelocksPlan…ORDER BY p.idremovedORDER BYis there so two deletes that share several plans take the plan locks in the same order. On these small fixtures both scans returnpurchase_plansin heap order anyway, so no deterministic test catches its removal. I kept it as cheap deadlock insurance.pgxmock: every
DeleteCloudAccounttest now expects plan lock, account lock, plan lock. New:TestPGXMock_DeleteCloudAccount_PlanLockFailsRollsBack, which fails the first and then the second plan lock and checks for a rollback.Other checks:
go build ./...;go vet ./internal/config/;go vet -tags integration ./internal/config/go test ./internal/config/ -count=1;go test ./internal/mocks/ -count=1go test -tags integration ./internal/config/ -count=1passes (370s). One earlier run of the full suite failed once inTestRampRetention_FreshEditsRequireContinuousUnheldLineage. That test does not touch account deletes orplan_accounts(it exercisesUpdatePurchasePlanand the ramp ledger trigger). It passed 3/3 on its own, and the next full-suite run was clean. I did not capture that failure's output, so I can't say more about its cause.golangci-lintv2.10.1run --allow-parallel-runners ./internal/config/, with and without--build-tags integration: 0 issuesgocyclo -over 10 internal/config/store_postgres.go: silent. The lock sequence is split intolockAccountForDeleteTxsoDeleteCloudAccountstays under 10.git secrets --scanon all tracked files, in a fresh temp repo with an isolated HOME: clean.scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.Not covered: no deployed-environment or real-cloud check. gosec was not run because it is not installed here; the change only adds parameterized SQL.
Out of scope
These are carried over from #1065 and not changed here:
PUT /api/plans/:id.Commits
289c99ddis the WIP commit of the plan-lock change.656eb4a8merges origin/main; its conflicts were only between the squash-merged #1065 and the branch's own copy of the same code.97015fccadds the relock test, the pgxmock and race-test updates, the helper split and the comment narrowing.Maintainer: please mirror the issue's triage labels on this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/s,type/bug.🤖 Generated with Claude Code