Repository navigation
fix(history): set cloud_account_id on every purchase_history insert - #1244
Conversation
The direct-execute, ambient (host-account) and legacy single-account paths reach SavePurchaseHistory with no CloudAccountID, so their rows were stored with cloud_account_id NULL even after the 000139 backfill. SavePurchaseHistory now resolves a missing cloud_account_id in the INSERT from (provider, account_id) with the backfill's rules: lower-cased provider, exact account id, never an empty id. The lookup locks the account FOR KEY SHARE so a concurrent account delete makes the row NULL instead of failing the insert on the foreign key. A row that matches no registered account is still stored (dropping it would be an audit gap, LeanerCloud#621) and logged as a warning. The function moves to its own file out of store_postgres.go. The marketplace constraint test's "missing account" rows now clear the resolved account to keep a legacy unlinked row, and a new case covers a host-account row the save links to its registered account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aces Real-Postgres tests for LeanerCloud#1235: rows shaped like each write path get their account, an explicit id wins, rows that match nothing are kept with NULL, the dedupe path does not rewrite a row, and a save racing an account delete stores NULL instead of failing on the foreign key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review 2 (claude-fleet, cold): APPROVE bc2581c Findings (none blocking):
Attacked:
Verified locally, with GOTOOLCHAIN=go1.26.10 and no cloud credentials, under the heavy lock, on testcontainers Postgres:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE bc2581c Findings (none blocking):
Attacked:
@IAmJSD CI on this head is red only from the old merge base (govulncheck on go1.26.9, fixed on main by #1236). Please merge or rebase onto current |
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>
SavePurchaseHistory now fills cloud_account_id from the registered account with the same (provider, external_id). The History summary (LeanerCloud#1200) and History page (LeanerCloud#1277) fixtures seed "legacy" rows by saving with a nil CloudAccountID next to a registered account, so after the merge those rows came back linked and the drift guards no longer exercised the unlinked, external-id-only shape they were written for. Clear the column after the save so the fixtures still seed legacy rows, and require that exactly one row was cleared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged current main (6b586cc) into the branch. The merge was clean, with no conflicts. New head: The #1200 History summary fixtures. The summary tests now saw linked rows where they expected unlinked ones. Both summary fixtures seed "legacy" purchase_history rows by calling New commit b064b33 (
Checks on the merged tree, with no cloud credentials:
|
|
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: rC1. 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: 1 approving independent review(s) on |
Root cause
Every purchase_history row is written by one Go function:
PostgresStore.SavePurchaseHistory, called only frompurchase.Manager.savePurchaseHistory. That function storedexec.CloudAccountIDas given. Only the multi-account fan-out sets it. On the single-account path it is nil:PlanID == "", and the account is taken from the recs;On all three,
resolveSingleAccountProviderresolves the account and stamps itsExternalIDonAccountID, but nothing put the UUID oncloud_account_id. So new NULL rows kept appearing after the 000139 backfill (#1201).Fix
SavePurchaseHistorynow fills a missingcloud_account_idinside the INSERT itself. It usesCOALESCE($17::uuid, (SELECT id FROM cloud_accounts ... FOR KEY SHARE)), which:(provider, external_id), the cloud_accounts UNIQUE key;AWS);CloudAccountIDfrom the caller, as before;Concurrency. The lookup locks the account
FOR KEY SHARE.ON DELETE SET NULLwould leave."Fail loudly". I did not make an unresolvable account refuse the row.
SingleCloudAccountIDFromRecsreturns nil for them). Refusing would hide every purchase they make.Warnfnames the provider and account id. Readers still match it by the externalaccount_id.If you'd rather refuse, say so: it's a one-line change. The NOT NULL constraint the issue mentions should wait until it's clear that ambient-only deployments can't produce unlinked rows.
SavePurchaseHistorymoved out of the 4.5k-linestore_postgres.gointointernal/config/store_postgres_history_save.go. The SQL now usesQueryRow ... RETURNING cloud_account_id IS NOT NULLso it knows when to warn.pgx.ErrNoRowskeeps the old "already recorded" path.Behavior change worth a look
New host-account rows written by the ambient path are now linked to the registered account with the same external id. That makes them visible to account-scoped permission checks:
sellgrant now matches such a row;The 000139 backfill does the same for existing rows, and the marketplace path already resolved such rows to that account for signing (#560).
TestMarketplacePermissionConstraintsHTTP:missing-account/own-missing-accountcases now clear the resolved id after the save, so they still test a legacy unlinked row;resolved-accountcase pins the new behavior.Verification
All on macOS against real Postgres 16 via testcontainers. I ran the tests with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. No cloud APIs were called.Fail-before (base
c8bdd460plus only the new test file):Pass-after:
go test -tags integration -count=1 ./internal/config/: okgo test -tags integration -count=1 ./internal/api/: ok (273s, whole package)go test -tags integration -count=1 -run '^TestMigration095_' ./internal/database/postgres/migrations/: okgo test -count=1on./internal/config/ ./internal/mocks/ ./internal/purchase/ ./internal/api/: okgo test -short -race -count=1 ./internal/config/: okMutation probes (scratch edits of the SQL, each restored; the final file is byte-identical). All were caught:
FOR KEY SHAREConcurrentAccountDelete,ConcurrentSavesAndDelete(FK violation)LOWER()legacy_upper-case_providerambient_aws_host_account,azure_row_with_the_shared_number,legacy_upper-case_provider$1 <> ''empty_account_idan_explicit_cloud_account_id_is_kept,TestPGXMock_..._ResolvesAccountAndKeepsUnmatchedRow$17only)subscription_differing_only_in_caseStatic checks:
go build ./...,go vet(with and without-tags integration) andgofmt: clean.golangci-lint v2.10.1 run --build-tags integration ./internal/config/... ./internal/api/...: 0 issues.gocyclo -over 10on the changed non-test files: silent.scripts/gosec-hook.sh, v2.28.0): exit 0.--register-awsas in CI, isolated HOME, fresh repo with the changed filesgit added):scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.Coverage gaps:
pg_stat_activity. There is also a 20-way concurrent smoke test.Out of scope / notes
account.ID. If that account is deleted mid-purchase, the insert can still fail on the FK, which is an audit gap. I kept explicit ids FK-enforced rather than silently nulling a bogus id. Worth a separate issue if you want it closed too.Overlap with open PRs
SavePurchaseHistorywithCloudAccountID: niland a registeredaccount_id. After this PR, those rows get linked automatically. If the fixtures need truly unlinked rows, they need the sameUPDATE ... SET cloud_account_id = NULLused here, or a raw INSERT.SavePurchaseHistorywith no matching registered account (or with an explicit id), so no behavior change is expected.handler_purchases.goandpurchase/*, not this code, so no conflict.Frontend follow-up for the UI owner
None needed. History already matches rows by either id.
Closes #1235
Maintainers: please mirror the issue's triage labels on this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/s,type/bug,triaged.🤖 Generated with Claude Code