Repository navigation
fix(plans): lock the plan before completing a target-met step - #909
Conversation
|
Warning Review limit reached
This review includes 3 billable files and costs up to $0.75.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 38 minutes for your next included review. View limit details
Comment |
|
@coderabbitai review |
|
a808bb4 to
ae15eeb
Compare
204d52e to
ac521dd
Compare
cristim
left a comment
There was a problem hiding this comment.
Independent review of ac521dd: no actionable findings
Diff reviewed: git diff ae15eebabc1cc2c7e7b34cc78248293b97370eca...ac521ddb (3 files, +128/-9). The base tip I used is the current head of #908, so the stack is consistent.
Findings
- Nit, non-blocking: no test exercises two real transactions racing. The test file proves refusal before write inside one tx. I wrote a scratch concurrency test (not committed): a purchase-side tx holds the root row via
SaveExecutionIfStatusTxand moves it torunninguncommitted; target-met in a second tx blocks on the row lock (store_postgres_plan_schedule_clock.go:59,FOR UPDATE), then on commit refuses withErrExecutionNotInExpectedStatus, leaves the rootrunningand the ramp at step 1. No hang. Passes on real Postgres. - Nit: the
foreignmanaged subcase passes even with the managed guard deleted, because the newplan_idCAS also catches it. Redundant defense, not a defect.
Lock ordering and scope
- Only production caller is
plan_step_attach.go:246(targetMet), which runs inside a claim that already holds the plan lock. The newLockPurchasePlanTxthere is a same-transaction re-lock (no-op), and it still re-reads after the caller's baseline write, so the optimistic-lock check inUpdatePurchasePlanTxis unaffected. - Order is now plan, then execution rows (
ResolveManagedPlanRootTxFOR UPDATEordered byexecution_id), then the CAS. The old method wrote the execution before locking the plan, i.e. execution then plan, the reverse of the claim path and ofCompletePlanStep/UpdatePlanTx(plan only). The PR removes that inversion. Other execution-first lockers (lockExecutionRow,store_postgres_execution_guarded.go:28) take no plan lock in the same tx, so I found no cycle. - Lock duration is the caller's transaction, unchanged. Added work is one extra
SELECT ... FOR UPDATEon the step's rows, scoped to one plan and one step. - Can target-met complete a step a purchase is executing? Not in managed mode: the root must be the exact resolved root, and the CAS needs
pending/notifiedwith empty recommendations; a root with recs or any other status is refused (test casesoccupied,scheduled, plus the concurrency probe above). LegacyrootID==""still inserts a new row without looking at existing step rows; that is pre-existing and out of scope. - Behavior difference from the plan file: the guard does not compare
stepNumber == CurrentStep+1explicitly; thestep_number = $7CAS gives the same refusal (caseswrong step,other step root).
What I attacked
- Ran
go test -tags integration -run 'TestManagedTargetMetRefusesBeforeWrite|TestManagedCompletionKeepsNextRootAndRawPlan' ./internal/config/at this SHA (Docker Postgres): pass.go build ./...,go vet -tags integration,go test ./internal/config ./internal/purchase: pass. - Fail-before: restored the two production files from #908 (ae15eeb), kept the new test: 9 of 11 subcases fail (missing, foreign, finished, wrong step, duplicate, legacy foreign, legacy other step, beside root, other step root);
occupiedandscheduledpass on the parent because the old CAS already refused them. Pass-after: all 11 pass. - Mutation probes (reverted): managed guard forced off fails
missing,duplicate,beside root; removingplan_id/step_numberfrom the CAS failslegacy foreignandlegacy other step. - Concurrency probe described in finding 1.
- golangci-lint crashed on this machine (tool panic), so lint is not verified locally.
Gate status
- Independent review: this comment, names the SHA.
- Findings resolved: none actionable.
- CI on SHA: not applicable until retarget (CodeRabbit reports "reviews disabled for this base branch").
- Local real-path verification: native Postgres run above, fixture-backed, not a real-account scenario. The PR body records its own run; this verdict and my evidence are recorded here.
- Labels: priority/p1, severity/high, urgency/this-sprint, impact/many, effort/xl, type/feat, triaged present; the PR has no closing issue (Refs #631), so no mirror check is possible beyond that.
ae15eeb to
06a3368
Compare
ac521dd to
90ae138
Compare
|
Rebased onto the rebased #908 (06a3368). Pure rebase, Empty range-diff, so the verdict on ac521dd carries to 90ae138. Retarget to main after #908 merges. |
06a3368 to
67eed57
Compare
90ae138 to
8e0707b
Compare
|
Rebased onto the rebased #908 (67eed57). Pure rebase, Empty range-diff, so the verdict on ac521dd carries to 8e0707b. Retarget to main after #908 merges. |
CompleteTargetMetStepTx wrote the root execution before locking its plan, and its CAS matched only execution id, status and empty recommendations, so a direct call could complete another plan's root or materialize an absent managed root. Lock the plan first, refuse a managed call whose root is missing or not the exact resolved root, and filter the final CAS by plan_id and step_number in both modes. Refs #631 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
managedClockFixture no longer takes a total parameter (every caller set it to 3, unparam). Update the target-met test caller to match. Refs #631 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8e0707b to
c37a90b
Compare
|
Rebased onto main after #908 merged (squash 4f97e95); retargeted to main. Pure rebase, Empty range-diff, so the independent verdict on ac521dd carries to c37a90b. Gate needs CI green on c37a90b. |
|
Gate record for c37a90b (origin/main CLAUDE.md "Review gate"):
Merging at c37a90b with --match-head-commit. This completes the #631 M2 stack (#906-#909). |
What
CompleteTargetMetStepTxnow locks the plan before writing the root execution, refuses a managed call whose root is missing or not the exact resolved root, and filters the final CAS byplan_idandstep_numberin both modes. Includes a fixture call fix (drops thetotalarg from the target-met clock fixture).Why
M2 managed-ramp chain for #631: the old order wrote the root before locking, and its CAS matched only execution id, status and empty recommendations, so a direct call could complete another plan's root or materialize an absent managed root.
Local verification
go build ./...andgo test -race ./internal/config/... ./internal/purchase/...on this tip (macOS).Stack (M2 chain, merge in order)
Refs #631
🤖 Generated with Claude Code