Repository navigation
fix(revoke): finalize only revocations whose Azure Return was recorded; release stale flags - #1524
Conversation
The finalize_revocations sweep marked every flagged, unrevoked purchase_history row revoked. A row is also left flagged when a request dies after FlipPurchaseRevocationInFlight but before Azure accepts the Return (or the flip's commit result is lost), so the sweep could record a refund that never happened while the reservation stayed active and billed. - Migration 000148 adds purchase_history.return_posted_at and revocation_in_flight_at, plus a partial index for the finalize queue. No backfill: rows flagged before it have no recorded Return. - The revoke handler stamps return_posted_at (MarkRevocationReturnPosted) right after Azure accepts the Return, retried with each MarkPurchaseRevoked attempt until it lands. - GetPurchaseHistoryInFlight, the sweep's queue, now returns unrevoked rows with a recorded Return, flag or not; a flag alone is never finalized. - The flip stamps revocation_in_flight_at. The sweep clears the flag on rows flagged for over 2h with no recorded Return (ReleaseStaleRevocationsInFlight) so the purchase can be revoked again, and logs each for an operator. A flagged row with no stamp (pre-000148) is stamped on first sight and aged from then. Closes LeanerCloud#1523 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up LeanerCloud#1520 (exclusive in-flight claim). No conflicts. 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:
Reviewer: rF13. 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: none. The claims I checked (main at 9583647, migration 148, the Cloud Run cap, the test names, and the This is a sensitive (money-path) PR, so it needs a second independent approval. The 2h-release vs operator-only choice is D25 for the acting owner. I recommend the PR's choice. Reviewer: rF14. 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
finalize_revocations(FinalizeInFlightRevocations→GetPurchaseHistoryInFlight) marked every row withrevocation_in_flight = true AND revoked_at IS NULLas revoked. The flag is set by the flip before the Azure Return, so it can't tell "Azure refunded, butMarkPurchaseRevokedfailed" apart from "the request died between the flip and the Return" (or "the flip committed but its result was lost"). In the second case the sweep recorded a refund that never happened. The purchase read back as revoked, the user could no longer revoke it, and the reservation stayed active and billed.Fix
The sweep now finalizes only rows where an accepted Return was actually recorded. A row that is only flagged is never marked revoked. Once it has been flagged for 2h, the flag is cleared so the user can retry.
000148_purchase_history_revocation_return_posted(number reserved for this branch; main is at 000147, and no open PR uses 148). It adds two nullable columns,purchase_history.return_posted_atandrevocation_in_flight_at, and a partial index for the finalize queue (WHERE return_posted_at IS NOT NULL AND revoked_at IS NULL). There is no backfill. The down drops both columns and the index.internal/api/handler_purchases_revoke.go). AfterreturnClient.Postsucceeds,persistAzureRevocationnow runsrecordAzureReturnAndMarkon each attempt. That stampsreturn_posted_at(newMarkRevocationReturnPosted, retried on everyMarkPurchaseRevokedattempt until it lands) and then callsMarkPurchaseRevokedas before. A failed or refused Return records nothing. If every attempt fails, the error log now says whether the Return was recorded.internal/config/store_postgres.go, newstore_postgres_revocation_return.go):GetPurchaseHistoryInFlight(the sweep's queue) now returnsreturn_posted_at IS NOT NULL AND revoked_at IS NULL, and readsrevocation_in_flightfrom the row instead of hard-codingtrue. The flag is no longer required. Once Azure has accepted a Return the purchase is refunded, even if something cleared the flag afterwards.FlipPurchaseRevocationInFlightalso setsrevocation_in_flight_at = now(). The WHERE clause and its fix(config/db): compare-and-set the revocation in-flight flip; make 000059 re-applicable after a rollback #1516/fix(revoke): make the Azure in-flight flip an exclusive claim so only one revoke posts a Return #1520 semantics are unchanged.MarkRevocationReturnPosted(ctx, id):SET return_posted_at = COALESCE(return_posted_at, now()) WHERE purchase_id = $1 AND revoked_at IS NULL. The first stamp stands. A revoked row is a no-op and a missing row is not-found.ReleaseStaleRevocationsInFlight(ctx, after)refusesafter <= 0. It (1) stampsrevocation_in_flight_at = now()on flagged, unrevoked rows with no recorded Return and no stamp, then (2) clears the flag (and the stamp) on flagged, unrevoked rows with no recorded Return whose stamp is older thanafter, and returns their IDs. Both steps use the database clock.internal/purchase/finalize_revocations.go): it first releases stale flags (staleRevocationInFlightAfter = 2h), logging each one at error level for an operator and counting it in the newFinalizeResult.Released. It then finalizes the queue as before. The two sets are disjoint. A release failure doesn't skip the finalize pass, but it is returned (joined), so the tick still fails visibly. The task's JSON output gains aReleasedkey; the existing keys are unchanged.internal/server/handler.gologsreleased=.Stale flags: clear after a timeout, or leave them for an operator? (owner decision)
I chose clear after a timeout, and log every release for an operator. Leaving the flag set forever looks safer but isn't:
request_timeoutis validated ≤ 3600s and Lambda ≤ 900s. So a revoke still between its flip and its Return is never released. The sweep runs hourly, so in practice a release lands 2–3h after the flip, which is small next to the 7-day window. Correctness doesn't depend on the exact value. A Return recorded after a release is still finalized, because the queue doesn't require the flag (the race is tested below).If the owner prefers operator-only handling, the alternative is to drop step (2) and only log. That reintroduces the lock-out described above.
Rows already flagged when 000148 lands
Interpretation: "no Return recorded". Nothing is backfilled. Such rows are never finalized by the new sweep. A legacy row has no
revocation_in_flight_at, so the sweep stamps it on first sight and releases it 2h after that. Its age is never overstated, and a request from a pre-000148 binary still running during a rolling deploy gets the full timeout too. Backfillingreturn_posted_atwould have kept the old behavior and kept the bug for exactly the rows it hurts. Backfillingrevocation_in_flight_atwith the migration time would have been equivalent, except that it misses flips from old binaries during the rollout. The trade-off is the same as above. A legacy row whose Return did go through (handler mark plus sweep both failed before the deploy) ends up reading as active, and the release log line flags it for manual reconciliation.How this combines with #1520 (merged while this was in progress; this branch merges
origin/mainat95836478)The changes are orthogonal, and the merge had no conflicts. #1520 made the flip an exclusive claim (
AND revocation_in_flight = false) and made sure only the claim holder posts a Return or callsClearRevocationInFlight. This PR only addsrevocation_in_flight_at = now()to the flip's SET list and adds the Return-accepted stamp inpersistAzureRevocation, which only the claim holder reaches. A release clears a claim, so the row becomes claimable again. That's intended, and given the 2h timeout the previous holder is long gone. Even if it weren't, Azure accepts only one Return per reservation.TestReleaseStaleRevocationsInFlight_RacesFlipcovers both claim semantics: if the flip refuses the row, nothing is posted, and if it succeeds, the row ends up flagged with a fresh stamp.Closes #1523
Verification
Linux VM, Go 1.26, every command run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. "Integration" means real PostgreSQL in Docker (testcontainers, the packages' shared containers). The handler tests use stub Azure CalculateRefund/Return clients and the mock store. Nothing calls a cloud API. All results below are on the pushed head (origin/mainmerged in).New tests:
TestFinalizeInFlightRevocations_FlagWithoutReturnIsNotRevoked(integration,internal/purchase) is acceptance criterion 1. It uses a row flipped with no Return, plus a legacy row whose flag was set by raw SQL with no stamps. After the real sweep, neither row is revoked and both are still flagged.TestFinalizeInFlightRevocations_RecordedReturnFinalizedStaleFlagReleased(integration,internal/purchase) is acceptance criterion 2. A row with a recorded Return is finalized as before (revoked_via=direct-api, flag cleared). A flipped row backdated past the timeout is released (flag cleared, still unrevoked). A second tick finds nothing.TestGetPurchaseHistoryInFlight_RequiresRecordedReturn(integration,internal/config): only rows with a recorded Return are in the queue, including one whose flag was cleared after its Return was recorded. The flag is read from the row.TestMarkRevocationReturnPosted(integration): missing row → not-found; the first stamp stands; a revoked row is left alone.TestReleaseStaleRevocationsInFlight(integration):TestReleaseStaleRevocationsInFlight_RacesReturnRecord(integration, 30 rounds, also run with-race):MarkRevocationReturnPostedruns concurrently with the release of the same stale row. In every round the recorded Return ends up in the finalize queue and nothing is revoked by the release. Both orders occurred: the release won 3/30 and 1/30 rounds in two runs. The test doesn't force a given split.TestReleaseStaleRevocationsInFlight_RacesFlip(integration, 30 rounds): a flip runs concurrently with the release. A flip that succeeds always leaves the row flagged with a fresh stamp.TestMigration_000148_RevocationReturnPosted(integration): pre-000148 rows keep their flag andrevoked_at, and get noreturn_posted_atorrevocation_in_flight_at. The index exists. The down drops both columns and leaves the flag. The up re-applies after the rollback.TestCallAzureReturn_RecordsReturnBeforeMark,…_ReturnErrorRecordsNothing,…_RetriesReturnRecordUntilItLands(unit, mock store): the stamp comes before the mark; a failed Return records nothing; the stamp is retried with the mark until it lands, then not again.TestFinalizeInFlightRevocations_ReleasesStaleFlagsWithoutMarking,…_ReleaseErrorStillFinalizes,…_ReleaseAndListErrorsJoined,TestStaleRevocationInFlightAfter_OutlastsLongestRequest(unit, mock store).TestNullablePurchaseCostPostgresmigrates to 148 before the queue check and stampsreturn_posted_aton its fixture row.TestFinalizeSweep_PollsMarketplaceListingsexpects the new"Released":0key.Fail-before on unmodified
origin/main(temporary worktree, with only the criterion-1 test copied in, since it uses pre-existing APIs only):It passes on this branch. The other new tests call new store methods or the new columns, so on main they fail to compile or fail with SQLSTATE 42703. For those, the fail-before evidence is these mutation probes on this branch, each reverted afterwards:
revocation_in_flight = true AND revoked_at IS NULL(main's predicate)TestGetPurchaseHistoryInFlight_RequiresRecordedReturn,TestReleaseStaleRevocationsInFlight_RacesReturnRecord,TestFinalizeInFlightRevocations_FlagWithoutReturnIsNotRevokedTestFinalizeInFlightRevocations_RecordedReturnFinalizedStaleFlagReleasedTestCallAzureReturn_RecordsReturnBeforeMark,…_RetriesReturnRecordUntilItLands…_RetriesReturnRecordUntilItLandsreturn_posted_at IS NULLTestReleaseStaleRevocationsInFlight/only_stale_flags…TestReleaseStaleRevocationsInFlight/a_legacy_flag…revocation_in_flight_atTestReleaseStaleRevocationsInFlight/only_stale_flags…,…_RacesFlipnow() + interval)TestReleaseStaleRevocationsInFlight(both subtests),…_RacesFlipI didn't run whole integration packages,
-race ./...or git-secrets. Per the queue process, those run on the combined tree.Full-suite verification at local queue commit
825f8d41= origin/main95836478+fix/issue-1523. 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 integrationoninternal/api,internal/config,internal/database/postgres/migrations,internal/purchaseandinternal/server; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Owner decision (D25 in the acting-owner report): clear a stale flag after 2h (this PR) or leave it for an operator. Main today marks such rows revoked, so either choice is an improvement; switching to operator-only means dropping the clearing step.
Out of scope (noted, not changed)
return_posted_atwould be an API change for the owner. fix(revoke): make the Azure in-flight flip an exclusive claim so only one revoke posts a Return #1520 notes the same wording issue.MarkRevocationReturnPostedandMarkPurchaseRevokedfail on every attempt (a DB outage for the whole retry window), the user still gets RECONCILE_PENDING. The sweep won't finalize the row, and after the timeout it is released and logged. Azure refuses a retry, and an operator has to reconcile the row. The log line now saysreturn recorded=falsefor this case.handleAzureReturnErrorclears the flag and nothing is recorded. This is pre-existing and unchanged.purchase_idalone. As with the flip, the clear andMarkPurchaseRevoked, the new store methods key onpurchase_idalone rather than(provider, account_id, purchase_id). This is pre-existing (also noted on fix(config/db): compare-and-set the revocation in-flight flip; make 000059 re-applicable after a rollback #1516 and fix(revoke): make the Azure in-flight flip an exclusive claim so only one revoke posts a Return #1520).GetPurchaseHistoryInFlightkeeps its name. The documented contract ("Return succeeded, the DB write failed") is unchanged; only the predicate now matches it. Renaming it would have meant churn in other tests.Frontend follow-up for the UI owner
None required. No response shape changes. The task output's new
Releasedcount is backend-only.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,type/bug. The issue has noseverity/*,urgency/*,impact/*oreffort/*labels and isn'ttriaged.🤖 Generated with Claude Code