Repository navigation
fix(revoke): record an accepted Azure Return even if the request is canceled right after it - #1526
Conversation
…anceled After Azure accepts a Return, the handler records it (MarkRevocationReturnPosted) and marks the purchase revoked. Both writes and their retries ran under the request context, so a client disconnect or an upstream timeout right after the Return made every attempt fail: the Return went unrecorded, the row stayed unrevoked, and the finalize_revocations sweep would release it after 2h as if nothing had been refunded. persistAzureRevocation now runs those writes under ctxutil.DetachedWithTimeout(ctx, 30s): request cancellation no longer reaches them, and the 30s budget covers the existing retry schedule (13s of backoff plus four attempts) while still bounding a hung database. Nothing before the Return is detached: CalculateRefund, the in-flight claim and the Return itself still honor cancellation. Also correct the staleRevocationInFlightAfter comment: 2h is not twice the longest request any deployment can run (Fargate and Container Apps do not cap request time); correctness does not depend on it. Tests: a stub Return client cancels the request right after accepting the Return; the row must end with the Return recorded and revoked (unit, with a store that fails writes on a done context as pgx does, and integration on real Postgres). A second test cancels before the claim and asserts no write and no Return. Existing Azure-path mocks now match any context for the post-Return writes. Closes LeanerCloud#1525 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale: none Reviewer: rF15. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF16. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Root cause
After Azure accepts a Return,
persistAzureRevocationrecords it (MarkRevocationReturnPosted, #1524) and marks the purchase revoked (MarkPurchaseRevoked), with the 1s/3s/9s retry loop. All of that ran under the request context. If the request was canceled right after the Return (a client disconnect, an upstream timeout), pgx refused every attempt withcontext canceled: the Return went unrecorded, the row stayed unrevoked, the handler answered 207 RECONCILE_PENDING, and because noreturn_posted_atwas recorded, the finalize_revocations sweep would eventually release the row after 2h instead of finalizing it. The purchase then shows as active even though Azure refunded it. Azure refuses a second Return, so there was no double refund, but the record was wrong.Fix
internal/api/handler_purchases_revoke.go:persistAzureRevocationnow runs the post-Return writes underctxutil.DetachedWithTimeout(reqCtx, revokePersistTimeout). That is the repo's existing helper forcontext.WithTimeout(context.WithoutCancel(ctx), d), already used for the RI-exchange and purchase terminal writes. The retry loop is unchanged.revokePersistTimeout): it covers the full existing retry schedule (13s of backoff) plus four attempts of two single-row UPDATEs. It still limits how long a hung database can hold the handler after its caller has gone. It matchesriExchangeTerminalSaveTimeout(30s), which bounds the same kind of write. If the budget runs out, the result is the same 207 as exhausted retries.FlipPurchaseRevocationInFlight) and the Return call itself still run under the request context and honor cancellation.handleAzureReturnError'sClearRevocationInFlightis also unchanged (it runs only when the Return failed).internal/purchase/finalize_revocations.go: corrected thestaleRevocationInFlightAftercomment. It used to say 2h is "twice the longest request a deployment can run", which is wrong because Fargate and Container Apps don't cap request time. The comment now says why correctness doesn't depend on that number: a Return recorded after the release is still finalized whether or not the flag is set, and a second revoke can't double-refund because Azure refuses a second Return of the same reservation. I made the matching comment fix infinalize_revocations_test.go.No migration. No frontend change.
Closes #1525
Tests
TestCallAzureReturn_RequestCanceledAfterReturn_StillRecordsAndMarks(unit, mock store): a stub Return client accepts the Return and then cancels the request context. The store wrapper (ctxHonoringStore) fails writes withctx.Err()once the context is done, the way pgx does, and records the context state at write time. The test asserts:revoked;revokePersistTimeout;TestCallAzureReturn_RequestCanceledBeforeReturn_PostsNothing(unit): CalculateRefund succeeds and then the request is canceled. The test asserts the claim does not land, no Return is posted, and nothing is recorded. This guards against detaching too early.TestRevokeAzure_RequestCanceledAfterReturn_RecordsAndRevokes(integration, real Postgres, throughloadAndRevokePurchaseHistory): the request is canceled right after the stub Azure client accepts the Return. The row must end withreturn_posted_atset,revoked_atset, andrevocation_in_flightcleared.MarkPurchaseRevoked/MarkRevocationReturnPostednow matchmock.Anythingfor the context, because those writes no longer receive the request context.Verification
All commands were run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. Azure is a stub throughout; there are no real cloud calls.Fail-before / pass-after. I ran the three new tests with
handler_purchases_revoke.gorestored frommain. A temporary file defined only therevokePersistTimeoutconstant so the tests would compile.TestRevokeAzure_RequestCanceledAfterReturn_RecordsAndRevokes(real Postgres)want revoked, got *api.revokeReconcilePendingResult {Code:RECONCILE_PENDING ...}TestCallAzureReturn_RequestCanceledAfterReturn_StillRecordsAndMarks(unit, mock store)context canceled,return recorded=falseTestCallAzureReturn_RequestCanceledBeforeReturn_PostsNothing(unit)Mutation probes (unit tests). Each mutation was applied to the fixed handler, then reverted.
context.WithCancel(context.WithoutCancel(reqCtx))): FAIL,write 0 must have its own bounded timeout.callAzureReturn(ctx = context.WithoutCancel(ctx)at the top): both new unit tests FAIL.context.WithoutCancel(ctx): FAIL,the Return itself must run under the request's context.Concurrency. The change adds no new DB statement and no new lock. It only changes which context the two existing post-Return UPDATEs run under, and both are idempotent (
COALESCEstamp and the existing mark). The existing concurrent-revoke race test (TestRevokeAzure_ConcurrentFirstRevokes_OneReturn, #1518) covers the claim and is unaffected. I did not rerun it here; it is included in the combined-tree run.Full-suite verification at local queue commit
c69ce9fa= origin/mainda38fa74+fix/issue-1525. Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationon the changed packages (internal/api,internal/purchase); the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Out of scope / not addressed
return_posted_atis recorded, and the sweep releases the row after 2h, which an operator then fixes from the error log. Fixing that would need a durable record outside this DB (or a write-ahead record before the Return), which is a design decision for the maintainer.lambda_timeout(>= 300s, enforced by Terraform validation).internal/api/handler_purchases_revoke.gowas already over the 500-line guideline before this change (~1090 lines). I didn't split it here.Labels
Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,type/bug.🤖 Generated with Claude Code