Repository navigation
Conversation
Migration 000053 made purchase_executions.cloud_account_id ON DELETE RESTRICT, so one completed/failed/canceled/expired execution blocked the account delete forever, and the handler answered with a 409 asking the operator to cancel pending purchases that did not exist. DeleteCloudAccount now detaches the account's terminal executions (cloud_account_id = NULL, status in the new TerminalExecutionStatuses allowlist) in its transaction before the DELETE, keeping them as history. Live rows stay attached, so the FK still refuses the delete. The deleteAccount preflight adds CountLiveExecutionsForAccount and answers a 409 with reason "active_executions" for approved, running, scheduled or paused rows, after the existing pending/notified 409. Migration 000127 gives purchase_executions its own updated_at trigger that keeps updated_at on a detach-only UPDATE, so deleting an account does not pull old failures back into the plan health window or restart the retention clock of canceled rows. Closes LeanerCloud#265 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 14 billable files and costs up to $3.50. Or wait 38 minutes for your next included review. View limit details
Comment |
Independent review verdict: CLEAN at 6fd7d9fReviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, focused on data loss and migration safety. The PR body was treated as a claim. Coordinator: claude-cc-reviewer. Blocking findings: none. No execution or audit row is ever deleted. What an account delete does to each referencing table:
Terminal statuses ( Concurrency:
Migration 000127:
Question for the owner (non-blocking): What was attacked and how:
Gate:
|
|
Merge-order note from the #1022 review: this PR adds migration 000127, and #1022 adds 000128. The runner is golang-migrate ( |
Closes #265
Root cause
Migration 000053 made
purchase_executions.cloud_account_idON DELETE RESTRICT, so the FK refuses the delete for every referencing row, whatever its status. ThedeleteAccountpreflight only countspending/notifiedrows. An account with onecompleted,failed,canceledorexpiredexecution passed the preflight, hit the FK, and got a 409 saying "pending purchase(s) must be canceled first" when there was nothing to cancel.CleanupOldExecutionsnever purgesfailedrows, so the account could never be deleted through the API.Reproduced on
origin/main(173474e) against real Postgres:DeleteCloudAccountreturnedSQLSTATE 23503for an account whose only execution was in each of the 7 terminal statuses.Fix
config.TerminalExecutionStatuses(new allowlist):completed,partially_completed,failed,canceled,cancelled,expired,revocation_requested. It is an allowlist on purpose, so a status added to the schema later counts as live and blocks the delete until someone classifies it. A new integration test (TestTerminalExecutionStatuses_CoverSchema) fails if the terminal and live lists stop matching thepurchase_executions_status_checkconstraint exactly.DeleteCloudAccountdetaches the account's terminal executions (SET cloud_account_id = NULL WHERE cloud_account_id = $1 AND status = ANY(terminal)) inside its existing transaction, just before theDELETE. Live rows stay attached, so the RESTRICT FK still refuses the delete while any are left, and the detach rolls back along with it.CountLiveExecutionsForAccountcounts the account's rows withstatus <> ALL(terminal).deleteAccountnow runs its preflight inpreflightAccountDelete:pending/notifiedrows: the existing fix(migrations): enforce cancellation and timeout on the real migration runner #606 409 (reason: pending_executions,pending_count,pending_execution_ids). The Cancel-All-Then-Delete UX is unchanged.approved,running,scheduled,paused): a new 409 withreason: active_executionsandactive_count. Its message says to wait for them to finish, or revoke or delete them, not to cancel them. The frontend shows it through its generic error toast, so no frontend change is needed.Retention semantics
cloud_account_id), amounts and audit stamps are untouched. Only the row-levelcloud_account_idbecomes NULL. That matches whatpurchase_history,savings_snapshotsandri_exchange_historyalready get from theirON DELETE SET NULLFKs, and it is what this FK did before 000053 tightened it for live rows.failedrow can still be sent to retry, but the retry cannot buy anywhere else. Its recommendations still name the deleted account, so the executor fails it before submission (credential resolution failed ... account not found, fix(server): exclude uninitialized replicas from readiness #646) and never falls back to ambient credentials.revocation_requestedcounts as terminal: the system does nothing further with it (the revocation is an AWS support case), and no code moves a row out of it, so blocking on it would bring this bug back for those accounts.Migration 000127
000127_executions_detach_keeps_updated_at: the sharedupdate_updated_at_column()trigger would stampNOW()on every detached row. Several readers treatupdated_atas a lifecycle clock:CountExecutionsByPlanAndStatuswindows the plan health score's failed/canceled counts on it, andCleanupOldExecutionsages canceled rows by it. Deleting an account would then pull its old failures back into each plan's health window and restart retention on its canceled rows. The migration givespurchase_executionsits own trigger function. It keepsOLD.updated_atwhen the only change in an UPDATE iscloud_account_idgoing from a value to NULL, and stampsNOW()on every other UPDATE as before. The whole-row comparison only runs inside the detach branch.update_updated_at_column()) and drops the new function.origin/maintops out at 000126, and no open PR adds a migration at 000120 or above.000127_executions_detach_keeps_updated_at_test.gocovers a detach-only update (kept), a detach plus another column change (stamped), a status change (stamped), re-attaching (stamped), and the down migration (stamps again, function dropped).Verification
All tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. No cloud calls.internal/config/store_postgres_delete_account_integration_test.go:TestDeleteCloudAccount_TerminalExecutionsAreDetached, one subtest per terminal status: the delete succeeds, the row survives with its status,cloud_account_idis NULL andupdated_atis unchanged. Fails onorigin/main(SQLSTATE 23503 for all 7) and passes with this change.TestDeleteCloudAccount_LiveExecutionBlocks, one subtest per live status: the count is 1, the delete fails with 23503, the account survives, and both the live row and a siblingfailedrow stay attached (the transaction rolls back whole).TestDeleteCloudAccount_DetachIsScopedToAccount,TestCountLiveExecutionsForAccount,TestTerminalExecutionStatuses_CoverSchema.TestDeleteAccount_ActiveExecutions_Returns409,_PendingTakesPrecedenceOverActive,_OnlyTerminalExecutions_Deletes,_LiveCountErr_Returns500. Three of these fail against theorigin/mainhandler. The existing fix(migrations): enforce cancellation and timeout on the real migration runner #606 tests still pass unchanged.TerminalExecutionStatuses, plus new tests for a failed detach (rollback) and forCountLiveExecutionsForAccount.= ANY;runningclassified as terminal;expiredleft unclassified; trigger always bumping; trigger skipping the row diff; down migration leaving the function.go build ./...,go vet ./...,go vet -tags integration ./..., and unit tests forinternal/{config,api,mocks,server,analytics}andinternal/database/postgres/migrations(which includesTestMigrationsAreTransactional).go test -tags integration ./internal/config/and./internal/database/postgres/migrations/. In the migrations run, one test hit a testcontainers start-up error once (port "5432/tcp" not found) while other jobs were running at the same time; it passed when re-run on its own.--allow-parallel-runners, with and without--build-tags integration): 0 issues. gocyclo -over 10 on the changed non-test files: none. git-secrets--scanon the changed files in a temp clone with an isolated HOME: clean.scripts/test-git-secrets-allowlist.sh: 42 PASS.Out of scope / interactions
plan_accountsrows still cascade, and plans keep their current enabled state. One interaction to know about: the ramp-step unit CTE (store_postgres_ramp_step.go) treats rows with no account as root rows. While any account row is left on a step it ignores them, so detaching changes nothing there. If every account on a fanned-out plan is deleted (the fix(config): deleting a cloud account leaves its scoped plans enabled with zero accounts #264 state), the detached children join the step's root row in its NULL partition.ever_boughtis unchanged (the root already aggregates its children), and because 000127 keepsupdated_at, the representative row is picked on the original write times.cloud_account_idis still inrecommendations, but nothing resolves a deleted account's name.active_executions409 shows through the existing generic error toast.🤖 Generated with Claude Code