Repository navigation
fix(ri-exchange): run ExecuteExchange outside the request context - #897
Conversation
The RI exchange execute paths called the provider on the HTTP request context, so a client disconnect or the container server's 30s request timeout could cancel the exchange after the provider had committed it, and the terminal ledger write recording the outcome then failed on the same dead context. Same class as LeanerCloud#706 (fixed for purchases in LeanerCloud#789). Run the provider call on a detached context bounded by its own 4m budget, and the terminal writes (CompleteRIExchangeWithPayment, FailRIExchange after execution, approved_by stamp) on a separate 30s detached context. Covers the direct AWS endpoint, the approval flow and the Azure execute endpoint. The direct AWS path now goes through the same injectable executor as the approval flow so it can be tested without AWS. Closes LeanerCloud#790 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 12 billable files and costs up to $3.00. Or wait 58 minutes for your next included review. View limit details
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review of 15e3ec8: draft: blocks ready
Findings
- Actionable,
internal/api/handler_ri_exchange.go:2295-2303and:2658-2667(executeApprovedExchangepre-commit failure writes). The PR body says gates before the commit correctly keep the request context. That holds for the provider call, but not for the ledger.TransitionRIExchangeStatus(pending -> processing)commits on the request ctx, thenGetGlobalConfig,ReserveApprovedRIExchangeand theirfailExchange(ctx, ...)calls run on the same ctx. Scenario: the client disconnects (or the 30s server timeout fires,internal/server/http.go:253) right after the transition.ReserveApprovedRIExchangefails withcontext canceled,failExchange(ctx)then fails on the dead ctx (only logged), and the record staysprocessing. The approval retry gets 409 from the transition guard, so it cannot be retried, and the row's initial quote keeps counting against the daily cap (GetRIExchangeDailySpendincludesprocessing). Fix: run thefailExchangecalls before the provider call on a detachedriExchangeTerminalSaveTimeoutcontext too (simplest: makefailExchangeitself detach), and add a test that cancels beforeReserveApprovedRIExchange. - Actionable (test gap),
internal/api/ri_exchange_detached_test.go. Mutation probe: reverting thestampRIExchangeApproverdetach (handler_ri_exchange.go:2313-2315) leaves the wholeinternal/apisuite green. The PR body lists theapproved_bystamp as detached but no test pins it. Add one that cancels the request and expectsStampRIExchangeApprovedBywithliveCtx(). - Nit,
ri_exchange_detached_test.go:41.assertDetachedcomputes the expected deadline fromriExchangeExecutionTimeout, so changing the constant (probe: 4m to 40m) still passes. The test proves "has a deadline", not "4m". Acceptable, but say so or assert against a literal. - Nit,
handler_ri_exchange.go:1243-1247comment. It still describes "a ctx cancellation mid-poll" as an ambiguous case. After this change a request cancel can no longer cause it; only the 4m budget can. Update the comment. - Note (not blocking, document in PR body). Direct AWS and Azure execute paths persist nothing: when the client has disconnected, the detached call can commit and the exchange ID and result are only in the server log (
logging.Errorf/Infof). The 15 minute claim (ri_exchange_idempotency.go:30, never released) correctly turns the client retry into a 409, so there is no double-execute, but the caller has no way to learn the outcome except the provider console. The 409 text already says to verify. Out of scope for #790 unless the maintainer wants a ledger row there.
Deployment reality (the "background goroutine" question)
There is no background goroutine. The handler still blocks on the detached call, so the change only helps where the request ctx dies earlier than the process.
- Lambda: no effect.
lambda_timeoutdefaults to 30 (terraform/environments/aws/variables.tf:231), and the 4m execution plus 30s save budgets need >= 270s. The PR and #789 both state that requirement but nothing enforces it (no validation or default change). A default AWS deployment still dies at 30s with the exchange in flight, the same as before. A follow-up should either add a variable validation or document it in the deploy guide. - Container (Cloud Run / Container Apps): helps.
handleHTTPRequestuses a 30s ctx (internal/server/http.go:253);http.Server.WriteTimeoutis 30s (:76) but does not cancel the handler, so the detached call runs to completion. Residual risk: SIGTERM on scale-in/deploy has a 30sshutdownGracePeriod(:160) against a 4m budget, so an in-flight exchange can still be cut by a revision rollout. Cloud Runrequest_timeoutis a variable (terraform/modules/compute/gcp/cloud-run/variables.tf:62) and must be >= the 4m budget; not checked here.
Other attack results
- Duplicate execution on retry: AWS and Azure direct paths take
claimExchangeSubmitbefore the detached call and never release it, so a retry after a cancelled request gets 409. The approval flow is guarded by the atomicpending -> processingtransition (:2295,:2358), so a retry gets 409 and cannot reach the provider again. No double-execute found. (Existing tests inri_exchange_idempotency_test.gostill pass.) - Terminal-save budget:
saveCtxis created after the provider call returns, so a slow provider does not eat the 30s.retryCompleteWithPaymentmakes 3 attempts with no backoff inside that one budget; fine. - Values lost from ctx:
context.WithoutCancelkeeps all values (logger, auth, tenant, request id). No test pins this, but the stdlib guarantees it and no code path in the diff swaps the ctx forBackground(). - Missing deadline: both detached contexts carry a deadline;
defer cancelis present at every site. - Duplication/overlap: PR #895 (draft,
internal/server/http.go+ its test only) adds no shared helper, so there is no conflict. This is a third copy ofpurchase.detachedTimeout(internal/purchase/manager.go:236, same body). The PR declares this openly. Consolidation is a maintainer call, but a one-line shared helper would remove it; I did not treat it as blocking. - Scope: 8 files, +280/-35 of which about 190 are new tests; under the 400-line cap for production change.
handler_ri_exchange.gois 2789 lines, pre-existing over the 500 guideline.
What I attacked
- Ran the affected package tests at the SHA (
go test ./internal/api -run 'RequestCancel|ExchangeIdempotency|ExecuteApprovedExchange|ExecuteExchange|ExecuteAzure|Stamp'): 103 passed,AWS_CONFIG_FILE=/dev/null. - Mutation probes (reverted after each, worktree clean):
- helper keeps cancellation (
WithTimeout(ctx, d)): approval test FAILS (caught). - Azure
ExecuteExchangeback on request ctx:TestExecuteAzureExchange_RequestCancelDoesNotCancelExchangeFAILS (caught). - AWS direct and approval provider call back on request ctx: both
TestExecuteExchange_RequestCancel...andTestExecuteApprovedExchange_RequestCancel...FAIL (caught). CompleteRIExchangeWithPaymenton request ctx instead ofsaveCtx: FAILS (caught).FailRIExchangeon request ctx:...RequestCancelStillRecordsFailureFAILS (caught).stampRIExchangeApproverdetach removed: all tests pass (SURVIVED, finding 2).- execution timeout 4m to 40m: passes (SURVIVED, finding 3, tautological).
- helper keeps cancellation (
- Fail-before/pass-after: the mutation probes above reproduce the pre-fix behaviour on each site and the new tests fail on each, so the regression coverage is real. One correction to the PR body: the direct AWS test cannot be run against the parent as-is, because the parent's
executeExchangecalledexchange.ExecuteExchangedirectly and does not readexecuteExchangeFn; the seam is part of this PR. The reverted-site mutation is the equivalent proof. - Did not run the repo-wide race suite or lint; CI has not run (see below).
Gate status (origin/main Review gate)
- Independent review naming the SHA: this comment (draft, so not a merge verdict).
- Actionable findings resolved: no, findings 1 and 2 are open.
- CI on the SHA: not run. Both workflows (
pre-commit,CI - Build & Test) areaction_required(external contributor, needs maintainer approval). CodeRabbit skipped (draft). CodeRabbit has no actionable items to count. - Local real-path verification evidence on the PR: present from the author (mocks and stubs only, macOS, no real AWS/Azure; labeled honestly). I reproduced the package results above, not the repo-wide runs.
- Verdict recorded on the PR: this comment.
- Labels: the PR has none (author lacks permission). A maintainer must mirror #790:
priority/p1,severity/high,urgency/this-sprint,impact/many,effort/m,type/bug,triaged.
Blocks ready
- Fix finding 1 (pre-commit
failExchangeon a cancelled ctx strands aprocessingrecord) with a test. - Add the stamp-detach test (finding 2).
- Approve and get both CI workflows green on the final SHA.
- Maintainer applies the #790 labels.
- Decide on and record the Lambda
lambda_timeout >= 270handling (validation or doc), since the default 30s makes this PR a no-op on the default AWS deployment. - Fresh review of the new head SHA after the above (any push restarts the gate).
|
Clarification of the review above (15e3ec8), after maintainer calibration. Only these block ready:
Non-blocking: the untested |
…ntext helper Address review on LeanerCloud#897: - failExchange now detaches its write. Every caller runs after the pending -> processing transition committed, so a request cancel before ReserveApprovedRIExchange could strand the record in "processing" (409 on retry, still counted against the daily cap). - Pin the approved_by stamp detach with a test. - Assert the 4m execution and 30s terminal-save budgets against literals rather than the constants under test. - Update the stale Azure comment: a request cancel no longer reaches the poll, only the execution budget can. - Introduce ctxutil.DetachedWithTimeout as the single detached-context helper and use it from the purchase and RI exchange paths, replacing purchase.detachedTimeout and the exchange-local copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Response to the independent review of 15e3ec8. New head: d4fd03d (a new commit on top, no force-push).
Shared helper (the #895 review's finding 5). There is now a single copy:
Deployment notes (Lambda Verification at d4fd03d (macOS, mocks and stubs only, no real AWS or Azure; tests run with
CI on the fork still needs maintainer approval. I have not marked the PR ready or applied labels. |
…oud Run timeout Address review of LeanerCloud#895: - Track detached HTTP-triggered scheduled tasks. StartHTTPServer drains for 5/6 of the 30s grace, then cancels tasks still running and waits for them in the remaining time before main closes the database. - Lower the task bound from 15m to 300s, the Cloud Run request_timeout set in terraform/environments/gcp (cpu_idle = true, so no CPU past it), and pin it against the Terraform values with a test. - Assert request-scoped context values survive the detach. - Use ctxutil.DetachedWithTimeout, copied byte-identical from LeanerCloud#897. Refs LeanerCloud#784 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim
left a comment
There was a problem hiding this comment.
Independent delta review of d4fd03d (previous: 15e3ec8): draft: no blocking findings (CI and gate items still open)
OLD_SHA is an ancestor of NEW_SHA. Base origin/main has not moved (merge-base is e003025), and the PR is mergeable.
Previous findings
| # | Finding | Status | Evidence |
|---|---|---|---|
| 1 (blocking) | Pre-commit failExchange writes on the request ctx strand a processing record |
RESOLVED | handler_ri_exchange.go:2589-2592 now detaches inside failExchange with the 30s terminal-save budget. All 6 pre-commit callers (:2656-2681) go through it. |
| 2 | Untested stampRIExchangeApprover detach |
RESOLVED | TestStampRIExchangeApprover_SurvivesRequestCancel fails when the detach is reverted. |
| 3 | Tautological deadline assertion | RESOLVED | assertDetached uses a literal 4*time.Minute. liveCtx() also requires a deadline within 30s. |
| 4 | Stale Azure comment | RESOLVED | handler_ri_exchange.go:1243-1248. |
| 5 | Helper consolidation | RESOLVED | One copy in internal/ctxutil/detached.go. The local exchange helper and purchase.detachedTimeout are removed. #895 can adopt it after merge. |
Proof for finding 1 (the exact scenario: cancel after the pending -> processing transition, during ReserveApprovedRIExchange): TestExecuteApprovedExchange_RequestCancelBeforeReserveRecordsFailure cancels the request inside ReserveApprovedRIExchange, which returns context.Canceled. It expects FailRIExchange with a live ctx and a deadline within 30s, and expects zero provider calls. With the failExchange detach removed at NEW_SHA (equivalent to OLD_SHA behaviour for these call sites) the test FAILS; at NEW_SHA it passes. The author reports a FAIL on the parent as well.
New findings
- NON-BLOCKING,
handler_ri_exchange.go:2297and:2360. Thepending -> processingtransition itself still runs on the request ctx. If the cancel lands while the transition's commit is in flight and the driver returns an error although the row committed, the record is stuck inprocessing. This is a much narrower window than the one fixed here, and no provider call has been made. Follow-up candidate: detach the transition, or treat a cancelled-ctx error as "re-read the row". - NON-BLOCKING, behaviour note. A cancel before
GetGlobalConfigorReserveApprovedRIExchangenow records the exchange asfailed(clean terminal state) instead of continuing. That is fail-loud and consistent with the issue. The caller retries by creating a new exchange, not by retrying the approval.
No double execution
failExchangeonly writes the terminalfailedstate. It never calls the provider.- Retry needs
pending. The transition guard (:2297,:2360) returns 409 forprocessing,failedandcompleted. The new path moves the record fromprocessingtofailedand does not make itpendingagain. - Direct AWS and Azure paths are unchanged apart from the helper rename. They keep
claimExchangeSubmit, which is never released, so a retry gets 409. - The nested detach (
failExchange(saveCtx, ...)re-detaches) only resets the 30s budget. It cannot cancel or re-run anything.
What I attacked
- Tests at NEW_SHA (
AWS_CONFIG_FILE=/dev/null):go build ./...ok../internal/api3110 passed../internal/ctxutiland./internal/purchase682 passed. The 6RequestCancelandSurvivesRequestCanceltests pass. - Mutation probes (worktree reverted after each, clean):
failExchangedetach removed:...RequestCancelBeforeReserveRecordsFailureFAILS (caught).stampRIExchangeApproverdetach removed:TestStampRIExchangeApprover_SurvivesRequestCancelFAILS (caught).riExchangeExecutionTimeout4m to 40m: 3 tests FAIL (caught; it survived before).
- Read the full delta (
git diff OLD NEW): production change is +40/-23 over 4 files, plus tests. Full PR vs main is 12 files, +411/-59, including tests. - I did not re-run lint or the repo-wide race suite. The author reports both green.
Gate status (origin/main Review gate)
- Independent review naming the SHA: this comment (draft, so not a merge verdict).
- Actionable findings resolved: yes for the previous round. New items above are non-blocking.
- CI on the SHA: not run.
pre-commit(38822610708) andCI - Build & Test(38822610718) are bothaction_requiredand need maintainer approval. CodeRabbit skipped the draft and has no threads. - Local verification evidence on the PR: present from the author (mocks and stubs only, macOS, no real AWS or Azure). I reproduced the package results above.
- Labels: mirrored from #790 (priority/p1, severity/high, urgency/this-sprint, impact/many, effort/m, type/bug, triaged).
- Verdict recorded: this comment.
Blocks ready
- A maintainer approves the two
action_requiredruns and both go green on the final SHA. - Decide and record the Lambda
lambda_timeout >= 270handling (validation or deploy-guide note), since the default 30s makes this a no-op on the default AWS deployment (carried from the previous review). - The author marks the PR ready, then a fresh review of the final head (any push restarts the gate).
…ontext (#895) * fix(server): detach HTTP-triggered scheduled tasks from the request context handleScheduledHTTP ran the task on r.Context(), so a scheduler that gave up (Cloud Scheduler attempt_deadline, Logic Apps 120s) and dropped the connection cancelled the task, possibly mid-purchase, losing the finalize/history write. Run the task on context.WithoutCancel bounded by a 15 minute timeout (matching the --task CLI default), and lift the server's 30s WriteTimeout for this route so a long task can still return its result instead of the connection being dropped. Closes #784 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server): cancel detached scheduled tasks on shutdown, bound to Cloud Run timeout Address review of #895: - Track detached HTTP-triggered scheduled tasks. StartHTTPServer drains for 5/6 of the 30s grace, then cancels tasks still running and waits for them in the remaining time before main closes the database. - Lower the task bound from 15m to 300s, the Cloud Run request_timeout set in terraform/environments/gcp (cpu_idle = true, so no CPU past it), and pin it against the Terraform values with a test. - Assert request-scoped context values survive the detach. - Use ctxutil.DetachedWithTimeout, copied byte-identical from #897. Refs #784 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Gate record for d4fd03d (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at d4fd03d with --match-head-commit. |
Summary
Root cause. The RI exchange execute paths called the provider on the HTTP request context. A client disconnect, or the container server's 30s per-request timeout (
internal/server/http.go), could cancelExecuteExchangeafter the provider had already committed the exchange. The terminal ledger write that records the outcome then ran on the same dead context and failed, so the record stayedprocessing. This is the same class of bug as #706, which #789 fixed for purchases.Fix. Mirrors #789's
detachedTimeoutpattern:context.WithTimeout(context.WithoutCancel(ctx), 4m)(riExchangeExecutionTimeout). It keeps the request's values, drops its cancellation, and is bounded by its own budget.riExchangeTerminalSaveTimeout). These areCompleteRIExchangeWithPayment(with its retries),FailRIExchangeon an execution error, and the best-effortapproved_bystamp.executeExchange, the line the issue names)executeApprovedExchange, which owns the terminal save)executeAzureExchange). ItsExecuteExchangepolls a long-running operation, so it is the path most exposed to the 30s timeout.awsExchangeExecutor) as the approval flow. Before this, it calledexchange.ExecuteExchangedirectly and could not be tested without AWS.Lambda lifetime. On Lambda the invocation is still capped by the function timeout (default
lambda_timeout = 30). No context can extend that, so detaching only helps where the request context is shorter-lived than the process, i.e. container deployments and client disconnects. #789 needslambda_timeout >= 270sfor the same reason, and the 4m and 30s budgets here assume the same.Shared helper. The detaching is
ctxutil.DetachedWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc)ininternal/ctxutil/detached.go. It is now the single copy:purchase.detachedTimeoutwas removed and its two call sites use it. #895 (#784) can adopt it once this merges.Pre-commit failure writes.
failExchangedetaches its own write (30s). Every caller runs after thepending -> processingtransition has committed, so a cancel beforeReserveApprovedRIExchangecan no longer strand the record inprocessing.Direct execute paths persist nothing (known gap, out of scope). The direct AWS and Azure execute endpoints write no ledger row. If the client has disconnected, the detached call can still commit, and the exchange ID and result end up only in the server log. The 15-minute submit claim (
ri_exchange_idempotency.go, never released) turns a client retry into a 409, so the exchange can't run twice. The caller's only way to learn the outcome is the provider console, which the 409 text already tells them to check. A ledger row for those paths would be a follow-up if the maintainer wants one.Closes #790
Verification
All checks ran locally on macOS against mocks or stubs. No real AWS or Azure calls were made. There is no real-account coverage of an actual exchange being committed while the request is cancelled.
New regression tests in
internal/api/ri_exchange_detached_test.go. Each one cancels the request context while the provider call is in flight, then asserts two things. First, the provider call's context is still live and carries its own ~4m deadline. Second, the terminal write receives a context that has not been cancelled. The second check uses amock.MatchedBy(ctx.Err() == nil)expectation, standing in for a real DB driver refusing a cancelled context.Fail before the fix. The executor seam was already in place, so the direct path could be stubbed. Command, run once per test:
go test ./internal/api/ -run '^<Test>$' -count=1TestExecuteApprovedExchange_RequestCancelDoesNotCancelExchangeOrLedger: FAIL.mock: Unexpected Method Call ... (*context.cancelCtx=context.Background.WithCancel) not matched by func(context.Context) bool, meaning the ledger write was attempted on the cancelled request context.TestExecuteApprovedExchange_RequestCancelStillRecordsFailure: FAIL, same mismatch onFailRIExchange.TestExecuteExchange_RequestCancelDoesNotCancelAWSExchange: FAIL,cancelling the request must not cancel an exchange already submitted to the provider.TestExecuteAzureExchange_RequestCancelDoesNotCancelExchange: FAIL, same message.Review round 1 (d4fd03d). Two more tests, plus the existing ones now checking the budgets against literals (4m execution, 30s terminal save):
TestExecuteApprovedExchange_RequestCancelBeforeReserveRecordsFailure: FAIL before thefailExchangedetach (FailRIExchangegot the canceled request ctx), PASS after.TestStampRIExchangeApprover_SurvivesRequestCancel: FAIL with the stamp detach reverted (mutation), PASS with it.internal/ctxutil/detached_test.gocovers: values kept, parent cancel dropped, own deadline, CancelFunc works.Pass after the fix.
go test ./internal/api/ -run 'RequestCancel' -count=1 -v: all 4 PASS.go build ./...: ok.go vet ./...: ok.go test -race -short -count=1 ./...: ok, whole repo.go test -race -count=1 ./internal/api/: ok.golangci-lint run --timeout=10m ./...with v2.10.1, the CI-pinned version: 0 issues.Notes:
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. The local~/.aws/configbreaks the unrelatedTestDiscoverOrgAccounts_*tests.ctxonExecuteExchange,CompleteRIExchangeWithPaymentorStampRIExchangeApprovedBynow usemock.Anything, because those calls deliberately get a detached context.failExchange(its error result is always nil). I couldn't pin down why. It has a//nolint:unparamwith a justification, matching existing usage in the repo.Labels
The author lacks label permissions. A maintainer should mirror #790's triage labels onto this PR:
priority/p1,severity/high,urgency/this-sprint,impact/many,effort/m,type/bug,triaged.🤖 Generated with Claude Code