Skip to content

fix(exchange): show unknown-outcome auto-exchanges as needing reconciliation in the notification email - #1278

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1055
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1055

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Since go#497/#499, an auto exchange whose accept response was lost stays in AutoExchangeResult.Failed with OutcomeUnknown=true. The record stays processing, still counts against the daily cap, and the run halts after it.

The platform ignored the flag. buildExchangeNotificationData (internal/server/handler_ri_exchange.go) copied only Error. The issue says the exchange was then listed as failed, but on main it is worse than that: the completed-email template (riExchangeCompletedTemplate) lists only rows with an empty Error, so the unknown-outcome exchange disappeared from the email. A run whose only outcome was unknown arrived as "CUDly - RI Exchanges Completed (1 exchanges)" with an empty list, which reads as a success.

Fix

  • email.RIExchangeItem gets an OutcomeUnknown field, and buildExchangeNotificationData copies it from exchange.ExchangeOutcome.
  • RIExchangeNotificationData.ReconciliationExchanges() returns the flagged items. The completed template renders them first, in their own group:
    NEEDS RECONCILIATION - verify in AWS before acting. The group explains that AWS may or may not have executed them, says not to retry them, notes that they keep counting against the daily cap, and gives each exchange's source/target, quoted payment, record ID, the POST /api/ri-exchange/resolve/<record ID> request (the existing fix(exchange): reconcile stale processing RI exchange rows that now hold daily headroom indefinitely #692 operator-resolve endpoint) and the error detail.
  • The completed list now skips OutcomeUnknown rows even when Error is empty, so the flag alone decides the group.
  • The subject comes from one shared helper, riExchangeCompletedSubject, used by both the SNS/SES Sender and the SMTPSender. With any unknown outcome it reads CUDly - RI Exchanges Need Reconciliation (k of n exchanges). Otherwise it is unchanged (CUDly - RI Exchanges Completed (n exchanges)).
  • Definite failures keep their current handling (still left out of the completed email). No DB, API or concurrency change.

Example of the rendered body (from the test fixture):

NEEDS RECONCILIATION - verify in AWS before acting
--------------------------------------------------
...
- Source: ri-unknown (c5.large)
  Target: c6i.large x2
  Quoted payment: $34.00
  Record: rec-unknown
  Resolve: POST /api/ri-exchange/resolve/rec-unknown
  Detail: purchase outcome unknown: reconcile before retrying

The following RI exchanges have been automatically completed:
...

Closes #1055

Verification

All of this is unit/mock/fixture evidence. No real email was sent: the tests use the existing recording mockEmailSender (server), a recording SNS publisher stub, and the existing loopback mockSMTPServer (email). No cloud APIs were called. Environment: AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true, Go 1.26.9.

New tests:

  • internal/server/handler_ri_exchange_reconcile_test.go: TestSendExchangeNotification_OutcomeUnknownRendersReconciliation runs an auto result (completed + unknown outcome + definite failure, the go shape) through sendExchangeNotification, captures the data and renders the real completed template. The unknown exchange must sit in the reconciliation group before the completed group, and must not appear in either the completed list or (for the definite failure) the reconcile group.
  • internal/email/ri_exchange_reconcile_test.go: render tests (unknown outcome, unknown outcome with empty Error, no unknown outcome means no group), a subject table test, plus wire-level tests for Sender.SendRIExchangeCompleted (recorded SNS subject/message) and SMTPSender.SendRIExchangeCompleted (loopback SMTP server: Subject: header and body).

Fail-before: the server test compiles on unmodified main and fails there:

$ go test -count=1 -run 'TestSendExchangeNotification_OutcomeUnknownRendersReconciliation' ./internal/server/
--- FAIL: TestSendExchangeNotification_OutcomeUnknownRendersReconciliation (0.00s)
    handler_ri_exchange_reconcile_test.go:87: email has no "NEEDS RECONCILIATION" group for the unknown-outcome exchange:
        CUDly - RI Exchanges Completed
        ==============================

        The following RI exchanges have been automatically completed:

        - Source: ri-done (m5.large)
          ...
FAIL

(The unknown-outcome exchange ri-unknown is missing from main's email entirely.) The email-package tests need the new field, so they do not compile on main. Their fail-before evidence comes from the mutation probes below.

Pass-after and mutation probes (each mutation applied alone, then reverted):

Mutation Result
none all new tests PASS
internal/email/templates.go reverted to origin/main (template and SNS subject) ..._OutcomeUnknownNeedsReconciliation, ..._OutcomeUnknownWithoutError, TestSender_..._OutcomeUnknown, TestSMTPSender_..._OutcomeUnknown FAIL
completed list filter back to {{if eq .Error ""}} ..._OutcomeUnknownWithoutError FAIL
subject helper never picks the reconciliation subject TestRIExchangeCompletedSubject, TestSender_..._OutcomeUnknown, TestSMTPSender_..._OutcomeUnknown FAIL
builder drops OutcomeUnknown: o.OutcomeUnknown TestSendExchangeNotification_OutcomeUnknownRendersReconciliation FAIL

Commands run on this branch:

go build ./...                       # ok
go vet ./...                         # ok
go vet -tags integration ./...       # ok
go test -count=1 ./internal/email/   # ok
go test -count=1 ./internal/server/  # ok
go test -count=1 ./internal/mocks/   # ok
golangci-lint (v2.10.1) run --allow-parallel-runners ./internal/email/ ./internal/server/   # 0 issues
gocyclo -over 10 -ignore "_test\.go" <changed non-test files>                               # no output

git merge-tree against the other open RI-exchange PRs (#1108, #1170, #1191, #1212, #1246, #1249) and against the unopened fix/issue-326 and fix/issue-337 branches: all merge clean.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

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:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_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).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns 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.
  • Nothing was applied to any cloud.

Out of scope (noticed, not changed)

  • Definite failures (Failed without OutcomeUnknown) are still dropped from the completed email, as before. That is pinned by the existing TestRenderRIExchangeCompletedEmail_WithError. A run with only hard failures still sends "RI Exchanges Completed (n exchanges)" with an empty list. Listing them as a "Failed" group would be a separate change.
  • buildExchangeNotificationData never sets TotalPayment, so both RI exchange emails render Total Payment: $ with no amount.
  • The pending-approval email ranges over all of Exchanges, including Failed rows. That cannot carry an unknown outcome today, because manual mode never executes.
  • The resolve-request hint says "a few hours" rather than quoting config.RIExchangeResolveStaleAfter (6h), so the email package doesn't depend on internal/config.

Frontend follow-up for the UI owner

None needed for this fix: it changes only the notification email. Optionally, the RI exchange history view could badge processing rows older than the stale threshold as "needs reconciliation" and link to the resolve action.

Maintainer: please mirror the issue's triage labels onto this PR: triaged, priority/p3, severity/low, urgency/eventually, impact/few, effort/s, type/bug.

🤖 Generated with Claude Code

…liation in the notification email

Since go#497/LeanerCloud#499 an auto exchange whose accept response was lost stays in
AutoExchangeResult.Failed with OutcomeUnknown set. buildExchangeNotificationData
dropped the flag, and the completed-email template lists only rows with an
empty Error, so the exchange vanished from the email: a run whose only
outcome was unknown arrived as "RI Exchanges Completed (1 exchanges)" with
nothing listed.

Carry OutcomeUnknown into email.RIExchangeItem, render those exchanges first
in their own "NEEDS RECONCILIATION - verify in AWS before acting" group with
the record ID and the resolve request, keep them out of the completed list,
and switch the subject (SNS/SES and SMTP) to "RI Exchanges Need
Reconciliation (k of n exchanges)" when any are present. Definite failures
keep their current handling.

Closes LeanerCloud#1055

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/few Limited audience effort/s Hours type/bug Defect labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(exchange): show unknown-outcome auto-exchanges as needing reconciliation in the notification email

1 participant