Repository navigation
fix(config): cancel a deleted plan's open step rows in the same transaction - #1046
Merged
Merged
Conversation
…action purchase_executions.plan_id is ON DELETE SET NULL, and token approval applies the plan's max_step_commitment and the approve-own plan-step rule only while plan_id is set. A pending, notified or paused step row left open when its plan was deleted became a plan-less row that a token approver with no approve verb could approve past the plan's cap. DeletePurchasePlan now cancels those rows (and releases their suppressions) inside its transaction, before the plan row goes, so no committed state ever has an approvable row orphaned from its plan. Scheduled, approved, running and settled rows are left as they were. Closes LeanerCloud#862 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Warning Review limit reached
This review includes 3 billable files and costs up to $0.75. Or wait 54 minutes for your next included review. View limit details
Comment |
cristim
reviewed
Oct 17, 2026
cristim
left a comment
Member
There was a problem hiding this comment.
Independent review of 1c2a980: draft: blocks ready
No code defects found. The remaining blockers are gate items only (CI not finished, one test I could not run here).
Findings
- None BLOCKING in code.
- NON-BLOCKING (already acknowledged in the PR body): open rows orphaned before this change still have plan_id NULL and stay approvable on the uncapped path;
scheduledrows of a deleted plan still fire. Both need a product call or a migration, so a follow-up issue is the right place. Worth filing before merge so it is tracked.
What I attacked
- Atomicity:
DeletePurchasePlan(internal/config/store_postgres.go:869-891) runs lock, managed guard, cancel, delete inside oneWithTx. A cancel error returns before the DELETE and rolls back; the new pgxmock testCancelFailureKeepsPlancovers it. The cancel and the FKSET NULLland in the same commit, so no committed state has an open plan-less row from a deletion. - Race: the plan row is locked FOR UPDATE (
LockPurchasePlanTx). An approval that wins the status CAS first leaves anapprovedrow that already passed the cap check; one that loses seescanceled. The same plan lock covers thecreatePlanrollback caller (internal/api/handler_plans.go:202). - "Open" statuses: I listed every status allowed by
purchase_executions_status_checkon a fully migrated scratch Postgres 17.pending,notified,pausedare the pre-approval states.scheduled,approved,runningand settled states are correctly left alone.revocation_requestedis only reachable fromcompleted(handler_purchases.go:1897), so it is not approvable.canceledis written with the canonical spelling and the CHECK accepts it. - Scoping: the UPDATE is keyed on
plan_id = $1, a global UUID primary key. Tenant access is enforced before the call (requirePlanMutationAccess, handler_plans.go:430). No cross-plan or cross-account rows can be touched. Suppression delete is limited to the canceled execution ids. - SQL run for real: I applied all up migrations to a scratch Postgres 17 and ran the exact CTE plus the plan delete in a rolled-back transaction with 3 open rows, a scheduled row, a revocation_requested row, a second plan's pending row and two suppressions. Result: the 3 open rows became
canceledwithplan_idNULL, the others kept their status, the second plan's row was untouched, only the scheduled row's suppression remained. - Other stores: the only
config.StoreInterfaceimplementations arePostgresStoreandmocks.MockConfigStore(a recorder with no behavior to diverge). No other store needs the change. - Ramp ledger trigger (migration 000124):
ramp_ledger_holdstreats canceled as not holding, which is consistent with a plan being deleted. - Mutation probe (pgxmock): removed
pausedfrom the status list;TestPGXMock_DeletePurchasePlan_Successfailed on the SQL regexp. Reverted. - Tests at this SHA:
go test ./internal/config/ ./internal/mocks/864 passed;go vet -tags=integration ./internal/config/clean.
Gate status (origin/main Review gate)
- Independent review of the full diff: this comment, at 1c2a980.
- Findings resolved: none open. CodeRabbit skipped (draft); no unresolved threads.
- CI on the SHA: not green yet.
pre-commitsucceeded;CI - Build & Testwas queued at the time of review (fork run, may need approval). This is a gate gap. - Local verification: the testcontainers integration test
TestDeletePurchasePlan_CancelsOpenStepRowscould not run in my environment (no Docker daemon, it skips). I replaced it with the manual Postgres 17 SQL run above, which exercises the same statement and data shape. I did not independently re-run the author's fail-before/pass-after on the integration test; CI's Integration Tests job on this SHA should confirm it. - Labels: mirror #862 (priority/p2, severity/medium, triaged, urgency/this-quarter, impact/few, effort/s, type/bug). Present on the PR.
- Size: 27 additions in production code, within the cap; 125 additions total.
Blocks ready
- CI
CI - Build & Test(including Integration Tests) green on 1c2a980. - Mark the PR ready for review.
- Optional: file the follow-up issue for pre-existing orphaned rows and scheduled rows.
Collaborator
Author
|
Filed #1048 for the two leftover cases (rows orphaned before this change, and |
Member
|
Gate record for 1c2a980 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at 1c2a980 with --match-head-commit. |
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.
Root cause
purchase_executions.plan_idisON DELETE SET NULL(migration 000033). Token approval applies the plan'smax_step_commitment(requirePlanStepCap) and the approve-own plan-step rule (enforceHeldApproveConstraints) only whileexecution.PlanID != "".DeletePurchasePlandeleted the plan row and left its open step rows (pending/notified/paused) in place. Postgres nulled theirplan_id, so they stayed approvable as plan-less rows. A token approver with no approve verb (for example the account's contact_email) could then approve them with no cap check, and an approve-own holder's Constraints stopped applying.Fix
DeletePurchasePlan(internal/config/store_postgres.go) now calls a newcancelOpenPlanExecutionsTxinside its existing transaction, after the managed-plan guard and before theDELETE FROM purchase_plans. One statement flips the plan'spending,notifiedandpausedrows tocanceledand deletes theirpurchase_suppressions, the same way the other cancel paths release suppressions. The cancel and theSET NULLcommit together, so no committed state has an approvable row that has lost its plan. An approval racing the delete either reads the plan before it goes (the cap check runs) or loses the status CAS on acanceledrow.This follows the first option in the issue ("cancel or expire pending plan-step rows when their plan is deleted"). I didn't use the second option (mark the row with a column that survives deletion):
step_numberdefaults to 1 for ad-hoc rows too, so no existing column can tell a plan-step row from an ad-hoc one. That option would need a new column and a migration. This fix needs no migration.Left unchanged on purpose:
scheduledrows (already approved under the cap during the delayed-approval window),approved/runningrows that are in flight, and settled rows (completed,failed, etc.). Managed plans are unaffected becauserequireManagedDeleteTxalready refuses to delete them while open rows exist.Verification
All evidence is local unit, pgxmock or testcontainers Postgres. No real cloud account, purchase, email or deploy was involved. Commands ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid.Regression test (real Postgres via testcontainers):
internal/config/store_postgres_plan_delete_integration_test.go,TestDeletePurchasePlan_CancelsOpenStepRows. It builds a plan withpending,notifiedandpausedrows (the open rows) andscheduled,approved,completedandfailedrows (the others), a suppression on the pending row and on the scheduled row, and a pending row on a second plan. Then it deletes the first plan. It asserts:canceledand detached;ErrExecutionNotInExpectedStatus;expected: "canceled" actual: "pending" ... pending row of the deleted plan stays approvableMutation probes (each one applied alone, then the test rerun; all were caught):
plan_idscope (plan_id = $1 OR TRUE): FAIL, "another plan's row is untouched"pausedout of the status list: FAIL, "paused row of the deleted plan stays approvable"scheduledto the status list: FAIL, "scheduled row is not an open step row and keeps its status"WHERE FALSE AND ...): FAIL, "the canceled row's suppression is released"Unit (pgxmock):
TestPGXMock_DeletePurchasePlan_Successnow pins the cancel statement (plan-scoped, the three statuses, the suppression delete, argsplan-id,canceled), which runs before the plan delete. The newTestPGXMock_DeletePurchasePlan_CancelFailureKeepsPlanchecks that a failed cancel rolls back and never issues the plan delete. Both fail on the code before this PR and pass with it.Other checks:
go build ./...: OKgo vet ./...andgo vet -tags=integration ./internal/config/: OKgo test -count=1 ./internal/config/: okgo test -count=1 ./internal/api/: okgo test -count=1 ./internal/mocks/: okgo test -tags=integration -count=1 -timeout=20m ./internal/config/(the whole integration suite, including the existingDeletePurchasePlancallers instore_postgres_test.go,store_postgres_db_test.goandstore_postgres_retention_integration_test.go): ok (285s)./internal/config/..., with and without--build-tags=integration: 0 issuesgocyclo -over 10 internal/config/store_postgres.go: silentscripts/gosec-hook.shon the changed files: exit 0scripts/test-git-secrets-allowlist.shgave 42 PASS, 0 FAIL.git secrets --scan --cachedover every tracked file plus the new test file, in a freshgit initrepo with an isolated HOME, exited 0.Coverage gaps: this was not run against a deployed environment, and the email-link approval was not driven end to end against a real database. The handler-side behavior follows from the store change, because approval's status CAS only accepts
pending/notified, and run-now's only acceptspending/notified/paused.Out of scope (not fixed here)
plan_id IS NULLand can still be approved through the same uncapped path. A backfill would need to tell those rows apart from genuine ad-hoc rows, and no current column does that reliably (step_numberdefaults to 1, andcreated_by_user_idis not guaranteed on every ad-hoc writer). If a maintainer wants one, it belongs in a follow-up with a migration (next free number after the open fix(config): let an account with only terminal executions be deleted #1020/000127 and fix(scheduler): record partial multi-subscription sweeps in collection state #1022/000128 would be 000129).scheduledrows of a deleted plan still fire at their scheduled time. They were approved while the plan existed, so the cap was already applied, but whether deleting a plan should also revoke them is a product question.Labels
A maintainer, please mirror #862's triage labels onto this PR:
priority/p2,severity/medium,triaged,urgency/this-quarter,impact/few,effort/s,type/bug.Closes #862
🤖 Generated with Claude Code