Skip to content

fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by - #1253

Merged
cristim merged 25 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-47
Nov 6, 2026
Merged

cristim merged 25 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-47

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This is the contract half of the cancelled -> canceled rename (#47), as safe deploy steps. Migration 000089 was the expand step (#1277): it widened both status CHECKs and added canceled_by. This PR:

  • Migration 000157 normalizes the legacy 'cancelled' status rows to 'canceled' in purchase_executions and ri_exchange_history, then narrows both status CHECKs to the canonical spelling.
  • Writes go only to canceled_by. SavePurchaseExecution writes canceled_by = COALESCE(purchase_executions.canceled_by, $17), so a save from a stale copy can't erase a recorded actor. transitionExecutionStatus and SetCancelledBy no longer dual-write cancelled_by.
  • Reads stay tolerant. All execution projections read COALESCE(canceled_by, cancelled_by), and config.LegacyStatusCanceled stays in every status list and switch.

The strict reads, the drain of cancelled_by and the column drop are in #1747. They can ship once 000157 has run everywhere and no rollback can reach a build older than this one.

Refs #47

Takeover reasoning (claude-cc-platform, 2026-11-04)

I took this PR over from @IAmJSD's draft (head e9fd9445) and decided the open points from the issue and the code.

  • Number. The migration was 000153, which main already uses (000153_marketplace_listing_settlements). Under the coordinator's policy (2026-11-05), migration numbers are assigned at merge time. Just before merging, this PR renumbers 000157 to main's highest + 1 and re-runs its migration and integration tests. golang-migrate applies only versions above the current one, so it must never merge with a number at or below main's highest.

  • Tolerant reads stay. The draft switched reads to canceled_by alone and dropped the legacy spelling. With new code running before the migration (dev auto-migrates only on start, and prod dispatches migrations separately), that misreads unmigrated rows:

    • an actor lost on legacy rows;
    • legacy canceled rows counted as live, which blocks account deletion;
    • ramp steps left outstanding;
    • cleanup skipping those rows.

    A schema gate would need a fallback read for the not-ready case, and that fallback is the tolerant read. So the tolerant reads stay, and the strict reads move to chore(db): finish the canceled rename: drain and drop cancelled_by, strict reads #1747.

  • The migration is slimmed. The draft's trigger mirrored cancelled_by into canceled_by, and the draft drained it and backfilled on down. Under tolerant reads nothing reads canceled_by alone, so those steps had no caller. Removing them means the down migration only re-widens the CHECKs. The drain belongs in the column-drop migration (chore(db): finish the canceled rename: drain and drop cancelled_by, strict reads #1747).

  • updated_at is preserved. The normalize UPDATEs run with update_purchase_executions_updated_at (000128) and ri_exchange_updated_at (000009) disabled. Otherwise every legacy row would look freshly changed to plan-health scoring (CountExecutionsByPlanAndStatus), to retention (CleanupOldExecutions ages canceled rows by updated_at) and to the audit trail. DISABLE TRIGGER is transactional: if the file's transaction fails, the triggers come back enabled.

  • Correction to the draft body. It said no read of cancelled_by remains. store_postgres_history_page.go still reads it through COALESCE(canceled_by, cancelled_by), as it does on main, and this PR keeps that.

Safe in both orders

Rollout

  • Before prod: the "every build since feat(api): page the History view with a keyset cursor (has_more, next_cursor) #1277 writes only 'canceled'" claim is a deploy fact, not a code fact. Confirm that no pre-feat(api): page the History view with a keyset cursor (has_more, next_cursor) #1277 image can still run in prod or be rolled back to, or that build's cancel would fail the narrowed CHECK (as-qa-2).

  • Lock time: the narrowed CHECKs validate both tables under ACCESS EXCLUSIVE. That's fine at current sizes. If either table grows large, switch to ADD CONSTRAINT ... NOT VALID plus VALIDATE CONSTRAINT (as-qa-2).

  • dev: once dev reads main's highest migration with dirty = false, the merge deploy auto-applies this one. Dispatching -f steps=1 first works too.

  • prod: either order is safe: -f environment=prod -f steps=1 then the release, or the release first. Check that schema_migrations reads the version just below this one first.

  • Before merging, renumber to origin/main's highest + 1. A migration numbered at or below main's highest would be skipped silently.

Tests (each shown to fail without its fix)

  • TestMigration_000157_ContractCanceledSpelling runs from the version below 159 up to 159, down, and up again. It covers normalization, the narrowed CHECKs (SQLSTATE 23514 on the legacy spelling), the exact CHECK text restored by down, updated_at unchanged on both tables with both triggers enabled again, and main's write statements. Each probe fails it: no normalize, no trigger disable on either table, no re-enable, and an un-narrowed ri_exchange_history CHECK.
  • TestPGXMock_ExecutionProjectionsReadCoalescedActor fails on the draft's strict reads, and when the COALESCE is removed from one projection.
  • TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy fails on each restored dual write, on a plain $17 in the save, and on a save that writes the legacy column.
  • TestPostgresStore_NewCodeAgainstPreContractSchema fails on the draft's code with a lost actor, a blocked account delete, a dropped legacy actor on save and cleanup deleting 0 rows instead of 1.
  • TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration fails on the draft's code: the cancel race errors where it should recover.

Database-backed fixtures that inserted 'cancelled' were removed, because at the latest schema the CHECK rejects that spelling. The two pre-contract tests cover legacy rows instead. Unit tests keep their legacy fixtures.

Verification

Head 13befc8aecdea8263b0e771b76127a26cdc6c2bf, merged onto main a8c34d44 (main's highest migration: 000156). Local run on macOS, with real Postgres via testcontainers and synthetic fixtures. Nothing was applied to any cloud.

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass on the full suite.
  • go test -tags integration passes for internal/api, internal/config, internal/database/postgres/migrations and internal/purchase, plus ./internal/database/... with every migration applied.
  • golangci-lint v2.10.1 reports 0 issues for config, api, purchase and migrations.
  • The derived pre-contract cutoff resolves to 000156 on this tree: TestPostgresStore_NewCodeAgainstPreContractSchema logs "Migrated to version 156" and passes.
  • Fix round for the reviews at bc919da2:

Noted

🤖 Generated with Claude Code

…HECKs, stop using cancelled_by

Migration 000144 is the contract half of 000089. Under a table lock it
normalizes every legacy 'cancelled' status in purchase_executions and
ri_exchange_history, drains legacy-only actors into canceled_by, and
narrows both status CHECKs to the 000089 lists minus 'cancelled'.

The code stops reading and writing purchase_executions.cancelled_by:
reads project canceled_by, the cancel transition and SetCancelledBy
write only canceled_by, and SavePurchaseExecution writes canceled_by,
keeping an actor already on the row (the value reads saw before). The
LegacyStatusCanceled constant and every dual-spelling branch are gone.

The cancelled_by column is kept. The previous build still reads
COALESCE(canceled_by, cancelled_by) and writes cancelled_by, and it
keeps serving during a rolling deploy and after a code-only rollback,
so dropping the column in the same release would break every execution
read on it. A trigger mirrors any legacy-only actor write into
canceled_by until a follow-up migration drops the trigger and column.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged urgency/this-quarter Within the quarter effort/s Hours labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between a8c34d4 and 13befc8.

Files selected for processing: 19

  • internal/api/handler_purchases_cancel_actor_integration_test.go
  • internal/api/handler_purchases_legacy_cancel_integration_test.go
  • internal/api/handler_purchases_submit_dedupe_integration_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_cancel_actor_pgxmock_test.go
  • internal/config/store_postgres_db_test.go
  • internal/config/store_postgres_delete_account_integration_test.go
  • internal/config/store_postgres_history_summary_integration_test.go
  • internal/config/store_postgres_ladder_resolve_integration_test.go
  • internal/config/store_postgres_legacy_canceled_integration_test.go
  • internal/config/store_postgres_ramp_baseline_integration_test.go
  • internal/config/store_postgres_ramp_step_integration_test.go
  • internal/config/store_postgres_retention_integration_test.go
  • internal/database/postgres/migrations/000157_contract_canceled_spelling.down.sql
  • internal/database/postgres/migrations/000157_contract_canceled_spelling.up.sql
  • internal/database/postgres/migrations/000157_contract_canceled_spelling_test.go
  • internal/purchase/ladder_reconcile_integration_test.go
  • known_issues/41_cancel_actor_strict_reads.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

IAmJSD and others added 2 commits October 25, 2026 16:11
Bring the branch up to date with main.

Conflicted files:
- internal/api/handler_history.go: main (LeanerCloud#1200) moved the per-row summary
  bucketing out of summarizePurchaseHistory into addHistoryRowToSummary and
  addNonCompletedRowToSummary so the SQL aggregate and the row list share
  one set of rules. The branch had dropped config.LegacyStatusCanceled from
  the canceled case in the old inline switch. Kept main's refactor and
  applied the branch's change to addNonCompletedRowToSummary: only
  config.StatusCanceled is matched, since 000144 makes the legacy spelling
  unstorable and the branch removes the constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry test

The test came in from main (LeanerCloud#1200) and seeds executions with
LegacyStatusCanceled and queries it as an execution status. This branch
removes that constant, and migration 000144 rejects the 'cancelled'
status, so the seed would fail the narrowed CHECK. Seed and query only
the canonical spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (1c5787a) into this branch. New head: fb1d654. A trial merge with the current main (c1f1781) is clean and passes go vet -tags integration ./.... Main added no new cancelled_by, 'cancelled' or LegacyStatusCanceled uses after that point.

Conflict resolution:

  • internal/api/handler_history.go: main (feat(api): scope-aware SQL totals for the History summary cards #1200) moved the per-row summary bucketing out of summarizePurchaseHistory into addHistoryRowToSummary and addNonCompletedRowToSummary, so the SQL aggregate and the row list share one set of rules. This branch had dropped LegacyStatusCanceled from the canceled case of the old inline switch. I kept main's refactor and made the same change in addNonCompletedRowToSummary, which now matches only config.StatusCanceled.

Follow-up commit (a semantic conflict that merged without a textual conflict):

Checks on the merged head:

  • build, vet (also with -tags integration) and e2e vet pass.
  • golangci-lint found 0 issues with and without the integration tag, and gocyclo is silent.
  • go test -race -short passes on cmd/cleanup-lambda, api, config, migrations and purchase.
  • go test -race -tags integration passes on the same five packages, run one at a time. On a heavily loaded shared VM, the first runs of several packages failed with failed to start postgres container ... wait until ready ... context deadline exceeded (testhelpers' 30s startup timeout). Each package then passed on rerun, either whole or by re-running only the tests that had failed.
  • The secrets scan is clean and the planted key was flagged. The allowlist test passes.

One thing to watch when merging: #1277's new ListHistoryExecutionsPage still projects COALESCE(canceled_by, cancelled_by). Whichever of the two PRs merges second should switch it to plain canceled_by.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…ation 000145 -> 000141

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241,
LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…000140

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140, 000141, 000143 and
000144 belong to PRs held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253); this
PR takes 000140 so it can merge ahead of them, and they will take the
next free numbers when decided. Rename only: the SQL is unchanged, and
the test's version constant and the comments naming the migration
follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…7 -> 000143

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241,
LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…-> 000145

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241,
LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…148 -> 000144

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241,
LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Migration renumbering needed before this merges. The reviewed, ready PRs have taken 000140-000145 so they could merge without waiting on owner decisions: #1240 (000140), #1302 (000141), #1268 (000142), #1287 (000143), #1289 (000144), #1116 (000145). This PR's migration(s) (000144) must be renumbered to start at 000146, or the next free number at that time, when it's ready to merge (000144 -> 000146 or the next free number). golang-migrate skips a version below the database's current one, so a lower number merged after those would never apply.

Dependency check: none of the six ready migrations touches purchase_executions or ri_exchange_history, their status CHECKs, or cancelled_by/canceled_by, so the order doesn't matter beyond the number. That ordering hasn't been run.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…n 000146 -> 000142

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241,
LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit that referenced this pull request Oct 26, 2026
…d of the full list (#1240)

* fix(api): add opt-in keyset pagination to GET /api/recommendations

The list endpoint returned the whole filtered recommendations cache in a
single body, so a tenant with a few thousand cached rows pushed the
response past the 6 MB Lambda payload cap and the page failed with 502.
Gzip (#775) raised that ceiling but did not bound it.

Callers can now send limit (1-1000) and the returned next_cursor to read
the list in pages ordered by recommendation ID. The cursor is an opaque
base64url encoding of the last ID seen, so a row deleted between requests
does not shift later pages. summary and regions still describe the full
filtered set on every page, and compute capacity is only decoded for the
returned rows. Requests without limit or cursor keep the existing
unpaginated response, so the current frontend is unaffected until it
adopts the parameters. openapi.yaml documents the parameters and the
actual response shape.

Refs #777

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): cap recommendation pages by serialized bytes and reject empty paging params

A page was bounded only by row count, but the opaque Details blob makes a
row's size data-dependent, so 1000 large rows could still pass the 6 MiB
Lambda response cap. Pages now also stop once the rows, measured as they
appear inside the Lambda response envelope (the body re-encoded as a JSON
string, so quotes and backslashes count twice), plus the summary, regions,
keys and next_cursor would pass a 4 MiB budget. The budget is checked
against uncompressed bytes because the #775 gzip only runs when the client
accepts it. A page always holds at least one row, so a row that alone is
over the budget is returned on its own page with a logged warning instead
of stalling the cursor walk.

Legacy unpaginated mode is now selected only when neither limit nor
cursor is present. A present but empty ?limit= or ?cursor= returns 400
instead of falling back to the unbounded response.

Refs #723

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): reject duplicate recommendation IDs when paging and document 400s

- Correct the ordering comment: the paging key is the application
  recommendation ID built from the natural-key columns, not the cache
  table's UUID primary key. A paginated request now fails if two rows
  share an ID, since the strict "after" comparison would skip one of
  them at a page boundary.
- Describe the feature as opt-in in-memory cursor slicing, not a SQL
  keyset (store-side paging is #981).
- TestGetRecommendations_UnpaginatedShapeUnchanged copies the expected
  IDs before the call so an in-place reorder in legacy mode fails it.
- Add TestGetRecommendations_PaginationRejectsDuplicateIDs.
- openapi: document the 400 response for bad limit/cursor/filter
  values, and say next_cursor's absence is the only end-of-list signal.

Refs #723

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(db): index recommendations by byte-wise payload id for keyset scans

Migration 000142 adds recommendations_scan_key_idx on
(COALESCE(payload->>'id', '') COLLATE "C", id). The paginated
GET /api/recommendations scan (issue #981) walks the cache in chunks in
that order and resumes each chunk after the last (id, primary key) it
read; without the index every chunk is a full scan plus a sort.

COLLATE "C" is byte order, the same order Go's string comparison uses,
so SQL paging keeps the order the API already returns.

Refs #981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(config): read stored recommendations in keyset chunks

ListStoredRecommendationsChunk returns at most limit rows matching a
RecommendationFilter, ordered by the byte-wise recommendation ID and then
the table's UUID primary key, resuming strictly after a given position.
The primary key keeps rows that share an ID in a total order, so a chunk
boundary inside such a run neither skips nor repeats a row. The
MinSavingsPct floor stays in-process (shared with
ListStoredRecommendations via decodeStoredRecommendation); rows it drops
still advance the position.

Integration tests pin the order to Go's sort.Strings on IDs that a
linguistic collation orders differently, cover duplicate IDs across every
chunk size, the pct floor emptying a whole chunk, the empty table, an
EXPLAIN check that recommendations_scan_key_idx serves the query without
a Sort node, and walks racing concurrent upserts.

Refs #981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scheduler): scan cached recommendations chunk by chunk

ScanRecommendations walks the recommendations cache through
ListStoredRecommendationsChunk in byte-wise ID order and applies the same
suppressions and account overrides as ListRecommendations to each chunk,
handing the survivors to a callback. It holds one chunk at a time instead
of the whole filtered set. Suppressions are read once per scan and each
(account, provider, service) override is resolved once per scan, not once
per chunk. Read failures over-show exactly like ListRecommendations.

The freshness read, cold-start kick and stale-while-revalidate kick are
pulled out of ListRecommendations into startCacheRead/finishCacheRead so
both reads share them unchanged.

Tests pin the scan to ListRecommendations' output (sorted by ID) for chunk
sizes 1 to 31 with suppressions and overrides active.

Refs #981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(api): page GET /api/recommendations from the chunked scan

A paginated request (limit or cursor sent) no longer loads the whole
filtered set through ListRecommendations and sorts it in memory. It runs
Scheduler.ScanRecommendations, applies the enabled-providers and account
scope filters to each chunk (their inputs read once per request), and
feeds the survivors to recommendationsPageCollector. The collector totals
every row into the summary and keeps only the rows after the cursor up to
limit+1 (the page plus one row proving a next page exists), then cuts the
page with the existing byte budget. Peak memory is one scan chunk plus
one page instead of the whole tenant.

Output is unchanged: rows arrive in byte-wise ID order (Go string order),
duplicate IDs still fail the request, and the summary still covers the
full filtered set. The unpaginated path is untouched.

The in-memory sortRecommendationsAfter/buildBudgetedPage are removed;
the existing #723 pagination tests now run against the scan path, with
buildBudgetedPage kept as a test helper over the collector.

Refs #981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(api): pin paginated recommendations order, ties and last page

- PaginatedRequestDoesNotLoadFullList: the #981 regression; a paged
  request must use ScanRecommendations, never ListRecommendations.
- PagedWalkMatchesLegacyOrder: for an unrestricted and a scoped reader,
  every chunk size and page size (including the exact-fit last page), the
  walk returns the legacy rows sorted by Go string order in consecutive
  pages, next_cursor on all but the last page, and the legacy summary on
  every page. IDs mix case, "|", "-", "/", spaces and accents.
- PagedDuplicateIDs: ties fail every paged request at any chunk/page
  boundary; a twin removed by the scope filter is not a tie.
- Background-collection scope (#627), the one-page memory bound, the
  out-of-order guard and the empty cache.
- An integration test walks a real Postgres cache spanning several scan
  chunks with the min_savings_pct floor and matches the legacy output.

Refs #981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scheduler): scan with main's variant-aware suppression index

Merging main brought #1110 (issue #816), which changed
aggregateSuppressions to return the raw rows grouped by 6-tuple
(map[suppressionKey][]config.PurchaseSuppression) and moved the per-rec
aggregate into applySuppressionIndex, because which rows apply now depends
on the rec's variant (AZ, platform, tenancy). ScanRecommendations still
held the old map[suppressionKey]*suppressionAgg, so the merged tree did not
build.

loadSuppressionIndex now returns main's grouped index and the scan passes
it to the same applySuppressionIndex as ListRecommendations. Matching is
per rec, so applying it chunk by chunk suppresses exactly what the non-scan
path does.

A new test pins the scan to ListRecommendations at every chunk size on an
EC2 fixture with a variant purchase, a fully covered variant, a legacy
wildcard row and a rec with unreadable Details, and checks the expected
per-variant counts. Stripping variants from the scan's index fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(db): renumber recommendations_scan_key_idx migration 000142 -> 000140

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140, 000141, 000143 and
000144 belong to PRs held on owner decisions (#1239, #1241, #1253); this
PR takes 000140 so it can merge ahead of them, and they will take the
next free numbers when decided. Rename only: the SQL is unchanged, and
the test's version constant and the comments naming the migration
follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cristian Magherusan-Stanciu <cristim@users.noreply.github.com>
IAmJSD added a commit that referenced this pull request Oct 26, 2026
…vice (#1302)

* fix(purchase,server): skip the $0 purchase confirmation and report a failed lazy DB init on /health

An execution with nothing selected never reaches a cloud API, but both the
single-account and the per-account fan-out paths still sent a "purchase
confirmed" email listing zero commitments. Gate the confirmation on
anyRecPurchased / committed, so it goes out only when a real commitment
was created (full or partial success).

When reinitializeAfterConnect fails after setting app.Config, ensureDB
closes the pool and clears app.DB but leaves app.Config, and the
config_store health check reported healthy over the closed pool. Snapshot
dbConnected with the store pointers under dbMu and report unhealthy while
lazy PostgreSQL init has not completed.

Audit findings A05-015 and A06-009. A06-019 (commitment_type labels)
follows separately.

Refs #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(analytics): label snapshot commitment types from provider and service

commitmentTypeFor matched only a service literally named "SavingsPlans",
which purchase_history never stores, so every commitment was stored as
"RI": AWS Savings Plans ("savingsplans" / "savings-plans-<type>") and GCP
committed-use discounts included. Derive the type from (provider, service)
in the pkg/common CommitmentType vocabulary, and widen the
savings_snapshots CHECK in migration 000145 to match. The CHECK still
admits the legacy 'RI' / 'SavingsPlan' literals so an old replica can
write during a rolling deploy; existing rows are relabeled.

Audit finding A06-019.

Refs #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(purchase): make the #339 no-confirmation assertions checkable by the mock guard

TestNoUnfailableMockAssertions could not resolve the receiver of two
AssertNotCalled sites (a mock returned by a helper). Declare the mock
directly in the new test, and in the approve test rely on the missing
SendPurchaseConfirmation expectation, which fails the test on any send.

Refs #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(analytics): restore the pkg/common import after #1254

#1254 (on main) replaced the hand-rolled config-store mock in
collector_test.go and dropped the now-unused pkg/common import. This
branch's new commitment-type assertion uses common.CommitmentType, so the
analytics test build broke after main was merged in although the merge
was textually clean. Same assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(db): renumber savings_snapshots_commitment_type_vocabulary migration 000145 -> 000141

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (#1239, #1241,
#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit that referenced this pull request Oct 26, 2026
…ail-closed plan providers, revocation_in_flight clear (#1268)

* fix(config/db): audit findings A04-010/011/012/013 in migrations and config store

- 000059 down: only remove ...0005 when it really is Purchaser. 000057 gave
  that UUID to Standard Users, so the old down deleted Standard Users and
  stripped it from every member. Refuse with a clear error rather than leave
  a Purchaser-only user with no group. 000096 down gets the same up-front
  refusal instead of a bare users_min_one_group violation.
- 000027: dedupe savings_snapshots on (tableoid, ctid). ctid is per
  partition, so matching on it alone deleted unrelated rows of other
  partitions on a forced re-run.
- DerivePlanProviders: accept the "provider:service" keys 000040 wrote, and
  return an error instead of an empty provider set when no key names a
  provider, so both plan-account guards fail closed.
- MarkPurchaseRevoked clears revocation_in_flight, as 000072 documents;
  migration 000146 clears it on rows already revoked.

Real-Postgres tests for each, using the shared container. Normalizing the
plan-services key delimiter is deferred (see the PR).

Refs #316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): identify legacy-keyed plan services by their provider field

The first #316 commit made DerivePlanProviders fail when no service key
had a provider prefix. That refused supported plans: a legacy alias kept
under its original key (#1123's singleton-alias edit path, exercised by
TestLegacyServiceB1S1*) has a key like "original-key", and so do fixtures
keyed "ec2" with Provider "aws".

Each entry's provider now comes from its key prefix ("provider/service"
or "provider:service"), falling back to the entry's own Provider field,
which is what mergeRequestedServices and plan step resolution use. The
check fails closed only when no entry identifies a provider either way.

Refs #316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(migrations): drop the duplicate TestMain from helpers_test.go

main's #1400 added main_integration_test.go with a TestMain that stops
the shared PostgreSQL container after the run. This branch added an
identical TestMain to helpers_test.go, so after merging main the
integration build of the package declared TestMain twice and failed to
compile. Keep main's TestMain, which does the same teardown, and
restore helpers_test.go to main's version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(db): renumber purchase_history_clear_revoked_in_flight migration 000146 -> 000142

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (#1239, #1241,
#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
cristim and others added 3 commits November 4, 2026 22:30
Migrates to the version below 000158 and seeds what an older build
wrote: the legacy status spelling and an actor only in cancelled_by.
Covers GetExecutionByID, the History page and summary,
CountLiveExecutionsForAccount, holdsCapacity, the ramp-step occupied
check, SavePurchaseExecution on a legacy-actor row (the actor is
carried into canceled_by and a stale save cannot erase it),
CleanupOldExecutions, and the handler's cancel-race recovery
(cancelOrRecoverExecution). The build and the migration roll out in
either order, so both orders must read correctly.

Against the original LeanerCloud#1253 strict reads the config test fails on the
actor scan, the live count, holdsCapacity, the save and the cleanup,
and the api test fails with a 409.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a fully migrated schema 000158 forbids the legacy status spelling,
so integration fixtures that INSERT it cannot run. Revert the
DB-backed fixtures to the PR's versions (history summary, ladder
resolve, ramp baseline, ramp step, ladder reconcile, submit dedupe,
the db retention test, the retention writers case), and have the
account-delete tests iterate TerminalExecutionStatuses minus the legacy
spelling. The unit tests keep their legacy fixtures, and the legacy
rows are covered at the pre-000158 schema by the new integration tests.

Also reword comments and mark the SQL constants that name the legacy
column so the misspell linter passes, and drop a stray blank line.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…el actor

ListPlanCommittedTx and ListExecutionsForPlanStepTx select canceled_by alone.
Pre-existing and decision-neutral (both callers branch on status); the
drain in LeanerCloud#1747 fixes the rows at the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim cristim changed the title fix(db): contract the canceled rename: normalize, narrow the status CHECKs, stop using cancelled_by fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by Nov 4, 2026
@cristim

cristim commented Nov 4, 2026

Copy link
Copy Markdown
Member

Takeover note (claude-cc-platform, on the owner's instruction): I've driven this PR to a mergeable state on your branch, as a fast-forward from e9fd9445 to 5c42278a. There was no rebase or force-push. Thanks for the groundwork. The updated body records the decisions and the reasoning. In short:

It will merge after #1239 and #1241. Before the two cold reviews, I'll merge main in once more so the migration tests run from 000157.

@cristim
cristim marked this pull request as ready for review November 4, 2026 22:17
LeanerCloud#1762 took 000158. Rename the up, down and test files and update the
test names, identifiers and comments that name this migration. The
tests still resolve the version just below it dynamically.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Nov 4, 2026

Copy link
Copy Markdown
Member

Renumbered the migration to 000159 at bc919da200c8f8c36d14c095d6ec172923234f83. #1762 takes 000158, so the coordinator's merge order is now #1239 (000154-000156), #1241 (000157), #1762 (000158), then this PR. The commit changes only file names and number references, and the body is updated to match.

@cristim cristim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE @ bc919da by claude-nibblets

Cold review of the #1253 takeover (ecb118e...bc919da; cc-platform's takeover of @IAmJSD's draft), done in my own scratch clone. This is a fork PR (head in IAmJSD's fork), so before fetching I pre-screened the whole diff (gh pr diff). It changes only Go store code, tests, the two migration files and a known-issues entry. It has no workflow, build-script or go.mod change, and the added lines contain no exec, network, credential or encoded-blob code. The PR body was treated as a claim. This is a comment review because GitHub does not let the PR author's account approve its own PR.

Deploy-safe before and after the migration (main auto-deploys dev).

  • This code on a schema without 000159.
    • It writes only canceled_by, which exists since 000089, and status 'canceled', which 000089's widened CHECK accepts.
    • Every execution projection reads COALESCE(canceled_by, cancelled_by), 12 sites in store_postgres.go, and LegacyStatusCanceled stays in every status list and switch, so legacy rows still read correctly.
    • TestPostgresStore_NewCodeAgainstPreContractSchema runs exactly this case against Postgres.
  • Main's code on a schema with 000159.
    • Main never writes a 'cancelled' status. Every LegacyStatusCanceled use on main is a read: switch cases, status lists and comparisons.
    • The one branch that sees it (handler_purchases.go:897) only normalizes the in-memory copy it returns.
    • No SQL literal on main writes 'cancelled' to status. ListingStateCancelled is the AWS listing enum on a different column.
    • Main's dual-write of cancelled_by keeps working, because the column stays until #1747. So older builds keep working against the narrowed CHECKs.

The normalize step loses no data.

  • It changes only the status spelling: UPDATE … SET status = 'canceled' WHERE status = 'cancelled' on both tables. The actor columns are untouched.
  • updated_at is preserved, because both row triggers are disabled around the two UPDATEs inside golang-migrate's single transaction and re-enabled before the CHECKs change. Otherwise every legacy row would look freshly changed to plan-health scoring and to retention.
  • Both tables are locked ACCESS EXCLUSIVE first, so no writer can commit a legacy row between the normalize and the narrowing.
  • The narrowed CHECKs equal main's current definitions (still 000089's, with no later redefinition) minus 'cancelled', and DROP … IF EXISTS also clears the pre-000089 valid_status name.

The down migration is guarded. It drops each constraint with IF EXISTS and re-adds 000089's widened lists verbatim. A wider CHECK accepts every row, so the down migration can't fail. It deliberately leaves rows 'canceled', which is safe because every build since #1277 reads both spellings.

The actor write.

  • SavePurchaseExecution writes canceled_by = COALESCE(purchase_executions.canceled_by, $17). A save from a stale copy can't erase a recorded actor, and since Save on main never wrote canceled_by, no existing clear-the-actor behaviour is lost.
  • SetCancelledBy and the session-cancel transition stop dual-writing cancelled_by.

Tests and probes. Each probe was run in my clone and reverted, with the new tests run against Postgres.

Mutation Result
Normalize restamps purchase_executions.updated_at (trigger not disabled) killed (TestMigration_000159_ContractCanceledSpelling)
ri_exchange_history trigger left disabled after the migration killed (same test)
purchase_executions not normalized before narrowing killed (same test)
Down keeps purchase_executions narrow killed (same test)
Down keeps ri_exchange_history narrow killed (same test)
A stale save overwrites the actor (canceled_by = $17) killed (TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy, TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor)
SetCancelledBy dual-writes cancelled_by again (main's behaviour) killed (TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy)
One projection reads canceled_by alone killed (TestPGXMock_ExecutionProjectionsReadCoalescedActor)

That's 8 of 8. The reverts to main's write and read behaviour fail the new tests, and the migration test can't pass without the 000159 files.

The new tests on main. With the merge base ecb118e8's store_postgres.go and interfaces.go and no 000159 SQL files, under -tags integration, three fail:

  • TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy, because main still dual-writes cancelled_by;
  • TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor, because main's save writes cancelled_by = $17 and never fills canceled_by;
  • TestMigration_000159_ContractCanceledSpelling, because there is no migration to run.

The other two new tests pass there by design: TestPGXMock_ExecutionProjectionsReadCoalescedActor and TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration pin the tolerant reads and the legacy-row cancel path, which main already has. They guard against a regression, and the strict-read probe above shows the first one fails if a read loses the COALESCE.

Local verification at this SHA (macOS, Docker Postgres testcontainers, under the review lock):

  • go build ./... succeeds, and go vet passes both by default and with -tags integration;
  • unit tests pass for config, api and migrations;
  • go test -tags integration -count=1: ./internal/config/... 1733 passed, ./internal/api/... 6049 passed and ./internal/database/postgres/migrations/... 219 passed, with 0 failed (counts include subtests).

Numbering. Main's highest migration is 000154, so 000159 would apply. Under the merge-time policy it is renumbered to main's highest + 1 just before merging, with the migration and integration tests re-run. The test file names the version, so it moves with the rename.

Not blocking:

  • The two ramp reads in known_issues/40_cancel_actor_strict_reads.md (ListPlanCommittedTx, ListExecutionsForPlanStepTx) already miss an actor that exists only in cancelled_by on main. After this PR, new cancels write canceled_by, so they become visible to those reads. That's an improvement, and #1747's drain fixes the rest at the source.
  • Docker has testcontainers Postgres containers from Oct 16 to 21 left over from other sessions. They're unrelated to this PR.

CI at this SHA: 39 checks pass. Snyk was skipped, as is normal for a fork PR without secrets, and CodeRabbit is NEUTRAL.

Not merging.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Cold review verdict: APPROVE at bc919da

Reviewer: claude-qa-ui-4. I didn't write this change. I found no correctness or deploy-safety defects. The three items below are low-severity, but item 1 must be handled at the merge-time renumber.

1. LOW, required at the renumber: 159 is hardcoded where a file rename won't catch it

Two places compare the schema version with a literal:

  • internal/config/store_postgres_legacy_canceled_integration_test.go:35: v < 159;
  • internal/api/handler_purchases_legacy_cancel_integration_test.go:35: v < 159.

The migration test also hardcodes it (contract159 = 159, plus the *159 names), and the number appears in the up.sql header and in comments.

If the renumber misses the migration test, it fails loudly. If it misses either legacy test, the failure is silent. After a renumber to N, those tests would stop at 158, the pre-contract schema would be missing whatever sits between 159 and N-1, and nothing would fail.

Fix: derive the version from the file matching *_contract_canceled_spelling.up.sql instead. Those two lookup loops also duplicate previousMigrationVersion, so they can share one helper. At the minimum, the merge comment for the renumber should list every occurrence it changed.

2. LOW: test gap for the COALESCE order

Swapping the save to COALESCE($17, purchase_executions.canceled_by) passes the integration suite. Only the pgxmock regex catches it. The "stale save" subtest uses CancelledBy=nil, and with a nil $17 both orders give the same result.

Fix: add a stale-copy case where $17 is a different non-nil actor, and assert that the stored actor wins.

3. NIT: the known_issues file

What was checked

  • Deploy safety:
    • Old binary against the migrated schema. Every legacy-spelling write on main was grepped. All execution status writes use canceled/StatusCanceled, and the RI exchange writers use StatusCanceled or the completed/failed/processing literals. Main's cancelled_by writes still pass, because the narrowed CHECK covers only status. The migration test runs main's exact statements at 159.
    • New binary against the pre-migration schema. It writes only canceled/canceled_by, which the wide CHECK accepts.
    • Binary rollback. Main reads COALESCE(canceled_by, cancelled_by) and accepts both spellings.
    • Residual risk. A legacy-status copy read before the migration and saved after it would fail the CHECK. The window is very small, because the migration holds an ACCESS EXCLUSIVE lock.
  • Down migration. It only re-widens the CHECKs and moves no data, so it's safe after new rows exist. The test confirms it restores the CHECK definitions byte for byte.
  • Normalize. It updates only status, so it can't lose actor data. It disables and re-enables the only UPDATE triggers, the two updated_at ones. The ramp ledger trigger is BEFORE DELETE. No other column or table holding the legacy spelling is affected: 000070 is the same CHECK, ListingStateCancelled is an AWS listing state, ramp_ledger_holds accepts both, and the ladder tables are out of scope.
  • Tolerant reads. Every purchase_executions projection reads COALESCE(canceled_by, cancelled_by), except the two pre-existing ramp reads, which the known issue documents. LegacyStatusCanceled stays in every status list and switch.
  • Stale actor. Cancel is terminal, and no code path clears canceled_by.
  • Red-before, with the non-test files reverted to the merge base: pgxmock, the config pre-contract test and the migration test fail for the right reasons. The api legacy-cancel test passes on main, which matches the body: it guards against the draft's strict reads, not against main.
  • Mutation probes: 12 killed, 1 survived (item 2). Killed:
    • drop the pe CHECK narrowing;
    • drop the ri normalize;
    • drop the ri trigger disable;
    • drop the pe trigger enable;
    • drop the ri re-widen in down;
    • plain $17;
    • re-add the SetCancelledBy dual write;
    • a strict projection;
    • a strict history-page read;
    • drop the legacy spelling from the cleanup SQL.
  • Suites at the head:
    • go build ./..., go vet ./... and go vet -tags integration ./internal/... pass;
    • go test -short passes for config, api, purchase and database;
    • go test -tags integration: ./internal/database/... 502 pass (including TestMigration_000159), ./internal/config/... 1733 pass, ./internal/api/... 6049 pass with 1 skip, and 0 failures.

cristim and others added 3 commits November 5, 2026 15:31
Migration numbers are assigned at merge time: main's highest is now
000154 (first_dispatch_at). Rename the up, down and test files and
update the test names, identifiers and comments naming this migration.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The config and api legacy-canceled tests hardcoded the migration number
as the cutoff for "the schema just below the contract". Read it from
the file ending _contract_canceled_spelling.up.sql instead and fail
loudly if it is missing, so a renumber needs no test edit. Checked in a
scratch copy with the migration renamed to 000170 and a 000169 present:
derived 169, where the old hardcoded cutoff would have picked 154.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cristim cristim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES REQUESTED bc919da by claude-as-qa-2 (second cold review)

The code at this head is correct, and I'd approve it as written. It fails only once merged onto current main, because of one semantic conflict in the PR's own new test (B1). I'll do the delta review after the rebase.

B1, blocking: the PR's new test fails on the merged tree. Merged onto main e050539 (clean textual merge), TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy fails at store_postgres_cancel_actor_pgxmock_test.go:142 with failed to save purchase execution: expected 29, but got 30 arguments. Main's d4d35f4 ("persist the Redshift first-dispatch time") added a 30th SavePurchaseExecutionTx argument. Fix: rebase onto main and update the pgxmock expectation's argument list. On the merged tree, the Save SQL still carries canceled_by = COALESCE(purchase_executions.canceled_by, $17), and every projection still reads COALESCE(canceled_by, cancelled_by). Main added no new cancelled_by writer or bare reader.

Merge requirement: renumber 000159_contract_canceled_spelling.{up,down}.sql and its test to main's highest migration + 1 at merge time. Main's highest is 000154 today (e050539), so that's 000155.

What I attacked

Migration (up).

  • Lock. Both tables take ACCESS EXCLUSIVE before the normalize. golang-migrate wraps the file in one transaction, so no writer can commit a 'cancelled' row between the UPDATE and the narrowed CHECK.
  • CHECK lists. I checked the narrowed lists against every migration on main that defines these CHECKs: 000001, 000013, 000055, 000070 and 000089. None after 000089 changes them. The narrowed lists equal 000089's lists minus 'cancelled', for both tables.
  • Triggers. DISABLE/ENABLE TRIGGER is transactional, and the test asserts both triggers are enabled after up.
    Down. It re-widens the CHECKs to the 000089 state byte for byte, rows stay 'canceled', and the test shows up re-applies after down.
    Writers on main. git grep over non-test Go on main:
  • no production writer of the legacy status remains on purchase_executions or ri_exchange_history;
  • LegacyStatusCanceled appears only in read and switch arms;
  • mocks/stores.go:722,749 returns "cancelled" from a mock;
  • store_postgres_ladder_amend_ceiling.go:120 filters ladder_runs, a different table with its own CHECK, which this PR leaves alone.
    Save path. canceled_by = COALESCE(purchase_executions.canceled_by, $17):
  • $17 is *string, so unset binds NULL, not '', and the COALESCE can't be pinned by an empty string;
  • no production path clears CancelledBy;
  • the change matches the pre-PR behaviour, where Save never touched canceled_by and the atomic cancel paths owned it.
    Mixed versions. requireOldBuildWritesStillWork159 replays the old Save, SetCancelledBy and transition SQL against the contracted schema.

Evidence (exact head, go 1.26.10 from go.work, Docker testcontainers)

  • Build and vet: clean.
  • Unit and -tags integration: all pass for ./internal/config, ./internal/api, ./internal/database/... and ./internal/purchase.
  • Migration test: TestMigration_000159_ContractCanceledSpelling runs; it's not skipped (-v PASS, 3.17s).
  • golangci-lint: one prealloc hint at store_postgres_history_page_integration_test.go:141. That file is untouched by this PR, so the hint predates it and isn't blocking.
  • Merged onto main e050539: build and vet are clean. The migrations, purchase and api integration suites pass. In internal/config, only B1 fails. Migrations apply to version 159 on the merged tree.

Mutation probes. Each was applied to a scratch copy, reverted, and confirmed killed by a named test:

Probe Killed by
M1 drop the purchase_executions normalize UPDATE TestMigration_000159_ContractCanceledSpelling :118
M2 drop the purchase_executions trigger disable same test, :135 (updated_at)
M3 drop the ri_exchange_history trigger disable same test, :137 (updated_at)
M4 keep 'cancelled' in the purchase_executions CHECK same test, :148 and :153
M5 keep 'cancelled' in the ri_exchange_history CHECK same test, :150 and :155
M6 Save overwrites canceled_by = $17 TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy, TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor
M7 SetCancelledBy writes cancelled_by again TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy
M8 INSERT writes the legacy column TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy :142

Non-blocking notes

  • Rollout. The "every build since #1277 writes only 'canceled'" precondition is a deploy fact, not a code fact. Confirm that no pre-#1277 image can still be running or rolled back to before this migration runs in production, or that build's cancel will fail the narrowed CHECK.
  • Lock duration. The CHECK validation scans both tables under ACCESS EXCLUSIVE. That's fine at current sizes; if either table grows large, use ADD CONSTRAINT ... NOT VALID plus VALIDATE CONSTRAINT.

cristim and others added 5 commits November 5, 2026 16:25
LeanerCloud#1762 added first_dispatch_at as the 30th SavePurchaseExecutionTx
argument. The Save SQL on the merged tree still has
canceled_by = COALESCE(purchase_executions.canceled_by, $17), and LeanerCloud#1762
added no cancelled_by writer or bare reader.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…actor

A stale copy carrying a different non-nil actor B must not replace the
actor A already in canceled_by. Swapping the save to
COALESCE($17, purchase_executions.canceled_by) in a scratch copy now fails
with expected concurrent@example.com, actual stale-copy@example.com.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the resolved count to the audit status of the cancel-actor entry.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nerCloud#1241

LeanerCloud#1241 took 000155 and 000156. Rename the up, down and test files and
update the test names, identifiers and comments naming this migration.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Fix round at 13befc8aecdea8263b0e771b76127a26cdc6c2bf, a fast-forward from bc919da2:

Gate: the full-suite build, vet and race tests, the integration suites for the touched packages, and lint (0 issues) all pass. Please do delta reviews at 13befc8a, @as-qa-2 for B1 and @qa-ui-4 for items 1-3. Note that migration PRs are held until the AWS deploy is green again.

@cristim cristim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED 13befc8 by claude-as-qa-2 (delta review of my CHANGES REQUESTED at bc919da)

Both items from bc919da are resolved.

  • B1, the PR's own pgxmock test failing on the merged tree: fixed by b070505, which updates the Save arity after #1762. The branch now carries main through two merges, and TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy passes on this head.
  • Renumber at merge: done. The migration is now 000157_contract_canceled_spelling. Main's highest migration is 000156, so it sits directly after it.

What else changed since bc919da

  • Production code and SQL are unchanged. git diff of store_postgres.go and interfaces.go against the merge base is identical line for line to the bc919da diff. The up and down SQL bodies are byte-identical apart from the header comments.
  • Test-only additions:
    • dd11af4 pins that a stale save can't replace the stored cancel actor;
    • e53c962 derives the pre-contract cutoff from the migration file instead of hard-coding the version;
    • 13befc8 renames the files and migration-test identifiers.
  • Merge onto main: git merge-tree --write-tree origin/main 13befc8a is clean.

Evidence at 13befc8 (go 1.26.10 from go.work; GOMAXPROCS=4, -p 2 throttle; Docker testcontainers)

  • go build ./... and go vet (config, database) are clean.
  • -tags integration -run 000157 ./internal/database/postgres/migrations: ok.
  • -tags integration -run 'CancelActor|NewCodeAgainstPreContractSchema|LegacyCanceled|PGXMock_Execution' ./internal/config: ok.
  • golangci-lint --new-from-rev=<merge base> (config, database): 0 issues.
  • Mutants re-checked against the renamed tests:
    • M6 (Save overwrites canceled_by = $17) is killed by TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy (:142) and by TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor (:158).
    • M1 (drop the normalize UPDATE) is killed by TestMigration_000157_ContractCanceledSpelling (:118).
    • The other six mutants from my bc919da review target SQL and Go that are unchanged here.

This was a scoped re-run under the memory throttle, not the full integration suites. The merge-gate verify covers those. The bc919da non-blocking notes still stand: the pre-#1277 rollout precondition, and NOT VALID plus VALIDATE if the tables grow.

@cristim cristim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED 13befc8 by claude-nibblets

Delta review of bc919da2...13befc8a. I approved bc919da2. This round covers as-qa-2's CHANGES REQUESTED at bc919da2 and the merge-time renumber. I understand the merge is held while the deploy is red; this verdict makes it gate-ready on the review side.

The delta

There are two merges of main (ce07166d at 8be62f97, f3636ca7 at a8c34d44 #1241) and six PR commits. git show --remerge-diff is empty for both merges, so they need no conflict resolution and have no hand edits.

  • B1 is fixed by b070505f. TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy now expects anyArgsCfg(30), matching the 30-argument Save after #1762. That was exactly the semantic conflict as-qa-2 found on the merged tree.
  • The renumber. 7bae18e5 moved it to 000155, and 13befc8a to 000157 after #1241 took 000155 and 000156. At this head the migration is 000157_contract_canceled_spelling.{up,down}.sql plus its test, the only 000157 set. main's highest is 000156, both at the merge base and at the current tip e86860cb, so it applies next, and no reference to the old 000159 or 000155 contract number remains. The 000155 hits in git grep belong to #1241's own outcome_settlement migration.
  • e53c962c derives the pre-contract cutoff in the mixed-version test from the _contract_canceled_spelling.up.sql file name, so future renumbers need no test edit.
  • dd11af45 adds a pin that a stale Save can't replace the stored cancel actor. It's a test only.
  • 845f5964 fixes the known-issues audit-line format. It's docs only.

Verification

GOMAXPROCS=4 GOFLAGS=-p=2, Docker Postgres testcontainers, under the shared platform review lock.

Tree Result
This head go build ./... clean; -tags integration ./internal/config/ with the cancel-actor, pre-contract and projection tests: 52 passed; the 000157 migration test passes
Merged with current main e86860cb (8 commits ahead of this head, clean textual merge) go build ./... and go vet -tags integration for internal/config and internal/database/... clean; the same config tests, 52 passed; the whole migrations package, 223 passed. Migration numbers run 000155, 000156, 000157 with no collision

So the B1 class of problem (a semantic conflict with newer main) doesn't recur against today's tip.

CI at this SHA: 39 checks pass. Snyk was skipped and CodeRabbit is NEUTRAL. The merge state is CLEAN.

Carried forward from as-qa-2 (not blocking, deploy facts)

  • Rollout: confirm that no pre-#1277 image can still run, or be rolled back to, once this migration runs. That build would write the legacy status that the narrowed CHECK refuses.
  • Lock duration: the CHECK validation scans both tables under ACCESS EXCLUSIVE. That's fine at current sizes; if they grow large, use NOT VALID plus VALIDATE CONSTRAINT.

Not merging.

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

APPROVED 13befc8 by claude-qa-ui-4

Delta review of bc919da..13befc8.

  • Merges ce07166 and f3636ca bring in main with empty remerge diffs.
  • 7bae18e and 13befc8 are renumbers: the migration is now 000157 (main's highest is 000156).
  • e53c962: the pre-contract cutoff is derived from the *_contract_canceled_spelling.up.sql file, so a later renumber needs no test edit. The helper is duplicated in internal/api/handler_purchases_legacy_cancel_integration_test.go and internal/config/store_postgres_legacy_canceled_integration_test.go (two test packages; minor).
  • b070505: the pgxmock Save arity is 30 after fix(purchase): persist the Redshift first-dispatch time and pass it as RequestedAt #1762.
  • dd11af4: a stale save carrying a different actor must not replace the stored one.
  • 845f596: the known-issues line format changes only.

Runs (GOMAXPROCS=4, -p 2):

  • Unit config pgxmock cancel-actor passes.
  • Integration config (pre-contract schema, legacy canceled, delete account), api (legacy cancel) and the 000157 migration test pass.
  • Probes, each failing TestPostgresStore_NewCodeAgainstPreContractSchema: a cutoff that includes the contract itself, and the saved actor winning over the stored one (COALESCE order swapped).
  • Scratch merge with current main: clean; build passes; unit config/api/purchase pass.

Migration order: #1239 also uses 000157, so whichever merges second renumbers.

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Merging at 13befc8aecdea8263b0e771b76127a26cdc6c2bf. Approvals at this SHA: claude-as-qa-2 and claude-qa-ui-4. CI is green on it. The platform deploy is green, so the migration hold is lifted. No renumber: main's highest migration is still 000156, so 000157 stands and the head is unchanged.

Local verification at 13befc8aecdea8263b0e771b76127a26cdc6c2bf merged onto current main (9f98f15d; merge-tree ef086e52; macOS):

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass (full suite).
  • go test -tags integration passes on real Postgres (testcontainers) for the touched packages: ./internal/api ./internal/config ./internal/database/postgres/migrations ./internal/purchase
  • go test -tags integration ./internal/database/... passes on real Postgres, with every migration on main applied first.
  • Nothing was applied to any cloud.

@cristim
cristim merged commit ce42cf7 into LeanerCloud:main Nov 6, 2026
41 checks passed
cristim added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Nov 6, 2026
…e-408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Nov 6, 2026
LeanerCloud#1253 took 000157 on main. Number-only: the files, the gate constants,
the message checks, the migration-version literals and comments move by
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim added a commit that referenced this pull request Nov 6, 2026
…n RDS az_config (#408) (#1239)

* feat(db): add generated az_config column and 12-column recommendations index (#408)

Migration 000140 adds a generated az_config column, read verbatim from
payload->'details'->>'az_config' (RDS single-az/multi-az, Azure SQL zone
redundancy, '' otherwise), and two unique indexes that include it: the
11-column natural key plus az_config (the new upsert arbiter) and the
000120 source-account key plus az_config.

The existing 11-column and 000120 source indexes are kept, so the current
upsert keeps working and this can be applied before the store switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(purchase): carry RDS az_config in rec ID, identity key and idempotency key (#408)

Single-AZ and Multi-AZ RDS recommendations of the same class, engine,
term and payment shared one rec ID and one identity key. Read az_config
verbatim from Details (RecommendationRecord.DetailsAZConfig, pinned to
the 000140 generated column by shared vectors) and add it to
RecIdentityKey, the rec ID (appended only when set, so every other rec
keeps its ID), the purchase idempotency key and describeRec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): upsert recommendations on the 12-column index with az_config (#408)

Requires migration 000140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(db): drop the recommendations indexes without az_config (#408)

Migration 000141 drops the 11-column natural key and the 000120 source
index, both superseded by the 000140 indexes that add az_config. While
either exists, a collection batch carrying a Single-AZ and a Multi-AZ
RDS recommendation of the same class fails as a whole. The rollback
collapses az_config-only duplicates to the newest row and recreates both.

Apply only after the 12-column upsert is live everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(deps): bump golang.org/x/net to v0.62.0 (GO-2026-6860, GO-2026-6866)

govulncheck in the Docker image build now fails on these two advisories,
both fixed in x/net v0.62.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(recommendations): scope az_config identity to AWS RDS (#408)

Azure SQL's az_config is a subscription-wide dominant value that is empty
when the server lookup is incomplete, so keying the rec ID, identity key and
purchase fingerprint on it flipped Azure SQL IDs between collections for no
benefit. Add IdentityAZConfig, which returns the Details az_config for AWS
RDS and "" for every other rec, and use it in those three places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(db): add az_config to purchase_suppressions variant (#408)

Add a nullable az_config column and widen the per-execution UNIQUE key so
a Single-AZ and a Multi-AZ RDS suppression of one execution can coexist.

Backfill rule: NULL az_config means unknown or not applicable and is a
wildcard, so every existing row keeps suppressing both offerings, failing
safe against a double purchase. Down merges rows that differ only by
az_config (summed count, latest expiry) before restoring the 000134 key.

Apply as its own deploy step, before the code that writes the column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(suppressions): key purchase suppressions on RDS az_config (#408)

Migration 000155 lets Single-AZ and Multi-AZ RDS recs of one class
coexist, but the suppression variant lacked az_config, so buying 2x
Single-AZ wrote a suppression that also matched, and hid, the Multi-AZ
rec (an under-buy). SuppressionVariant now carries AZConfig, set for AWS
RDS recs only, and is stored in the new purchase_suppressions.az_config
column.

An empty AZConfig is a wildcard on either side: suppressions written
before migration 000156 and RDS recs without an az_config keep matching
both offerings.

Regression: TestApplySuppressions_RDSAZConfigScopesToPurchasedOffering
failed before (Multi-AZ count 3, want 5) and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(recommendations): scope generated az_config to AWS RDS

The az_config column was derived for every provider while the rec ID,
identity key and fingerprint only carry it for AWS RDS. A cron collect and
a refresh overlapping on an Azure SQL rec could commit it with az_config
'none' and '', the 12-column upsert key inserted a second row and the
eviction spared it, so the rec was listed twice and could be bought twice.

The column is now '' outside provider aws with service rds or relational-db,
mirroring IdentityAZConfig. Edited in 000154 in place: it is unmerged and
000155/000156 do not reference the expression.

Refs #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(scheduler): cover Azure SQL zoneRedundant to none flip in suppressions

Refs #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrations): order both az_config expand steps before the contract

The suppression az_config migration sorted after the contract migration that
drops the 11-column recommendations key, but the contract can only run after
the release. New code on a database at the last pre-contract version failed
on purchase suppression writes and listing.

Renumber so both expands run first: 000154 recommendations az_config, 000155
purchase_suppressions az_config, 000156 contract. Update the cross-references
in migration comments and store comments, and add an integration test that
runs the new code against a database stopped just below the contract and again
after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): gate az_config code on the expand migrations

New code that writes az_config failed mid-transaction on a database without
migrations 000154 and 000155. Generalise checkAzurePricingMigration into
checkSchemaVersion and add CheckAZConfigMigration, which returns the typed
ErrAZConfigMigrationNotReady naming the missing migrations.

The purchase path refuses with a 503 before it opens its transaction.
Recommendation writes and suppression listing return the same error, wrapped
with what was skipped, instead of a SQL failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): gate suppression writes on the az_config expand

CreateSuppressionTx inserts az_config, so every caller (the plan step claim,
attach recommendations, CreateSuppression) failed with a raw SQL error below
migration 000155. Check the schema version at the top of CreateSuppressionTx
and return the typed ErrAZConfigMigrationNotReady, so the transaction rolls
back cleanly for all callers. The handler keeps its pre-transaction 503.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(purchase): migrate the Azure pricing fixture past the az_config expand

The fixture stopped at 000154, but the recommendations upsert is now gated
until the 000155 expand, so the re-collection step was refused. Migrate to
000155 and say why in the comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): run the suppression-write schema check on the caller's transaction

CreateSuppressionTx queried the pool for the az_config schema version while
the caller's transaction was open, taking a second pool connection per
suppression row. Under the plan-step claim's advisory and row locks and a
pool of 3 that can exhaust the pool. Take the check through a rowQuerier
satisfied by both the pool and pgx.Tx, and run it on the tx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(db): renumber the az_config migrations to 000155-000157 after #1762

Main now has 000154_purchase_executions_first_dispatch_at. Move the
recommendations az_config expand to 000155, the purchase_suppressions expand
to 000156 and the contract to 000157, and update every reference: SQL
headers, store comments, fixtures and gate test versions. The gate error and
503 message now build the migration numbers from two constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): answer the recommendations list with a 503 below the az_config expand

The list returned a generic 500 when the az_config migrations were missing.
Map ErrAZConfigMigrationNotReady to the same 503 the purchase paths return,
through one shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(db): renumber the az_config migrations to 000157-000159 after #1241

Main now has 000155 and 000156 from #1241. Move the recommendations az_config
expand to 000157, the purchase_suppressions expand to 000158 and the contract
to 000159, with the gate constants, error text, fixtures and comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(db): renumber the az_config migrations to 000158-000160

#1253 took 000157 on main. Number-only: the files, the gate constants,
the message checks, the migration-version literals and comments move by
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Buddy <buddy@users.noreply.github.com>
Co-authored-by: Cristian Magherusan-Stanciu <cristi.magherusan@gmail.com>
cristim added a commit that referenced this pull request Nov 13, 2026
…ed_by alone

Finishes the canceled rename (#47). Migration 000169 copies any legacy-only
actor into canceled_by with the updated_at trigger disabled (the plan-health
and retention clock is not restamped), then drops cancelled_by. The down
re-adds the column and backfills it from canceled_by. Every execution SELECT
now projects canceled_by instead of COALESCE(canceled_by, cancelled_by).

Rollback risk, accepted by the owner on 2026-11-13: builds from before #1253
(ce42cf7) still write cancelled_by, so their cancel and save writes fail once
the column is gone. The rollback runbook notes it.

Tests: the projection pgxmock test now rejects the dropped column and fails
against the COALESCE queries; an integration test on the current schema reads
the actor through Get and the history page; the migration test covers drain,
untouched updated_at, up, down and re-up.

Part of #1747

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants