Repository navigation
fix(api): "Run now" flips a purchase to running but nothing executes it - #370
Conversation
runPlannedPurchase only CASed a pending/paused execution to "running" and reported "Purchase execution initiated". No component consumes "running" rows: the scheduler only picks up pending/notified, and purchase.Reaper flips anything stuck in running for 10 minutes to failed. The operator was told the purchase ran, nothing was bought, and the row read "failed" ten minutes later. Extract ApproveAndExecute's 4-eyes-gated, CAS-then-execute body into transitionApproveAndExecute, parameterized on the source statuses, and add purchase.Manager.RunPlannedPurchaseNow (CAS from pending/paused, same as before) that runs the purchase synchronously through the same funnel ApproveAndExecute and the direct-execute path already use. runPlannedPurchase now delegates to it instead of doing a bare status flip. This makes "Run now" idempotent by construction: a second click, or the scheduler's claimAndExecute racing the same pending row, loses the same atomic UPDATE ... WHERE status IN (...) CAS the approve path already relies on, so only one caller ever executes the purchase. It also closes a real gap: forcing an execution previously bypassed the 4-eyes dual-control check entirely, unlike every other execute path. Closes #218 Co-Authored-By: claude-flow <ruv@ruv.net>
|
Warning Review limit reached
This review includes 9 billable files and costs up to $2.25.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 13 minutes for your next included review. View limit detailsLimit details: You’ve used all 5 included reviews currently available. Your 20 included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour. Review configuration: ⚙️ Run configurationConfiguration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml Review profile: CHILL Plan: Essentials Run ID: 📒 Files selected for processing (9)
Comment |
|
Independent adversarial review + local verification: MERGE at b00e31d. Run now now CASes pending|paused -> approved and executes synchronously through the same funnel as approve (4-eyes, per-account idempotency keys, finalizeExecution, CompletePlanStep). Double-click and a concurrent scheduler tick are resolved by the single conditional UPDATE (loser gets 409); a crash leaves approved, which the reaper sweeps. 4-eyes now also applies to Run now (disclosed). Pre-fix tests fail (handler calls the wrong method; RunPlannedPurchaseNow undefined) and pass on head; -race OK for api/purchase/server; 24/24 checks. Follow-ups filed: error-to-status mapping, audit stamping. |
Summary
"Run now" on a planned purchase transitioned the row from pending/paused to running and returned success, but nothing executes a "running" row: the scheduler only picks up pending/notified, and
purchase.Reaperflips anything stuck in running for 10 minutes to failed. The operator was told the purchase ran, nothing was bought, and the row read "failed" ten minutes later.Root cause
internal/api/handler_purchases.go'srunPlannedPurchasedid a bareTransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "running", ...)and returned"status": "running"without ever calling an executor.claimAndExecute(the scheduler/SQS funnel) claims fromapproved/pending/notified, not from a row already sitting inrunning.ApproveAndExecute(the approve funnel) transitionspending/notifiedtoapprovedand then runs the purchase synchronously, but "Run now" needs to also accept apausedsource, whichApproveAndExecute's CAS does not.Fix
internal/purchase/approvals.go: extractedApproveAndExecute's 4-eyes-gated, CAS-then-execute body intotransitionApproveAndExecute(ctx, executionID, actor, transitionedBy, fromStatuses).ApproveAndExecutenow calls it with["pending", "notified"]; a newRunPlannedPurchaseNowcalls it with["pending", "paused"](the same source setrunPlannedPurchaseused before), and both go throughexecuteAndFinalizesynchronously.internal/api/handler_purchases.go:runPlannedPurchasenow delegates toh.purchase.RunPlannedPurchaseNow, mirroring howdirectExecutePurchasealready delegates toApproveAndExecute. On success the response reports"status": "completed"(the row's actual terminal status) instead of the transient "running" that nothing ever consumed.RunPlannedPurchaseNow:internal/api/types.go(PurchaseManagerInterface),internal/server/interfaces.go(server's own copy of the same interface name), plus the two hand-written mocks (internal/api/mocks_test.go,internal/testutil/mocks.go).Idempotency (issue's explicit ask)
pending/paused -> approved) affects zero rows once the first click's transaction commits, and returns a clean "cannot transition" error -- no second execution.claimAndExecute(approved/pending/notified -> running) andRunPlannedPurchaseNow(pending/paused -> approved). These are different target statuses on the same row, but Postgres row-level locking still serializes the twoUPDATE ... WHERE status IN (...)statements: whichever commits first changes the status, and the second statement'sWHEREre-evaluates against that committed value and matches zero rows. Only one caller ever executes the purchase.enforceFourEyesPolicycheck.Regression test
internal/purchase/approvals_test.go:TestManager_RunPlannedPurchaseNow_ExecutesFromPaused: drivesRunPlannedPurchaseNowfrom apausedsource (a statusApproveAndExecuteitself does not accept) and asserts the fullexecuteAndFinalizechain runs (plan lookup, confirmation email,SavePurchaseExecution,CompletePlanStep) -- proving the purchase is actually executed, not left stuck in "running".TestManager_RunPlannedPurchaseNow_LostCASReturnsError: a lost CAS surfaces as an error with no execution-chain calls, the idempotency half of the fix.internal/api/handler_purchases_test.go/handler_test.go: updatedTestHandler_runPlannedPurchase,TestHandler_runPlannedPurchase_NilExecution, and the router-levelTestHandler_HandleRequest_RunPlannedPurchaseto assert the handler delegates toRunPlannedPurchaseNowinstead of a bare status transition.Confirmed the new purchase-package tests fail pre-fix:
git stashoninternal/purchase/approvals.go(and the interface/mock plumbing) reproduces a build failure,manager.RunPlannedPurchaseNow undefined.Verification
GOWORK=off go build ./...— passGOWORK=off go vet ./...— passGOWORK=off go test ./... -short— 6132 tests passGOWORK=off go test ./internal/api/... ./internal/purchase/... -race -short— 2528 tests passGOWORK=off go mod tidy -diff— emptygofmt -lon changed files — cleangolangci-lintat the CI-pinned v2.10.1: reproduces the same pre-existing local toolchain issue noted on PR fix(frontend): RI-exchange execute never sends region, so the backend returns 400 #355/fix(api): PUT /api/plans/{id} resets the ramp schedule and re-buys completed steps #360 for this checkout, unrelated to this diff; relying on CI's clean go1.26.6 environment for the authoritative lint run.Closes #218