Repository navigation
Conversation
…tified executeAndFinalize skipped the failure notification for a multi-account fan-out root on the assumption that every per-account row had notified for itself. A child the fan-out skips once the context is done, or one whose panic it recovers, never stamps or saves its row, so its own deferred notification is a no-op and the run ended failed or partially_completed with no purchase_execution_failed marker and no email. The root now always logs the marker (the alarm source) and still sends no email, so a child that did notify is not mailed twice. Refs LeanerCloud#1195 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only executeAndFinalize and the fan-out children called notifyExecutionFailure. The other writers that end an execution row failed raised no purchase_execution_failed marker and sent no email: - failClaimedBeforeSubmission (post-claim cap and authorization refusals) - safeFail (stranded approved rows the recovery sweep fails) - the reaper (stuck approved/running rows) - failExistingPlanStep and the new failed row a notification tick records for a plan step that will never be bought Each now notifies once it has won the row (the running claim, the CAS to failed, or the plan lock), so racing workers cannot both alert. The reaper stays quiet when the executor recovered the row before the reap annotation landed, since that purchase did not fail. Existing tests that end a row failed now expect the one purchase-failed email. Refs LeanerCloud#1195 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
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 |
Resolve the conflict in internal/purchase/notifications.go with LeanerCloud#1176: failExistingPlanStep now runs the pinned CAS, reason stamp and suppression release in one transaction, so the LeanerCloud#1195 failure alert moves after that transaction commits. A lost CAS or any error before commit leaves the row pending and sends nothing; the unit table now expects no alert for a rolled-back reason save or suppression release. Alert from failClaimedRedrive (added by LeanerCloud#1095): the sweep that holds the running claim reports the failed row once, even when its terminal save fails, like failClaimedBeforeSubmission. The LeanerCloud#1095 integration test now expects exactly that one purchase-failed email. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged origin/main (a14dc8f) into the branch to resolve the conflict in Conflict:
Overlap with #1095 (
PR body updated: the writer list, the plan-step and redrive bullets, the test list and the overlap notes. New head: 0bd8bf0 Verification (merged tree,
The govulncheck jobs may still fail on GO-2026-6809 (Go 1.26.9) until #1236 lands. That is unrelated to this PR. |
Root cause
notifyExecutionFailure(thepurchase_execution_failedlog marker plus the purchase-failed email) was only called fromexecuteAndFinalizeand from each per-account fan-out child inexecuteForAccount. Two gaps followed.executeAndFinalizeskipped the root of a multi-account fan-out (isFanOutOutcome) because it assumed every child had already notified. Two kinds of child never do.FanOutWithConcurrencyrecordsctx.Err()for an account it skips at the semaphore once the context is done, and it recovers a panicking child (internal/execution/fanout.go). In both cases the child never stamps or saves its row, so its deferred notify sees a non-terminal status and does nothing. The root then endedfailedorpartially_completedwith no marker and no email. The audit-loss child path was already covered: the child stamps its status before the save, so its deferred notify still fires. A test now pins that.notifyExecutionFailureat all:failClaimedBeforeSubmission: post-claim cap re-check andClaimedRowCheckrefusals.safeFail: stranded approved rows thatRecoverStrandedApprovalsfails.reapOne).failClaimedRedrive(from fix(purchase): re-drive the claimed row, not the pre-claim snapshot #1095): a stranded row the recovery sweep claimed but whose claimed state no longer qualifies for a re-drive.failExistingPlanStep, and the new failed row thatfailSteprecords inside the notification tick's transaction.Fix
failure_notify.go: the marker logging is split intologExecutionFailure. A newnotifyRootFailurehandles the root row inexecuteAndFinalize. A fan-out root always logs the marker (so the alarm fires) and sends no email, as the issue proposed: children that did notify are not mailed twice, and the root has no per-recPurchasedflags it could list. Every other root works as before.failClaimedBeforeSubmissionholds therunningclaim. It notifies even when the terminal save fails, the same wayexecuteAndFinalizedoes.safeFailnotifies only after winning theapproved -> failedCAS. A lost CAS stays quiet.failClaimedRedriveholds therunningclaim and notifies even when the terminal save fails, likefailClaimedBeforeSubmission.failExistingPlanStepnotifies only after its transaction commits. Since fix(purchase): fail a plan step's root row atomically with its reason and suppression release #1176 the pinned CAS, the reason stamp and the suppression release share one transaction, so a lost CAS or any error before commit leaves the row pending and sends nothing.stepClaim.failedNewnow carries that row instead of a bool.ErrExecutionNotInExpectedStatus). That purchase did not fail, andTestSingleClaim_ReaperInterleavingRecoversPurchasedRowcovers this interleaving on real Postgres. The annotation step moved intoannotateReapedRow, and the counters behave the same as before.No schema or migration changes.
Closes #1195
Verification
All evidence is unit tests with mocks plus local Postgres integration tests (testcontainers). No real cloud, SMTP or SES calls were made. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid.New tests:
failure_notify_writers_test.gotests the fan-out root:TestLogExecutionFailure_LogsMarkerOnlyForFailedOrPartialRowspins the marker line itself. It catches the review's M11 mutation: deleting thelogging.Errorfnow fails this test and 3 others.TestNotifyRootFailure_FanOutRootLogsMarkerWithoutEmailcovers the skipped-child and panicked-child aggregates.TestFanOutRootAlertsWhenPanickedChildNeverNotifiesruns end to end through the SQS executor. The child's credential load panics, and exactly one root marker and no email are expected.TestFanOutAuditLossChildStillNotifiesAndRootAddsMarkercovers a child whose save fails: the child sends the marker and an email, and the root sends a marker and no email.failure_notify_paths_test.gohasTestTerminalFailureWritersAlertOncePerWonRow. Each writer is run with a won CAS, a won CAS whose follow-up save fails, and a lost CAS. Each case asserts exactly one marker and one email, or none. ForfailExistingPlanStepa failed reason save or suppression release rolls the transaction back, so those rows expect no alert.-tags integration):TestFailExistingPlanStep_ConcurrentTicksReportOnce: 4 goroutines fail the same pre-created step row, and exactly one email is sent.TestGetOrCreateExecution_ConcurrentTicksMintOneRownow also asserts one failure email across 4 racing ticks.TestAttachIntegration_NoMatchAndCapasserts one email for the cap-failed step.TestRecoverStrandedApprovals_ClaimedRowTurnedUnsafeIsFailedNotBought(from fix(purchase): re-drive the claimed row, not the pre-claim snapshot #1095, real Postgres) now also asserts exactly one purchase-failed email for the claimed row thatfailClaimedRedrivefails.TestClaimPin_PostClaimPlanReadErrorFailsClosed,TestManager_RecoverStrandedApprovals_FailsStrandedRow, andTestNotificationTickOn...(renamed...SendsNoApprovalEmail, since it now sends the failure email but still no approval email).Fail-before/pass-after: I reverted only the production changes (the fan-out root back to marker-free, and the new notify calls removed) and kept the new tests. Every new and updated test above failed (16 failing tests/subtests), and all pass with the fix.
Commands run (all green):
Coverage gaps: the alarm wiring (
terraform/modules/monitoring/*) is unchanged, the token and format are unchanged, andTestMonitoringLogMarkersMatchGostill passes. No deployed alarm or real mail delivery was exercised.Behavior notes for review
runningrow while a live executor later fails it as well, both the reaper andexecuteAndFinalize(on its audit-loss path) alert for that row. The 10-minute reap threshold sits well above the 270s executor budget, so this needs an executor that outlived its Lambda.safeFailrows the row'sErrortext explains that the commitment may exist, but the email does not. The template is unchanged here.Out of scope / follow-ups
Closesdoes not drop them:SMTPSender.dispatchSMTPandsendMailTLSnever receive the context (smtp.Dialandsmtp.SendMail), which affects every SMTP send, not just this path.purchaseExecutionTimeout(4m) +terminalSaveTimeout(30s) + the detachedfailureNotifyTimeout(30s) after the save = 300s =lambda_timeout. Fixing it means either raising the Terraform/CFN floor and the tfvars (a deploy-affecting change) or shrinking a budget, so it needs a maintainer decision. This PR adds no notify inside the purchase budget: the new call sites run before the purchase, in sweeps, or in the notification tick.api.(*Handler).finalizePurchaseStatusmarks a rowfailedwhen the approval email cannot be sent. It is left unchanged: that failure is returned synchronously to the user who submitted, and it is a mail failure, so sending another email would not help.failExistingPlanStep's CAS into the transaction; this branch now notifies after that transaction commits.failClaimedRedrive; this branch now notifies from it.manager.goandnotifications.go.Frontend follow-up for the UI owner
None.
Labels: please mirror the issue's triage labels on this PR:
priority/p2,severity/medium,impact/internal,triaged,urgency/this-quarter,effort/s,type/bug.Known unrelated CI failure: govulncheck (GO-2026-6809, crypto/x509 in Go 1.26.9) may fail "Security Scanning" / "Build Docker Image" until #1236 lands.
🤖 Generated with Claude Code