Skip to content

fix(history): set cloud_account_id on every purchase_history insert - #1244

Draft
IAmJSD wants to merge 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1235
Draft

IAmJSD wants to merge 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1235

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Every purchase_history row is written by one Go function: PostgresStore.SavePurchaseHistory, called only from purchase.Manager.savePurchaseHistory. That function stored exec.CloudAccountID as given. Only the multi-account fan-out sets it. On the single-account path it is nil:

  • direct execute: PlanID == "", and the account is taken from the recs;
  • legacy plan path: a plan with no plan accounts;
  • ambient path: the host's STS identity, no account.

On all three, resolveSingleAccountProvider resolves the account and stamps its ExternalID on AccountID, but nothing put the UUID on cloud_account_id. So new NULL rows kept appearing after the 000139 backfill (#1201).

Fix

SavePurchaseHistory now fills a missing cloud_account_id inside the INSERT itself. It uses COALESCE($17::uuid, (SELECT id FROM cloud_accounts ... FOR KEY SHARE)), which:

  • covers every writer, including future ones, at the single point all writes go through;
  • uses the backfill's rules exactly:
    • join on (provider, external_id), the cloud_accounts UNIQUE key;
    • provider compared lower-cased (legacy rows say AWS);
    • account id compared exactly, as the readers do;
    • an empty account id never matches;
  • keeps an explicit CloudAccountID from the caller, as before;
  • is not evaluated when the NOT EXISTS dedupe skips the insert, so an existing row is never rewritten.

Concurrency. The lookup locks the account FOR KEY SHARE.

  • Without the lock: an account delete that commits between the lookup and the FK check aborts the insert with an FK violation. The purchase has already happened, so the history row is lost (audit gap).
  • With the lock: the insert waits for an in-flight delete, finds no account, and stores NULL, the same value ON DELETE SET NULL would leave.
  • A delete that starts later waits for the insert and then clears the column itself.
  • Both orders are tested on real Postgres.

"Fail loudly". I did not make an unresolvable account refuse the row.

  • When a row has no account, the commitment has already been bought. Refusing the row would recreate the fix(api): skip plan disable on planned purchase delete when the plan spans out-of-scope accounts #621 audit gap.
  • Ambient-only deployments with no registered accounts are supported (SingleCloudAccountIDFromRecs returns nil for them). Refusing would hide every purchase they make.
  • Instead, such a row is stored with NULL and a Warnf names the provider and account id. Readers still match it by the external account_id.
  • A DB error in the lookup fails the statement, and so the save, as before.

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.

SavePurchaseHistory moved out of the 4.5k-line store_postgres.go into internal/config/store_postgres_history_save.go. The SQL now uses QueryRow ... RETURNING cloud_account_id IS NOT NULL so it knows when to warn. pgx.ErrNoRows keeps 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:

  • an account-constrained sell grant now matches such a row;
  • before, the row was unattributed and failed closed.

The 000139 backfill does the same for existing rows, and the marketplace path already resolved such rows to that account for signing (#560). TestMarketplacePermissionConstraintsHTTP:

  • the missing-account / own-missing-account cases now clear the resolved id after the save, so they still test a legacy unlinked row;
  • the new resolved-account case 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/null because this machine's default AWS profile is invalid. No cloud APIs were called.

Fail-before (base c8bdd460 plus only the new test file):

go test -tags integration -count=1 -run 'TestSavePurchaseHistory_(StampsCloudAccountID|ConcurrentAccountDelete|ConcurrentSavesAndDelete)' ./internal/config/
--- FAIL: TestSavePurchaseHistory_StampsCloudAccountID   (5 subtests: expected <uuid>, actual "")
--- FAIL: TestSavePurchaseHistory_ConcurrentAccountDelete (the save never resolves the account, so it never waits on the delete)

Pass-after:

  • go test -tags integration -count=1 ./internal/config/: ok
  • go test -tags integration -count=1 ./internal/api/: ok (273s, whole package)
  • go test -tags integration -count=1 -run '^TestMigration095_' ./internal/database/postgres/migrations/: ok
  • go test -count=1 on ./internal/config/ ./internal/mocks/ ./internal/purchase/ ./internal/api/: ok
  • go test -short -race -count=1 ./internal/config/: ok

Mutation probes (scratch edits of the SQL, each restored; the final file is byte-identical). All were caught:

Mutation Caught by
drop FOR KEY SHARE ConcurrentAccountDelete, ConcurrentSavesAndDelete (FK violation)
drop LOWER() legacy_upper-case_provider
drop the provider join ambient_aws_host_account, azure_row_with_the_shared_number, legacy_upper-case_provider
drop $1 <> '' empty_account_id
lookup wins over the caller's id an_explicit_cloud_account_id_is_kept, TestPGXMock_..._ResolvesAccountAndKeepsUnmatchedRow
no lookup ($17 only) the direct-execute and ambient subtests
case-insensitive external id subscription_differing_only_in_case

Static checks:

  • go build ./..., go vet (with and without -tags integration) and gofmt: clean.
  • golangci-lint v2.10.1 run --build-tags integration ./internal/config/... ./internal/api/...: 0 issues.
  • gocyclo -over 10 on the changed non-test files: silent.
  • gosec hook (scripts/gosec-hook.sh, v2.28.0): exit 0.
  • git-secrets (--register-aws as in CI, isolated HOME, fresh repo with the changed files git added):
    • clean;
    • a planted key and account id in the same repo were flagged;
    • scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

Coverage gaps:

  • This is store-level evidence on real Postgres, with rows shaped like each write path. No end-to-end purchase was run against a real cloud account.
  • The account-delete race is tested deterministically: a held delete transaction, with the save checked to be waiting on its lock in pg_stat_activity. There is also a 20-way concurrent smoke test.

Out of scope / notes

  • Existing race on the fan-out path. The fan-out path passes an explicit 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.
  • Accounts registered mid-save. An account registered concurrently with a save is not visible to that save's lookup, so the row stays NULL. Rerunning the 000139 backfill would link it.
  • NOT NULL constraint. Not added here; see "Fail loudly" above.
  • No migration, so no migration-number change.

Overlap with open PRs

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

IAmJSD and others added 2 commits October 22, 2026 10:11
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>
@coderabbitai

coderabbitai Bot commented Oct 22, 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.

@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/bug Defect labels Oct 22, 2026

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/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(history): set cloud_account_id on every purchase_history writer

1 participant