Repository navigation
Conversation
…Status on pending finalizePurchaseStatus stamped "failed" over a freshly inserted execution with an unguarded full-row save whenever the approval email could not be sent. A session approve (pending -> running claim), cancel or pause can land while the email is in flight; the unguarded write then overwrote the row, so a commitment that was being bought ended up "failed" and eligible for retry, risking a second purchase. The write now goes through SaveExecutionIfStatus with "pending" as the only expected status (row lock + status check + write in one tx). A lost guard is a no-op with a warning: the row keeps the other writer's status, the caller's in-memory copy is left untouched, and the response reports the row's re-read status instead of a stale "failed". Other save errors keep the existing best-effort "pending" contract. Tests: mock unit tests pin the guarded call and the lost-guard behavior; integration tests on real Postgres drive the real send path with an approve claim landing during a blocked email send, and race the failed write against the approve claim for 40 rounds. Refs LeanerCloud#1232 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot be recorded
When the approval email failed, finalizePurchaseStatus tried to mark the
new execution failed. If that guarded save itself errored (anything other
than a lost status guard), it logged and returned "pending", so submit and
retry answered 200 {"status":"pending","email_sent":false}. The row
really was left pending, with no error text and a valid approval token,
and looked exactly like a healthy request waiting on its approver.
finalizePurchaseStatus now returns (string, error). On a save failure it
returns a 500 client error whose message starts "AUDIT LOSS:" and whose
body carries execution_id, email_sent=false and email_reason, so the
caller can find and cancel the row. The store error is wrapped (still
reachable with errors.Is) but kept out of the response body. executePurchase
and retryPurchase propagate it. The guarded write and the lost-guard
branch are unchanged. The submit-path tail moved into
sendApprovalAndFinalize to keep executePurchase within gocyclo 10.
Tests: mock unit tests for the save-error and missing-row cases, handler
tests for submit and retry that answered 200 "pending" before the fix,
and an integration test on real Postgres where the failure write runs on
a canceled context and the row stays pending.
Closes LeanerCloud#54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
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: rB9. 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. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1237
This branch is stacked on #1237 (
fix/issue-1232, head17654ccf) and must merge after #1237. Until #1237 merges, this PR's diff also shows #1237's commit (17654ccf, the guardedSaveExecutionIfStatuswrite). The change for this issue is the single commit91827b9aon top. Once #1237 is in main, the diff shrinks to that commit.Root cause
finalizePurchaseStatus(internal/api/handler_purchases.go) runs after the approval email send on the submit path (executePurchase) and the retry path (retryPurchase). When the send fails, it marks the new executionfailed. With #1237 that write isSaveExecutionIfStatus(..., []string{"pending"}). If the write failed for any reason other than a lost guard (connection reset, a deadline, a missing row), the function logged the error and returned"pending". Both callers put that into a 200 response:{"status":"pending","email_sent":false}.The row really was left
pending, with an emptyerrorcolumn and a valid approval token, while no approval email existed. It looked exactly like a healthy request waiting on its approver, and nothing in History orresolveOpsHintexplained it until the expiry sweep. This is the same silent save-failure pattern COR-10 fixed in LeanerCloud/cloud-commitments-cli#1237.Fix
finalizePurchaseStatusnow returns(string, error). On a save failure (thedefaultbranch) it returns a 500 client error built by the newemailFailureNotRecordedError:AUDIT LOSS:. It names the execution and says it may still show as pending with no approval email on the way, so cancel it from History.execution_id,email_sent: falseandemail_reason, so the UI or an API client can find the row and cancel it.%w(still reachable witherrors.Isand in the server log with anAUDIT LOSS:prefix) but kept out of the response body.executePurchaseandretryPurchasereturn the error instead of a response. On retry, the original row's link to the successor is unchanged, and the 500 names the successor.ErrExecutionNotInExpectedStatus(lost guard) branch are unchanged.sendApprovalAndFinalize, soexecutePurchasestays at gocyclo 10.Why a 5xx and not
failedor a new status: the row was not written, so answeringfailedwould be just as wrong aspending. It is also possible that the write committed and only the acknowledgement was lost, so the server cannot say which state the row is in. A new status value such asfailed_send_audit_losswould have to be added to the status set and handled by every client. A 500 is the option the issue and the 2026-07-28 review comment prefer ("the response should be a 5xx so the caller retries or alerts rather than recording a phantom pending"), and it matches how cli#1237 handled the same case (it returns theAUDIT LOSSerror). Noexecution_statusvalues change and there is no migration.Closes #54
Verification
Run on the Linux VM from the repo root with Go 1.26.9 and
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. The integration tests used real Postgres in testcontainers. No real email, cloud API or purchase was used: the email failure is the unconfigured-notifier path, and the store failures come from mocks or a canceled context.New and changed tests:
internal/api/handler_purchases_finalize_test.go(unit, mock store):TestFinalizePurchaseStatus_WriteErrorSurfacesreplaces fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237'sWriteErrorKeepsPending. It checks the 500, the body fields,errors.Is(err, saveErr), that the store error is not in the body, that the caller's copy is not mutated, and that there is no re-read.TestFinalizePurchaseStatus_MissingRowSurfaces:ErrNotFoundfrom the guarded save is also a save failure.TestExecutePurchase_EmailFailureNotRecorded_Returns500andTestRetryPurchase_EmailFailureNotRecorded_Returns500drive the full submit and retry handlers. Each one asserts the error and that no success body comes back. They also run the error throughhandleRequestError, the router's error mapping, and check the status code and body.internal/api/handler_purchases_finalize_race_integration_test.go(integration, real Postgres):TestFinalizePurchaseStatus_SaveFailureSurfacesinserts a real pending row throughpersistExecutionAndSuppressionsand fails the realsendPurchaseApprovalEmail(no notifier). It then runsfinalizePurchaseStatuson a canceled context, so the guarded save fails inside pgx. It asserts the 500,errors.Is(err, context.Canceled), and that the stored row is stillpendingwith an empty error. fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's two integration tests now also require a nil error.Fail-before / pass-after:
The temporary file was deleted afterwards.
Other checks:
This is unit, mock and local integration evidence only. A real SES failure combined with a real database write failure was not exercised.
Combined-tree verification. This branch was tested together with six other queued branches on a local, never-pushed tree:
queue/platform@77d116c6= origin/maina14dc8f2plus these heads, merged in this order:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration, andgocyclo -over 10is silent on the changed non-test files. These ran atb61a9c30, which differs from77d116c6only by one.gitallowedline on fix/issue-1233.-tags integration -count=1):cmd/cleanup-lambda,internal/analytics,internal/auth,internal/database/postgres/migrationsandinternal/purchase.internal/apipasses, except two tests whose Postgres container didn't start, or whose migrations hit a timeout, under machine load. Rerun alone on the same tree, both pass:TestDeleteManagedPlanByExecutionStatusandTestNotifiedRow_RescheduleDoesNotOverwriteConcurrentCancel.internal/configpasses exceptTestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).TESTCONTAINERS_RYUK_DISABLED=trueto avoid a testcontainers reaper race under load.scripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.Out of scope
directExecutePurchase, is unchanged. The issue calls it best-effort attribution, and the 2026-07-28 review tracks it in its groupedinternal/apiissue.Frontend follow-up for the UI owner
On this rare path, submit (
POSTpurchase execute) and retry now return HTTP 500 instead of 200. The JSON body is{"error": "AUDIT LOSS: ...", "execution_id": "...", "email_sent": false, "email_reason": "..."}. The UI should already show theerrormessage as a failure. Optionally, whenexecution_idis present on a 500, link to that execution in History so the user can cancel it.Maintainers: please mirror the issue's triage labels onto this PR:
priority/p3,severity/low,urgency/eventually,impact/internal,effort/s,type/bug,triaged.🤖 Generated with Claude Code