Repository navigation
fix(purchase): let stranded-approval recovery run before the reaper fails approved rows - #1042
Conversation
…ails approved rows The stuck-purchase reaper listed approved and running rows with one threshold (PURCHASE_APPROVED_REAP_AFTER, default 10m), while RecoverStrandedApprovals only sees approved rows older than 15m and runs once per process sweep (hourly by default). With both scheduled, the reaper always failed a stranded approval before the idempotent re-drive could see it. The reaper now lists running rows at reapAfter and approved rows at max(reapAfter, minApprovedReapAfter), where minApprovedReapAfter is staleApprovedThreshold + the default process sweep interval + 5m slack (1h20m). Running rows keep the configured threshold since recovery never re-drives them. Tests pin the ordering against staleApprovedThreshold. Closes LeanerCloud#894 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 7
✨ Finishing Touches
Comment |
There was a problem hiding this comment.
Independent review of bf2a604: draft: blocks ready
No blocking code finding. One non-blocking race worth a follow-up, plus gate items still open (CI pending, PR is a draft).
Findings
- NON-BLOCKING, internal/purchase/reaper.go:225 (reapOne CAS):
TransitionExecutionStatus(..., stuckStatuses, failed, nil)still acceptsapproved|runningfor rows from either pass. Scenario: the approved pass lists a row older than 1h20m, then a late process sweep claims it (claimAndRedrive, manager.go:569, approved -> running with fresh updated_at) and starts re-driving; the reaper's CAS then matchesrunningand fails a row that is mid-purchase. Provider idempotency tokens (#639) should stop a double buy, but the row would readfailedwhile the commitment may exist. This CAS widening predates the PR, but the PR makes the overlap more likely, since a delayed sweep is now what reaches rows near the 1h20m boundary. Fix: passpass.statusesto the CAS so an approved-pass row only flips fromapproved. I did not reproduce the final row state; I did not trace whatsaveOrRecoverTerminaldoes when the row is alreadyfailed. - NON-BLOCKING, reaper.go:103:
processSweepIntervalis a Go constant tied to the terraform defaultrate(1 hour). A slower operator schedule can still lose approved rows to the reaper. The PR body already states this; it is a follow-up (pass the cadence or an explicit approved threshold via env).
What I attacked
- Double execution: the reaper only ever writes
failed; it never calls the executor. Delaying it cannot cause a second submission. Recovery (manager.go:706) and the reaper use the same column (updated_at, store_postgres.go:1633 and :1669), so the 15m/1h20m ordering compares like with like. Worst-case first recoverable age is 15m + 1h = 1h15m; the floor is 1h20m, leaving the 5m slack. - Lost purchases: approved rows are still reaped, just later (never earlier); the floor is
max(reapAfter, 1h20m), so a larger operator value is unchanged. - Running rows: the running pass uses
reapAfterunchanged (reaper.go reapPasses). Mutation probe: running pass switched to the approved threshold ->ApprovedRowsWaitForStrandedRecovery,ShortReapAfterDoesNotBeatRecovery,ApprovedAnnotationUsesApprovedThresholdfail (reverted). - Mutation probe:
approvedReapAfterreturnsreapAfter->ApprovedRowsWaitForStrandedRecoveryandShortReapAfterDoesNotBeatRecoveryfail (reverted). Worktree clean afterwards. - Terraform: diff of the 2 files is description strings only (variables.tf x2); no defaults, types or resources changed.
- Error handling: a list error in either pass returns the wrapped error; rows reaped in the first pass stay reaped and counted in no returned result (result is nil on error), same as before for a single list failure. Acceptable.
- Scope: 82 additions in reaper.go, +123 test file, comments in manager.go/handler.go; files under 500 lines. Small enough for the 400-line additions cap.
- Tests at this SHA:
go test ./internal/purchase/ ./internal/server/ -count=1-> ok, ok (with isolated AWS config env). I did not re-run fail-before against the parent code; the PR body's fail-before output was not independently reproduced beyond the two mutation probes above. - Labels: PR carries priority/p2, triaged, severity/high, urgency/this-quarter, impact/few, effort/s, type/bug; identical to #894.
Gate status (origin/main Review gate)
- Independent adversarial review naming the SHA: this comment.
- Findings resolved: none blocking; finding 1 is a follow-up or a one-line fix at the author's choice.
- CI on the SHA: not green yet. At review time Lint Code passed; Unit Tests, Integration Tests, E2E, pre-commit, Validate Terraform and the rest were pending.
- CodeRabbit: skipped (draft); it is optional given this review plus local verification.
- Local verification: unit/mock evidence only (MockConfigStore with recorded ListStuckExecutions arguments). No real database or EventBridge run showing a stranded approval re-driven instead of reaped. Labeled as such by the author; I agree it is a remaining real-account gap.
- Verdict, SHA and evidence recorded on the PR: yes, here.
Blocks ready
- CI green on bf2a604.
- PR taken out of draft by the author.
- Decide finding 1 (narrow the CAS now, or file the follow-up); any new commit restarts the gate.
|
Merge-driver triage of the independent review at bf2a604: no blocking findings. Finding 1 (reaper CAS accepts approved|running regardless of the listing pass) predates this PR and is tracked as follow-up #1043. Finding 2 (hardcoded 1h sweep interval) is already noted in the PR. Once CI is green on this SHA I will mark the PR ready (owner-authorized) and merge under the gate; any new commit restarts it. |
|
Gate record for bf2a604 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at bf2a604 with --match-head-commit. |
Root cause
The stuck-purchase reaper (
internal/purchase/reaper.go) listedapprovedandrunningrows in one SELECT, both atreapAfter(PURCHASE_APPROVED_REAP_AFTER, default 10m).RecoverStrandedApprovals(run fromProcessScheduledPurchases) only seesapprovedrows older thanstaleApprovedThreshold(15m), and it only runs once per process sweep (process_scheduled_purchases_schedule, defaultrate(1 hour)). So when both sweeps were scheduled, the reaper failed a stranded approval at about 10m, and the idempotent re-drive (#639) never got to see it. The user got a failure instead of the purchase they approved.Fix
ReapStuckExecutionsnow runs two passes:runningrows atreapAfter, as before. Recovery never re-drivesrunningrows, so the reaper is still their only backstop.approvedrows atapprovedReapAfter(reapAfter) = max(reapAfter, minApprovedReapAfter).minApprovedReapAfter = staleApprovedThreshold + processSweepInterval (1h) + processSweepSlack (5m), which is 1h20m. It is derived fromstaleApprovedThreshold, so moving the recovery threshold moves the reaper floor with it. An operator-configuredreapAfterabove the floor is used unchanged. The floor can only delay the approved pass, never speed it up.manager.goandhandler.go. The terraform variable descriptions are updated too: the stale "Note (fix(purchase): reaper fails stranded approvals before process recovery can re-drive them #894)" caveat is removed from bothenable_process_scheduled_purchases_schedulevariables, andpurchase_approved_reap_afternow explains the approved floor. Only description strings changed in terraform; no defaults or resources changed.The per-row CAS is unchanged (it still accepts
approved|running -> failed), soreapOneand its callers keep the same signature and race handling.Closes #894
Verification
All of this is unit/mock evidence:
MockConfigStorewith recordedListStuckExecutionsarguments. No real database, Lambda or EventBridge run was involved, and no real cloud calls were made. Tests were run withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause the local~/.aws/configdefault profile is invalid on this machine.New regression tests are in
internal/purchase/reaper_ordering_test.go. Fail-before, withreaper.goreverted toorigin/mainand the two tests that reference the new helper set aside:LongReapAfterIsHonoredForApprovedandPassesCoverStuckStatusesare invariants. They pass both before and after the fix, and they guard against over-correcting.ApprovedAnnotationUsesApprovedThresholdandApprovedListStoreErroruse the new helper, so they don't compile against the old code.Pass-after (this branch):
Mutation probes, each reverted afterwards:
approvedReapAfterreturnsreapAfter:ApprovedRowsWaitForStrandedRecoveryandShortReapAfterDoesNotBeatRecoveryfail.reapAfter: the same two fail, plusApprovedAnnotationUsesApprovedThreshold.The existing
reaper_test.gocases now stub both passes throughexpectStuckLists.TerminalStatusNotTouchedchecks every pass and the union of statuses. A new test checks that an error on the approved pass also propagates.Not covered locally:
terraform validateand tflint on the description-only terraform edits (noinitwas run). There is also no end-to-end run with real EventBridge schedules showing a stranded approval being re-driven by the process sweep instead of reaped.Out of scope / notes
processSweepIntervalis a Go constant that matches the terraform defaultrate(1 hour). An operator who setsprocess_scheduled_purchases_scheduleslower thanrate(1 hour)can still have approved rows reaped before recovery runs. A full fix would pass the process cadence (or an explicit approved threshold) through to the Lambda environment. I left that out to keep this change small.approvedrows are now failed after about 1h20m instead of 10m. That is the trade-off the issue asks for, so the recovery path gets a chance to run where it is scheduled.runningas a source status for a row it listed asapproved. If recovery claims the row in the gap between the reaper's SELECT and its CAS, the reaper can still fail the re-driven row. This race existed before, and the executor's existing reaped-row recovery path (saveOrRecoverTerminal) handles it. Narrowing the CAS per pass would change thereapOnecontract that other tests use, so I left it.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/high,urgency/this-quarter,impact/few,effort/s,type/bug,triaged.🤖 Generated with Claude Code