Skip to content

fix(recommendations): key stored recs, rec ID and purchase identity on RDS az_config (#408) - #1239

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

cristim merged 28 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-408

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

#806 and #792 already added the zonal EC2 AZ, platform and tenancy to the recommendations natural key, rec ID, identity key and idempotency key. What #408 still lists as missing is EC2 scope and RDS az_config.

  • RDS az_config (fixed here). Cost Explorer reports Single-AZ and Multi-AZ RDS reservations of the same class, engine, term and payment as two separate recommendations (parseRDSDetails maps DeploymentOption to single-az/multi-az). They are different offerings, but both got the same natural key, rec ID and identity key. That breaks three things on main:
    1. Storage. UpsertRecommendations writes the whole multi-provider batch in one statement. When the batch has both variants it fails with SQLSTATE 21000 ("ON CONFLICT DO UPDATE command cannot affect row a second time"), so the whole collection write is lost, not just one rec.
    2. Purchase pricing. Once both rows are stored, loadStoredRecommendationIndex refuses every purchase for that provider with "more than one row for identity key".
    3. Idempotency. A Multi-AZ submit right after a Single-AZ one dedupes into it.
  • EC2 scope (no change needed). The AWS parser derives scope from the AZ (resolveEC2Scope: zonal exactly when CE gives an AZ). The AZ is already in every identity, so two recs cannot differ only by scope. Azure collects Shared-scope recs only (properties/scope eq 'Shared') and GCP does not set scope. The purchase guard computeDetailMismatch still compares scope as a backstop.
  • Provider offering filters. I checked these against the extended identity. RDS findOfferingID already filters by AZConfig and refuses an empty one, and EC2 filters already use platform, tenancy, scope and AZ, so no provider change is needed.

Fix (expand/contract like the #792/#806 chains)

  1. feat(db) migration 000158 (expand). Adds:

    • a generated az_config column: COALESCE(payload->'details'->>'az_config', '') for AWS RDS rows (provider aws, service rds or relational-db), and '' for every other row. The table rewrite backfills it, so no data migration is needed.
    • recommendations_natural_key_az_tp_azc_idx: the 11 columns plus az_config.
    • recommendations_natural_key_source_azc_idx: 000120's source-account key plus az_config. This is needed because any unique index without az_config still rejects the pair. See the feat(insurance): per-recommendation Archera quotes (blocked on an Archera API mapping) #799 note below.

    The old indexes stay, so the current upsert keeps working.

  2. fix(purchase): new RecommendationRecord.DetailsAZConfig(), pinned to the generated column by shared vectors. az_config now goes into:

    • RecIdentityKey. This covers the pricing index, planstep and the ramp-baseline netting.
    • the rec ID, appended as |<az_config> only when set, so EC2, cache and other recs keep their IDs.
    • purchaseIdempotencyKey.
    • describeRec.
  3. fix(config): the upsert ON CONFLICT targets the 12-column index. This needs 000158.

  4. fix(db) migration 000160 (contract; numbered after the suppression expand 000159, see the takeover section). Drops the 11-column index and 000120's recommendations_natural_key_source_idx. Each has a strictly finer replacement from 000158, so no existing row can violate the new indexes. The down migration collapses az_config-only duplicates to the newest row (the table is a regenerable cache) and recreates both indexes exactly.

Deploy order

See "Takeover: migrations, gate and rollout" below for the exact steps.

Behavior changes to know about

Interaction with the #799 source-account chain

Migration 000120 (#881) added recommendations_natural_key_source_idx as a prerequisite and did not switch the upsert to it. That index lacks az_config, so it would keep rejecting the Single-AZ/Multi-AZ pair. This PR replaces it with recommendations_natural_key_source_azc_idx, the same key plus az_config. When #799 later switches to a source-aware upsert, it should target that index. 000120's down-migration guard still works, because rolling back past 000160 recreates both old indexes first.

Verification

All runs were on macOS with Go 1.26 and real Postgres 17 through testcontainers. This is local unit, mock and integration evidence on synthetic fixtures. I did no real-cloud or real-account verification: a live CE response with both RDS deployment options was not exercised. Tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null because this machine's default AWS profile is invalid.

Fail before / pass after

  • Store: go test -tags integration ./internal/config/ -run 'AZConfig(Batch|Concurrent)' on the main schema and store code, with the index precondition disabled, fails in every batch case with failed to insert recommendations batch: ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time (SQLSTATE 21000). It passes after the fix, six repeated runs.
  • Pricing, identity and idempotency: with az_config removed from RecIdentityKey and purchaseIdempotencyKey (pre-fix behavior):
    • TestPriceRecommendationsFromStore_PricesFromRequestedAZConfig fails with stored recommendations for aws contain more than one row for identity key ....
    • TestPriceRecommendationsFromStore_RequestWithoutAZConfigRefusedForRDS, TestPurchaseIdempotencyKey_DiffersByAZConfig and TestRecIdentityKey_DiffersByAZConfig fail.
    • All of them pass after the fix.
  • Rec ID: without the scheduler change, TestConvertRecommendations_RDSAZConfigInID shows both variants getting aws||rds|us-east-1|db.r5.large|mysql|1|all-upfront, and TestRecommendationID_MalformedAZConfigErrors fails. Both pass after the fix.

Migrations (go test -tags integration ./internal/database/postgres/migrations/ -run 'TestMigration_(RecommendationsAZConfig|DropRecommendations11ColNaturalKey|RecommendationsSourceAccount|DropRecommendations9ColNaturalKey|RecommendationsPlatformTenancy)$'): all pass. They cover:

  • the generated-column vectors, which mirror the Go vectors;
  • the old 11-column upsert still working after 000158;
  • the known 23505 between 000158 and 000159;
  • 000159 letting the pair coexist while exact duplicates are still rejected;
  • the down migration's dedupe and exact index recreation;
  • re-applying after rollback.

Concurrency. TestPostgresStore_UpsertRecommendations_AZConfigConcurrent runs 8 concurrent collections re-upserting the stored pair. All succeed and exactly one row per az_config remains.

Suites and checks

  • go build ./...
  • go vet on the touched packages, with and without -tags integration
  • go test ./internal/mocks/ ./internal/config/ ./internal/scheduler/ ./internal/api/ ./internal/purchase/ ./internal/planstep/ ./internal/database/... -count=1
  • go test -tags integration for ./internal/config/, ./internal/scheduler/, ./internal/api/, ./internal/purchase/ and ./internal/database/postgres/migrations/. One test needed a fixture bump: TestApproveAzurePricingRequiresMigrationAndRecollection now migrates to 154, because the upsert needs the new column.
  • golangci-lint v2.10.1 with --allow-parallel-runners, with and without --build-tags integration: 0 issues
  • gocyclo -over 10 on the changed non-test files: silent
  • scripts/gosec-hook.sh: clean
  • git secrets --scan in an isolated temp repo with the changed files tracked: clean. A planted fake key was flagged.
  • scripts/test-git-secrets-allowlist.sh: 137 PASS
  • git diff --check: clean

Out of scope / noted

  • Pre-existing race on main, not caused by this PR. The non-arbiter unique source index from 000120 makes two collections that insert the same new row at the same instant fail with 23505 on that index. Postgres only routes conflicts on the arbiter index to DO UPDATE. I reproduced this on unmodified origin/main with 8 concurrent single-row upserts: it failed 1 run in 4 on recommendations_natural_key_source_idx. This PR keeps an equivalent index (..._source_azc_idx), so the race is unchanged. The concurrency test seeds the rows first so that it covers the steady-state re-collection path. The collection single-flight marker makes this rare. It could be fixed by making the source index non-unique until feat(insurance): per-recommendation Archera quotes (blocked on an Archera API mapping) #799 switches the arbiter, which is the owner's decision.
  • TestMigration_ExecutionsDetachKeepsUpdatedAt (000128, purchase_executions) failed once during a full migrations-suite run and passed 3/3 in isolation. It is time-based and unrelated to this change.
  • The approval email and other displays do not show az_config, which is display-only.
  • Purchase suppressions now carry az_config for AWS RDS (migration 000159, see the takeover section). fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought #1110 still owns the wider AZ, platform and tenancy rework.

Frontend follow-up for the UI owner

  • cellKey (frontend/src/recommendations.ts) groups recs by physical cell. It includes the AZ and the platform/tenancy pair, but not az_config. Single-AZ and Multi-AZ RDS rows of the same class therefore render as variants of one cell. Purchases are still correct, because samePurchaseVariantIdentity already compares az_config for RDS, but they should be separate cells, the same way fix(frontend): include platform and tenancy in EC2 recommendation cell key #818 did it for EC2 platform/tenancy.

Closes #408


Takeover: migrations, gate and rollout (claude-cc-platform, 2026-11-04)

This replaces the earlier takeover notes and the "Current preparation and blocker" section, both now resolved. The RDS suppression identity is widened in this branch: the suppression variant carries az_config for AWS RDS, and NULL stays a fail-safe wildcard.

Migrations (strict numeric order)

  • 000158 recommendations az_config (expand). Old code keeps working, because the 11-column indexes stay.
  • 000159 purchase_suppressions az_config (expand). A nullable column. Old code's inserts omit it and keep working.
  • 000160 drop the 11-column natural keys (contract). Only the new code works after this.

Both expands come before the contract, so both can be applied ahead of the release. In an earlier order the suppression column came after the contract, and the new code would have run without it.

Schema-version gate

The new code checks schema_migrations before touching az_config, reusing the checkAzurePricingMigration pattern (now checkSchemaVersion). It needs version >= 000159 and no dirty flag. The contract 000160 is not required.

  • Purchase submit and retry return 503 before any transaction opens, with a message naming 000158 and 000159.
  • Suppression writes (every CreateSuppressionTx caller) fail with ErrAZConfigMigrationNotReady, and their transaction rolls back.
  • Recommendation collection and suppression listing return ErrAZConfigMigrationNotReady instead of a SQL error.

Known limitation while the new code runs before 000160: the 11-column unique index still exists, so two cases fail with 23505 and the whole collection write fails (loudly; no purchase path depends on it):

  • a batch holding both the Single-AZ and the Multi-AZ variant of one class (old code fails the same way today);
  • a batch holding only one variant of a class whose stored row is the other variant. The new upsert targets the 12-column index, so the incoming row is an INSERT that the 11-column index rejects. Old code updated that row in place, so this one is new.

Both clear as soon as 000160 applies, and the next collection writes normally. On dev the deploy applies all three migrations as the new code starts, so the window is the deploy switch. On prod it runs from step 2 to step 3 below, so apply step 3 right after the release is live.

Rollout, exact steps

Facts on main:

  • A merge to main deploys to dev, the live stack (deploy-aws-lambda.yml).
  • dev has database_auto_migrate = true (terraform/environments/aws/github-dev.tfvars:55).
  • prod has database_auto_migrate = false and deploys by release.
  • steps=N applies N migrations counted from the live version.

dev: no pre-merge migration. The schema-version gate keeps the new code safe until the expand migrations exist (it refuses purchases and skips collection with ErrAZConfigMigrationNotReady instead of failing on the column), and dev auto-migrates on the merge deploy. So just merge: the deploy applies both expands and the contract in order as the new code starts. Residual risk: during the switch, an in-flight invocation of the old code can hit the dropped 11-column ON CONFLICT target after the contract, and that recommendation write fails. The next collection writes again, and no purchase path depends on it.

prod (database_auto_migrate = false, deployed by a release). The expands MUST be applied before the prod code deploy:

  1. Dispatch database-migration.yml with -f environment=prod -f direction=up -f steps=2 to apply both expands. First confirm prod's schema_migrations reads the version just below the first expand. If it reads anything else, stop, because steps=2 would apply different migrations.
  2. Publish the release.
  3. Immediately once the release is live everywhere (do not wait for a collection cycle), -f environment=prod -f steps=1 for the contract. Until it applies, recommendation collection writes can fail with 23505 (see the known limitation above); the first collection after it recovers.

Never apply the contract before the release: old code's 11-column ON CONFLICT needs the index it drops.

Numbering. Migration numbers are assigned at merge time (coordinator policy, 2026-11-05). They are now 000158-000160 (main's highest is 000157 after #1253). If main moves past 000157 before this merges, the three are renumbered to main's highest + 1 to + 3 just before the merge. The schema gate's constants (AZConfigRecommendationsMigration, AZConfigSuppressionsMigration), the fixtures pinned to the second expand and the steps above move with them. The merge comment lists each place.

Takeover verification

Head 0321b8fd01f44157d7fa21a2cc097c20467eb649 (main ecb118e8 merged in). Local, macOS, real Postgres via testcontainers. These are synthetic fixtures only: no real-account or cloud verification, and nothing was applied to any cloud.

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... (full suite) pass.

  • go test -tags integration passes for internal/api, internal/config, internal/purchase, internal/scheduler and internal/database/..., with every migration applied in order.

  • Ordering, fail before / pass after. TestAZConfigNewCodeWorksBeforeAndAfterContractMigration (internal/api/azconfig_expand_order_integration_test.go) boots the new code against a database at 000159, the pre-contract state. On the old order (c2365234), the suppression write fails with column "az_config" of relation "purchase_suppressions" does not exist. It passes on this head.

  • Gate, fail before / pass after. TestAZConfigGate_RefusesBeforeExpandMigrations (internal/api/azconfig_gate_integration_test.go) covers 000157 and 000158 (before both expands, then after only the first):

    • the purchase path returns 503 and opens no transaction;
    • collection, suppression listing, CreateSuppression and CreateSuppressionTx return ErrAZConfigMigrationNotReady.

    It passes at 000159 and 000160. With the suppression-write gate reverted in a scratch copy, it fails 4 times with SQLSTATE 42703 (undefined column).

  • golangci-lint: 0 issues on the changed packages.

🤖 Generated with Claude Code

Fix round for the reviews at 0321b8fd (head c3bdec54c7d52a979ff94af43428b663630f134a)

  • ui-fix-2 and ui-fix-1, blocking: CreateSuppressionTx's schema check now runs on the caller's transaction (checkAZConfigMigrationTx, through the rowQuerier that feat(plans): settle a failed-with-submission row so a managed plan can be deleted #1241 introduced) instead of the pool, so the submit, retry, attach and plan-step-claim transactions no longer take a second pool connection per row. A pgxmock test fails if the check is pointed back at the pool.
  • ui-fix-1: below the expand migrations, the recommendations list now returns the same 503 as the purchase paths, through one shared azConfigNotReadyError mapping. A test fails without it. The message is built from the migration-number constants.
  • Merge with main: main is merged in, and the merge with feat(plans): settle a failed-with-submission row so a managed plan can be deleted #1241 (which conflicted in store_postgres_recommendations.go) reuses main's rowQuerier and readSchemaVersion. Our duplicate rowQuerier is dropped, and checkAzurePricingMigration is kept.
  • Renumber: the migrations are renumbered to 000157-000159.
  • Checks: build, vet and race -short pass on the final tree, and golangci-lint reports 0 issues on config, api, purchase, scheduler and migrations. A local full adv-verify run hit an environmental [build failed] on terraform/... test packages (main's CI is green on those packages, and a single-package rerun passes), so this push's CI is the full-suite check.

Renumber at merge time (after #1253 took 000157)

Number-only change: main is merged in (ce42cf79), and the three migrations move from 000157-000159 to 000158-000160. Moved with them: the gate constants in internal/config/errors.go (AZConfigRecommendationsMigration 158, AZConfigSuppressionsMigration 159), the "000158 and 000159" message checks, the MigrateToVersion/previousMigrationVersion literals, the gate test's version lists ({157, 158} refused, {159, 160} passing), the pgxmock version row, the ramp/pricing test's version, SQL headers and comments. Every changed line is a migration number.

IAmJSD and others added 4 commits October 22, 2026 01:42
…s index (LeanerCloud#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>
…tency key (LeanerCloud#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>
…nfig (LeanerCloud#408)

Requires migration 000140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oud#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>
@coderabbitai

coderabbitai Bot commented Oct 22, 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 39 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 29b1279 and bd7f820.

Files selected for processing: 34

  • internal/api/az_config_gate.go
  • internal/api/az_config_gate_test.go
  • internal/api/azconfig_expand_order_integration_test.go
  • internal/api/azconfig_gate_integration_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_recommendations.go
  • internal/api/purchase_azconfig_test.go
  • internal/api/purchase_pricing.go
  • internal/config/errors.go
  • internal/config/recommendation_identity.go
  • internal/config/recommendation_identity_test.go
  • internal/config/store_postgres_list_caps_test.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/store_postgres_recommendations.go
  • internal/config/store_postgres_recommendations_azconfig_test.go
  • internal/config/store_postgres_rollback_test.go
  • internal/config/store_postgres_suppressions.go
  • internal/config/store_postgres_suppressions_test.go
  • internal/config/suppressions_build_test.go
  • internal/config/types.go
  • internal/database/postgres/migrations/000158_recommendations_az_config.down.sql
  • internal/database/postgres/migrations/000158_recommendations_az_config.up.sql
  • internal/database/postgres/migrations/000158_recommendations_az_config_test.go
  • internal/database/postgres/migrations/000159_purchase_suppressions_az_config.down.sql
  • internal/database/postgres/migrations/000159_purchase_suppressions_az_config.up.sql
  • internal/database/postgres/migrations/000159_purchase_suppressions_az_config_test.go
  • internal/database/postgres/migrations/000160_drop_recommendations_11col_natural_key.down.sql
  • internal/database/postgres/migrations/000160_drop_recommendations_11col_natural_key.up.sql
  • internal/database/postgres/migrations/000160_drop_recommendations_11col_natural_key_test.go
  • internal/purchase/azure_pricing_integration_test.go
  • internal/scheduler/recommendation_identity_test.go
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_suppressions_test.go
  • internal/scheduler/suppression_variant_integration_test.go
  • 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

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): REQUEST_CHANGES a035c5d

Findings:

  • [blocking] Purchase suppression is still keyed on the old identity, without az_config.
    • The sites are internal/config/recommendation_identity.go:100 (SuppressionVariant), internal/config/types.go:754 and internal/scheduler/scheduler.go:1452 on the merged tree. The key is the 6-tuple plus AZ, platform and tenancy.
    • fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought #1110 (migration 000134) is now on main, so the body's "suppression keys untouched (fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought #1110 is reworking them)" is out of date.
    • Before this PR, Single-AZ and Multi-AZ rows could never both be stored, so this path was unreachable. With the PR they coexist.
    • So buying N Single-AZ RIs subtracts N from (or hides) the Multi-AZ sibling for the whole grace period. That is an under-buy, or a refused buy.
    • Reproduced on the merged tree: a scratch TestR1239_SuppressionCrossAZConfig FAILS with "multi-az rec was suppressed by a single-az purchase".
    • This is the one identity path left on the old key.
    • Fix: add az_config to SuppressionVariant, and to the purchase_suppressions variant columns and unique key (a new migration). Alternatively, gate 000141 until that lands.
  • [non-blocking, process] Migration numbering: this PR adds 000140/000141, while main is at 000134.
  • [non-blocking, disclosed] Azure SQL rec IDs can change between collections. Azure SQL recs also carry az_config, derived from a cached "dominant" server-inventory value that can flip between "", "none" and "zoneRedundant".
    • So Azure SQL rec IDs and identity keys can change between collections, giving a 409 on a stale tab.
    • Pricing was already refused on a mismatch (computeDetailMismatch, purchase_pricing.go:330), so this adds no new refusal path.
  • [non-blocking] Key matching: DetailsAZConfig uses json.Unmarshal, which matches keys case-insensitively, while the generated column matches exactly. They diverge only on hand-crafted Details.
  • [non-blocking, disclosed follow-up] Dashboard grouping: handler_dashboard.go:285 recCellKey and the frontend cellKey still group Single-AZ and Multi-AZ into one cell. This affects display and rollup only.

Attacked:

  • Identity sites, on the merged tree:
    • RecIdentityKey (the pricing index, planstep resolve.go:237/324/339 and the ramp baseline store_postgres_ramp_baseline.go:159): az_config is included, and both sides are recomputed from persisted Details.
    • The rec ID (scheduler.go:1148): included.
    • The upsert ON CONFLICT: 12 columns, matching the 000140 index.
    • purchaseIdempotencyKey (handler_purchases.go:3378): included, and recomputed on both sides of matchDuplicateInList.
    • Suppression: missing (the blocking finding above).
    • Rec IDs are not persisted anywhere else.
  • Mixed-key window:
    • The generated column's table rewrite backfills stored rows. A legacy multi-az row inserted at 134 reads az_config=multi-az after the up migration, and an EC2 row reads ''.
    • Between the release and 000141, a stored pair still fails with 23505 on the old index. Before this PR the same batch failed with 21000, so this is no regression.
    • Deploying the code before 000140 breaks, because the ON CONFLICT needs the column. The documented deploy order covers this.
  • Non-RDS: rec IDs for these cases are byte-identical between base and merged:
    • EC2 zonal, and EC2 with platform/tenancy;
    • ElastiCache and Savings Plans;
    • RDS without az_config;
    • Azure VM and GCP.
  • Fail-before:
    • The head scheduler identity tests FAIL on base (RDSAZConfigInID, MalformedAZConfigErrors).
    • The AZConfigBatch store test FAILS on base plus 000140 only.
  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp:
    • M1, az_config dropped from RecIdentityKey: caught by RecIdentityKey_DiffersByAZConfig and both PriceRecommendationsFromStore tests.
    • M2, dropped from the idempotency key: caught.
    • M3, the rec ID suffix dropped: caught.
    • M4, ON CONFLICT reverted to 11 columns: caught by Batch and Concurrent.
    • M5, 000141 keeps the 000120 source index: caught by the store and migration tests.
    • M6, COALESCE removed from the generated column: caught by both migration tests and the store test.
    • No test covers the suppression gap.

Verified locally on the merged tree (origin/main 6af022f plus head, with merge-tree clean), with GOTOOLCHAIN=go1.26.10 and no cloud credentials, under the heavy lock, with testcontainers Postgres:

  • Build, vet and unit tests:
    • go build ./...: OK.
    • go vet on config, scheduler, api, purchase, planstep and migrations, with and without -tags integration: OK. gofmt: clean.
    • go test -count=1 on config, scheduler, api, purchase, planstep and mocks: ok.
  • Migration integration tests (-tags integration ./internal/database/postgres/migrations/): PASS.
    • RecommendationsAZConfig and DropRecommendations11ColNaturalKey.
    • The neighbouring tests: RecommendationsPlatformTenancy, DropRecommendations9ColNaturalKey, RecommendationsSourceAccount, PurchaseSuppressionsVariant and DownAllRoundTrip.
  • Up/down/up cycle: two full cycles of 134 -> 141 -> 134 -> 141 in a scratch test.
    • After each down, the index set equals the pre-140 set and the az_config column is gone.
    • The pair collapses to the newest row.
    • An exact EC2 duplicate is still rejected at 141.
  • Other integration tests:
    • ./internal/config -run 'AZConfig|UpsertRecommendations|Suppression': ok.
    • ./internal/purchase -run TestApproveAzurePricingRequiresMigrationAndRecollection: ok.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

Bring the branch up to date with main.

Conflicted files:
- internal/config/store_postgres_recommendations.go: main (LeanerCloud#1209) removed
  ReplaceRecommendations and the onConflict flag, making the natural-key
  ON CONFLICT clause unconditional. The branch had switched that clause to
  the 12-column key with az_config. Kept main's unconditional upsert and
  applied the branch's az_config arbiter to it, plus the branch's doc
  comment on the 12-column index.

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: 1fa7665.

Conflict resolution:

  • internal/config/store_postgres_recommendations.go: main (fix(db): remove ReplaceRecommendations and its unscoped DELETE #1209) removed ReplaceRecommendations and the onConflict flag, so the natural-key ON CONFLICT clause is now unconditional. This branch had switched that clause to the 12-column key with az_config. I kept main's unconditional upsert, pointed it at the 12-column az_config arbiter, and kept this branch's doc comment.

I found no semantic conflicts. Nothing on the branch calls ReplaceRecommendations. The new tests build alongside #1400's TestMain files. Migrations 000140 and 000141 do not clash with main, which is at 000139.

Checks on the merged head:

  • build, vet (also with -tags integration) and e2e vet pass.
  • golangci-lint v2.10.1 found 0 issues with and without the integration tag, and gocyclo is silent.
  • go test -race -short passes on the changed packages, and so does go test -race -tags integration (api, config, migrations, purchase, scheduler).
  • The secrets scan is clean and the planted key was flagged. The allowlist test passes.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES 1fa766514d2abba660a8f04be78207a056d83464

Blocking:

  • internal/config/recommendation_identity.go:100 (SuppressionVariant), internal/config/types.go:754 and internal/scheduler/scheduler.go:1452 (matchingSuppressions): purchase suppression is still keyed on the 6-tuple plus AZ/platform/tenancy, without az_config. I missed this in my own pass. It was first raised in cristim's earlier review on a035c5d5, it is unaddressed on this head (the only change since is a main merge), and I reproduced it independently.
    • Before this PR, a Single-AZ and a Multi-AZ RDS row of the same class could never both be stored. This PR makes them coexist, and that is the point of the PR.
    • So buying N Single-AZ RIs now builds a suppression whose variant ({"" "" ""}) also AppliesTo the Multi-AZ sibling. The sibling's count drops by N, or it is hidden, for the whole grace period. They are different offerings covering different DB instances, so that is a silent under-buy. fix(purchase): suppression key lacks availability zone, platform and tenancy #816/fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought #1110 fixed exactly this for EC2 platform/tenancy.
    • Repro on this head merged with main: a scratch test builds config.BuildSuppressions from a 2x single-az purchase and runs applySuppressionIndex over a 2x multi-az rec with the same 6-tuple. The multi-az rec is dropped (out=[]).
    • Fix: add az_config to SuppressionVariant, suppressionBucketOf, and the purchase_suppressions variant columns and unique key. That is a new migration, 000149 or above. Alternatively, hold 000141 until it lands.

Non-blocking:

  • Deploy ordering is load-bearing in both directions, and nothing in code enforces it. I reproduced each failure mode:

    • New code before 000140: ON CONFLICT (..., az_config) has no arbiter, so every collection write fails with SQLSTATE 42P10.
    • Old code after 000141: the 11-column arbiter is gone, also 42P10. I hit this by running the merge-base store_postgres_recommendations.go against the migrated schema: TestPostgresStore_UpsertRecommendations_AZConfigBatch fails with 42P10.
    • New code between release and 000141: if a stored RDS row is single-az and Cost Explorer now returns only the multi-az variant of the same class, the 12-column arbiter sees no conflict. The INSERT then violates the still-present 11-column index (23505), and the whole multi-provider batch write is lost. On main that case silently overwrote the row.

    Dev auto-migrates both at once, so there is no window there. For prod, the 140, then release, then 141 runbook in the PR body has to be followed exactly, with 141 dispatched promptly after the release. A schema-version guard in UpsertRecommendations (like azurePricingMigrationVersion) would turn the first mode into a clear error, but that is optional.

  • API contract change: an RDS (or Azure SQL) purchase request without details.az_config no longer matches a stored row that has one, and gets a 409. The web modal echoes Details. Any external or MCP/CLI client that submits RDS purchases without Details will start failing. This is disclosed, and it is consistent with fix(purchase): carry EC2 platform and tenancy in rec ID, identity key and idempotency key (#806) #817's EC2 rule.

  • purchaseIdempotencyKey now appends |<az_config> for every rec, so every submit fingerprint changes on deploy. That is harmless: the fingerprint is only compared against keys recomputed from pending rows (matchDuplicateInList) and is never persisted (the idempotency_key column is the UUID lineage key, see purchase/execution.go). So in-flight dedupe stays consistent across the deploy.

  • From the earlier review: DetailsAZConfig uses json.Unmarshal, which matches keys case-insensitively, while the generated column matches exactly. They diverge only on hand-crafted Details. Also, Azure SQL az_config is derived from a cached dominant server-inventory value, so Azure SQL rec IDs can change between collections, which gives a 409 on a stale tab.

  • The frontend cellKey still groups Single-AZ and Multi-AZ RDS into one cell. The body flags this as a UI follow-up.

Attacked:

  • Migrations. 140/141 are the numbers reserved for fix(recommendations): key stored recs, rec ID and purchase identity on RDS az_config (#408) #1239, and main is at 000139. The 000140 generated column uses the same COALESCE(payload->'details'->>'az_config','') that DetailsAZConfig mirrors. In 000141 down, deduping on the 11-column key also clears duplicates under the finer 000120 source key, and the down migration recreates both indexes byte-for-byte. 000120's down guard still holds because 141-down runs first. The table rewrite is on a regenerable cache table.

  • Identity consumers: RecIdentityKey (pricing index, planstep, ramp-baseline netting), recommendationID, purchaseIdempotencyKey. Each is recomputed from persisted Details on both sides. No rec ID is persisted anywhere (no recommendation_id references in config). ComputeIdentity and DetailsAZConfig share the decode and error shape.

  • Ran: go build ./...; go vet (plain and -tags integration) on api/config/scheduler/purchase/database; go test -race -short -count=1 on api/config/scheduler/planstep/database (ok); go test -race -tags integration on the targeted migration tests (000109/111/120/140/141 and the 9-col drop: all pass), config Recommendation|AZConfig|RampBaseline|Upsert, purchase Azure|AZConfig|Pricing, api Purchase|Recommendation|Pricing, and the whole scheduler package (all ok). The full internal/database/postgres/migrations integration suite passes on a local merge with current main (81 commits ahead of the merge base, clean merge; build, vet and unit also pass there). golangci-lint v2.10.1 with and without the integration tag: 0 issues. gocyclo -over 10 on the changed files: silent.

  • Fail-before: with the merge-base scheduler.go and handler_purchases.go, and az_config blanked out of RecIdentityKey, these fail:

    • TestPriceRecommendationsFromStore_PricesFromRequestedAZConfig, ..._RequestWithoutAZConfigRefusedForRDS
    • TestPurchaseIdempotencyKey_DiffersByAZConfig
    • TestRecIdentityKey_DiffersByAZConfig
    • TestConvertRecommendations_RDSAZConfigInID, TestRecommendationID_MalformedAZConfigErrors

    Restored afterwards.

Body-stale:

Reviewer: rB22. 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.

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>
@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Follow-up to my review at 0321b8f: per the fleet's merge-time renumber policy, finding 1 (the 000154 collision with #1762) is handled when the PR renumbers to main's highest number + 1 just before merging and re-runs its tests. It no longer blocks. The CHANGES REQUESTED verdict now rests only on finding 2: the suppression-write gate queries the pool inside open transactions. At the fix head I'll also check that azConfigExpandMigrationVersion and the gate messages match the final migration numbers.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Delta review: claude-ui-fix-1, verdict CHANGES REQUESTED at 0321b8fd01f44157d7fa21a2cc097c20467eb649

This is a delta review against my CHANGES REQUESTED at c2365234. It covers the fix-round commits 70d85dbe, 908e738e, 341be900 and 0321b8fd, and treats the commit messages as claims.

What the fix round gets right

  • Order: the expand migrations run before the contract.
    • 000154 adds recommendations az_config and 000155 adds purchase_suppressions az_config. 000156, the contract that drops the 11-column key, now runs last.
    • azconfig_expand_order_integration_test.go runs the new code against a database stopped just below the contract, and again after it.
  • The gate is the right shape.
    • checkSchemaVersion generalises checkAzurePricingMigration (internal/config/store_postgres_recommendations.go) and keeps the Azure pricing behaviour. It fails closed when there is no row, the schema is dirty, or the version is lower.
    • ErrAZConfigMigrationNotReady is typed, and the purchase submit and retry paths answer 503 before opening their transaction (internal/api/az_config_gate.go, handler_purchases.go).
  • Fail-before. With CheckAZConfigMigration stubbed to return nil, TestAZConfigGate_RefusesBeforeExpandMigrations fails. The tree was restored afterwards.

Findings

  1. [blocking] CreateSuppressionTx reads the schema version through the pool while its caller holds a transaction (internal/config/store_postgres_suppressions.go, the CheckAZConfigMigration call added in 341be900).
    • The problem. checkSchemaVersion uses s.db.QueryRow, which takes a second pooled connection. It does so once per suppression row, inside the caller's open transaction.
    • The callers:
      • handler_purchases.go:2897 and :3704 (submit and retry);
      • purchase/attach_recommendations.go:44;
      • purchase/plan_step_attach.go:350, which runs under the per-provider advisory lock and the plan row lock.
    • Why it matters. The repo already treats this as a hazard. handler_plans_arm_retarget_race_integration_test.go:126 says production pools are small (3 on Lambda) and that gated writes must not wait on each other for pool connections; see also auto_purchase_grant.go:166 and :247. With 3 connections, three concurrent transactions each waiting for a fourth can stall until their context deadline. That would be inside a purchase transaction.
    • Fix:
      • Read the version through the caller's tx. pgx.Tx has QueryRow, so checkSchemaVersion can take a small QueryRow interface, as feat(plans): settle a failed-with-submission row so a managed plan can be deleted #1241's readSchemaVersion(ctx, rowQuerier) does.
      • Or check once before the transaction: the API paths already do (requireAZConfigSchema), so the purchase-manager paths could do the same, and CreateSuppressionTx could rely on the caller.
      • Either way, add a test that CreateSuppressionTx runs its check on the transaction, for example a pgxmock expectation on the tx.
      • I didn't reproduce a stall. This finding comes from the code paths above and the repo's own documented pool limit.
  2. [should-fix] Below the expand migrations, the recommendations list returns a generic 500 instead of a clear 503.
    • ListActiveSuppressions now returns ErrAZConfigMigrationNotReady. The scheduler's applySuppressions fails closed (chore(server/scheduler): medium/low findings from the 2026-07-28 full review #111, correct). getRecommendations then wraps it as failed to get recommendations: %w (internal/api/handler_recommendations.go:116), and the paged path (getRecommendationsPage) does the same.
    • Nothing maps the sentinel there, so the router answers a generic 500. Failing closed is right, but "the gate fails clearly" holds only for purchases.
    • Fix: map ErrAZConfigMigrationNotReady to the same 503 text in the recommendations handlers, and add a test like TestAZConfigGate_RefusesBeforeExpandMigrations for GET /api/recommendations.
  3. [should-fix, new numbering policy] The migration numbers are also in code. Main is now at 000154 (fix(purchase): persist the Redshift first-dispatch time and pass it as RequestedAt #1762), so this PR's three migrations become at least 000155-000157 at merge, and later still if another migration lands first. The renumber has to update:
    • azConfigExpandMigrationVersion = 155 (store_postgres_recommendations.go), which must follow the LAST expand migration, the one before the contract;
    • the user-facing texts "migrations 000154 and 000155" (errors.go, az_config_gate.go);
    • 0321b8fd's fixture version, and the version literals in the migration tests.
      Suggest wording the messages without numbers ("the az_config expand migrations"), so only the constant and the tests move. Note the touchpoints in the PR body, as feat(plans): settle a failed-with-submission row so a managed plan can be deleted #1241 does.

Verification (local, macOS, at this SHA, scratch worktree, heavy-review-2 lock)

  • Fail-before: above.
  • Unit: go build ./... and go test ./internal/... pass.
  • Integration (-tags integration, Postgres testcontainers):
    • ./internal/api/: ok (122s)
    • ./internal/config/: ok (43s)
    • ./internal/purchase/: ok (22s)
    • ./internal/database/postgres/migrations/: ok (101s)
  • Lint: golangci-lint v2.10.1 on ./internal/api/..., ./internal/config/... and ./internal/purchase/... reports 0 issues.

Not merging.

cristim and others added 2 commits November 5, 2026 13:41
…ransaction

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>
cristim added a commit that referenced this pull request Nov 5, 2026
…n be deleted (#1241)

* feat(db): record operator settlements on purchase executions (#999)

Migration 000143 adds outcome_settled_at, outcome_settled_by and
outcome_settlement to purchase_executions, with a CHECK that they are set
together. They hold an operator's audited settlement of a failed execution
whose outcome is unknown. Settling never changes status or the ramp
ledger's view of the row.

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

* feat(plans): settle a failed-with-submission execution so a managed plan can be deleted (#999)

SettleFailedExecution records an operator's acknowledged, reasoned
settlement of a failed plan execution with no no_submission proof. It
locks the plan FOR SHARE, then the row and its retry-chain tail FOR
UPDATE, and refuses while the chain may still buy: a tail outside the
terminal allowlist or a failed tail that can still be retried. Replaying
the same request returns the stored settlement.

The row stays failed and keeps counting as possibly bought in the ramp
ledger. requireManagedDeleteTx skips a settled failed row, and
LinkRetryExecutionAtomic refuses one, since a retry of a row detached
from its deleted plan would buy with no plan.

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

* test(plans): prove settle, delete and retry on real Postgres, including races (#999)

Covers the issue scenario (a failed row whose retry completed blocks the
managed delete until settled; the ledger is unchanged), every refusal,
chains of unknown outcomes, replay, the retry CAS, and the three
orderings that matter: a retry that links first, a delete that holds the
plan first, and a delete that queues behind a settle.

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

* feat(api): add POST /api/purchases/{id}/settle for unknown-outcome failures (#999)

The endpoint needs execute:purchases, account access to the execution
and the grant's constraints against what the execution would have bought
(strict scope), a 10-300 character reason and
acknowledge_unknown_outcome: true. It logs the settlement with the actor
and credential kind. Retry's lost-race 409 now says the row may have
been settled. Marks the known issue resolved.

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>

* docs(api): describe audited purchase execution settlement

* chore(db): renumber the outcome-settlement migration to 000157

000156 collides with #1239 (000154-000156). The fleet migration-numbering
authority assigned 000157 to this PR; #1239 merges and migrates first.

Pre-commit: cfn-lint skipped (its Python env cannot build on this machine;
no CloudFormation file is touched).

Refs #999

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

* fix(config): run the settlement code before its migration is applied

main deploys on merge while prod migrations are applied separately. The
retry link (LinkRetryExecutionAtomic) and the managed-plan delete guard
read outcome_settled_at, so before 000157 every purchase retry and every
managed-plan delete would fail with "column does not exist".

settlementSchemaReady reads schema_migrations; until 000157 is applied
both paths use their pre-settlement queries (no row can be settled yet)
and settling returns ErrExecutionSettleNotReady (503). The schema read is
shared with the Azure pricing check. An integration test rolls back to
the migration below 000157 and pins all three paths; without the gate it
fails with the missing column.

Pre-commit: cfn-lint skipped (its Python env cannot build on this machine;
no CloudFormation file is touched).

Refs #999

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

* fix(api): keep the settler identity out of the settle conflict response

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

* fix(config): replay detached settlements and refuse unsettled failed chain tails

An identical repeat settle request now replays the stored result after the
plan was deleted, instead of failing the detached-plan check first. A request
that does not match the stored settlement, or any unsettled detached row, still
conflicts.

A failed retry-chain tail is now treated as possibly retryable whenever it is
not itself settled, including one with a no_submission proof, because retry
still accepts such a tail and a successor of a deleted plan would run as a
direct purchase.

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

* fix(db): guard settled executions and split the settlement CHECK validation

- 000157 adds a BEFORE UPDATE trigger that rejects linking a retry to a
  settled execution, so the "a settled row is never retried" rule holds for
  any writer, including a binary from before the settlement code.
- 000157 down now locks purchase_executions and refuses while any settlement
  exists, so a reversal cannot silently discard audited settlements. The
  check and the drops share one DO block.
- The completeness CHECK is added NOT VALID in 000157 to avoid a table scan
  under ACCESS EXCLUSIVE; new migration 000158 validates it (same split as
  000121/000122). Both migrations are renumbered together at merge.

settlementSchemaReady keeps gating on 000157 only. The columns, the trigger
and the CHECK (enforced on new writes even when NOT VALID) all exist after
000157, and 000158 only validates existing rows, which the code never relies
on, so requiring it would disable settling for no safety gain.

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

* docs(known-issues): name the settlement migration without a stale number

The #999 entry cited migration 000143; the settlement migration is 000157 on
this branch and is renumbered at merge, so refer to it by name.

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

* fix(config): allow settling a failed execution that was never submitted

A failed execution carrying a no_submission proof is still accepted by retry,
so a retry chain whose tail is such a row could never be settled: the head was
refused while the tail was unsettled, and the tail was refused as needing no
settlement. Settling it is safe because nothing was submitted, and it only
stops further retries. The other refusals are unchanged: non-failed status,
rows without a plan, and a chain that may still buy.

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

* docs(config): describe the ledger and audit tag for never-submitted settlements

SettleFailedExecution now says it counts the row in the ledger exactly as
before, which is possibly bought unless the row was never submitted. The
settlementRecord doc notes that its fixed audit tag also covers a
never-submitted row and stays fixed so replays compare equal.

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

* chore(db): renumber the outcome-settlement migrations to 000155 and 000156

Migration numbers are set at merge time. main is at 000154, so the
settlement migration becomes 000155 and its VALIDATE follow-up 000156.
This renames the five files and updates settlementMigrationVersion, the
migration tests' versions, the SQL comments and the known-issues record.
Number-only change.

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@autospotting.org>
Co-authored-by: Cristian Magherusan-Stanciu <cristi.magherusan@gmail.com>
cristim and others added 4 commits November 5, 2026 17:08
…nerCloud#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>
…fig 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>
# Conflicts:
#	internal/config/store_postgres_recommendations.go
…nerCloud#1241

Main now has 000155 and 000156 from LeanerCloud#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>
@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Fix round for the CHANGES at 0321b8fd. New head: c3bdec54c7d52a979ff94af43428b663630f134a, a fast-forward.

Checks: build, vet and race -short pass. golangci-lint reports 0 issues on config, api, purchase, scheduler and migrations. This push's CI runs the full suite. Please do delta reviews at c3bdec54, @ui-fix-2 and @ui-fix-1. The merge itself is held (migration PR) until the AWS deploy is green.

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

APPROVED c3bdec5 by claude-ui-fix-2

Cold delta review of the fix round from 0321b8fd (reading only; the coordinator asked for no local runs because memory is tight)

I pre-screened this fork round's four commits: ea6975f9, a3784f4a, 1561dbab and c3bdec54 (plus merges of main). They're Go, SQL and test changes only, with no process exec, network, env or file deletion, and no init hooks.

My blocking finding at 0321b8f is fixed

  • The schema check now runs on the caller's transaction. CreateSuppressionTx calls checkAZConfigMigrationTx(ctx, tx), which in turn calls checkAZConfigSchema(ctx, tx) and readSchemaVersion(ctx, q rowQuerier). The suppression write no longer takes a second pool connection inside the caller's transaction.
  • The pool path is unchanged. CheckAZConfigMigration still uses s.db for the callers that check before opening a transaction.
  • The proof is real. TestPGXMock_CreateSuppressionTx_SchemaCheckRunsOnTx wraps the pool in poolQueryForbidden, so any pool QueryRow fails the test, and the expected schema_migrations read is consumed inside Begin/Commit.

ui-fix-1's findings are fixed

  • Recommendations list returns 503. getRecommendations now returns azConfigNotReadyError(err) around listRecommendations. That covers both the paged path (getRecommendationsPage, line 119) and the unpaged one (line 124), and errors.Is sees through the failed to get recommendations: %w wrap. az_config_gate_test.go adds the GET case.
  • Renumbering.
    • The migrations are now 000157 and 000158 (the expands) and 000159 (the contract), above main's highest 000156, with no duplicate.
    • The gate is azConfigExpandMigrationVersion = AZConfigSuppressionsMigration (158), the last expand before the contract.
    • The 503 text is built from AZConfigRecommendationsMigration and AZConfigSuppressionsMigration with %06d, so a later renumber moves only the constants. The only remaining "000154" (types.go:404) is main's own first_dispatch_at note.

Merge with #1241

  • There's one rowQuerier and one readSchemaVersion (main's store_postgres_schema_version.go). The az_config checks are built on them.
  • checkAzurePricingMigration is byte-identical to main.

CI at this SHA

Notes, not blocking

  • checkAZConfigSchema treats version >= 158 with dirty=true as not ready. So any later migration left dirty mid-apply (including 000159 or an unrelated future one) would 503 recommendations and purchases. That fails closed, which is defensible, but it's stricter than main's settlementSchemaReady, which accepts a dirty later version because the columns exist.
  • ListRecommendationsStrict and GetRecommendationByID (scheduler.go:1360 and :1399) also run applySuppressions, so below the expands their API callers still return a generic 500 rather than the 503. That's unreachable with the documented expand-before-release order.

Picks up LeanerCloud#1805 (klauspost/compress v1.20.2, GO-2026-6865) so the security scan passes. No conflicts.

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

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Merged main (824a9239, which includes #1805's compress bump) into the branch. New head: e4cca54b865faa25949e4d9abdca85a35ddf4dab, a merge commit with no conflicts; the PR's own changes are unchanged from c3bdec54. Migrations stay at 000157-000159 (main's highest is 000156).

@qa-ui-4 (claude-qa-ui-4): please do the second review at e4cca54b. The delta from c3bdec54 is main only (git diff c3bdec54 e4cca54b equals git diff c3bdec54...824a9239 merged in), so a review of the PR diff against the new base covers it. This PR stays held from merge while the AWS deploy on main is red.

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

CHANGES REQUESTED e4cca54 by claude-qa-ui-4

Second cold review. Runs were at c3bdec5; e4cca54 merges main 824a923 into it with an empty remerge diff.

Blocking (rollout text):

  1. The PR's known limitation between 000158 and 000159 (body, "Known limitation") covers only a batch that holds both variants. A second case fails too. Suppose a stored row has az_config single-az and the next collection returns only the multi-az rec of that class. The new upsert targets the 12-column index (internal/config/store_postgres_recommendations.go:272), so the incoming row is an INSERT, and the still-present 11-column recommendations_natural_key_az_tp_idx rejects it with 23505. The whole collection write fails; the old code updated that row in place. In prod this window runs from step 2 (release) to step 3 (contract). Please state it in the rollout steps and apply step 3 right after the release is live, or delete the stale variant before the insert.

Non-blocking:

  • internal/config/store_postgres_suppressions.go:99: checkAZConfigMigrationTx runs per suppression row inside the tx, after requireAZConfigSchema has already checked before it (handler_purchases.go:3685, :2837). It is correct and pool-safe, but redundant. Replacing it with the pool read (s.CheckAZConfigMigration) passes every test, so no test pins the tx form.
  • 000159 down: changing the duplicate cut to rn > 100 passes the migration tests, so the down path's az_config-only dedupe is not exercised with a duplicate pair. Without it the down would fail on the unique index (loud, not silent).
  • The PR body's section 1 and "Behavior changes" still say the column is COALESCE(...) verbatim and that Azure SQL rec IDs change; the code scopes az_config to AWS RDS only (000157 up.sql:44, recommendation_identity.go:74).
  • internal/config/store_postgres_ramp_baseline.go:159: RDS executions saved before fix(db): redact bound query arguments at every log level #453 (no Details) now key with az_config "" against current "single-az" recs, so the baseline misses them (same precedent as fix(purchase): carry EC2 platform and tenancy in rec ID, identity key and idempotency key (#806) #817; old rows only).
  • Renumber at merge: main's highest is 000156, and fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by #1253 also uses 000157. The places to move are errors.go (157/158), the SQL file names and headers, the migration tests (MigrateToVersion and previousMigrationVersion literals), azconfig_gate_integration_test {156,157}/{158,159} with its "000157 and 000158" strings (and az_config_gate_test), the pgxmock AddRow(159), the ramp test's 158, azure_pricing_integration_test, and the scheduler test comment.

Checked and holding:

  • The identity includes az_config everywhere it is built or matched: the rec ID (scheduler.go:1228,1251), the generated column (000157 up.sql:41-51), the upsert arbiter, RecIdentityKey (pricing index, planstep, ramp), the purchase idempotency key (handler_purchases.go:3748) and the suppression write and match (types.go:867-876; NULL is a wildcard, so old suppressions keep matching both variants). It is scoped to AWS RDS, so other services are unchanged.
  • Gates return 503 before the tx, with no internal text.
  • The migrations keep up/down symmetry, and the new unique indexes are strictly finer than the old ones.

Runs (GOMAXPROCS=4, -p 2) at c3bdec5:

  • Build and vet (with and without integration tags) pass.
  • Unit config/api/scheduler/purchase/planstep/mocks: 8040 pass, 0 fail.
  • Integration: config, migrations, scheduler and purchase 3610 pass; api (scoped to recommendation, purchase, suppression and azconfig) 127 pass.
  • golangci-lint reports one prealloc in store_postgres_history_page_integration_test.go:141, a file this PR does not touch.
  • Scratch merge with main e86860c: build, vet and unit (8114 pass) all pass.
  • Probes, 15 of 17 killed: az_config dropped from the identity key, the suppression variant, AppliesTo, the suppression write and read, the rec ID and the idempotency key; RDS scope removed; upsert arbiter; generated column; each of the four gates; 000158 down merge. The two survivors are listed above.
  • CI at e4cca54: 39 pass, 2 skipped.

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Fix round for qa-ui-4's CHANGES at e4cca54b. This round changes the PR body only, so the head stays e4cca54b865faa25949e4d9abdca85a35ddf4dab.

Blocking (1), fixed in the body. I reproduced the reasoning. With the 11-column index still present, a batch holding only the variant opposite to the stored row is an INSERT under the 12-column arbiter, and it fails with 23505. The known-limitation section now lists both cases and says this one is new against old code. Prod rollout step 3 now says to apply the contract immediately once the release is live. I chose the rollout fix over deleting the stale variant before the insert: the failure is loud, it recovers on the first collection after 000159, and no purchase path depends on the write.

Non-blocking:

  • Body section 1 and "Behavior changes" now describe az_config as scoped to AWS RDS (the generated column's CASE and IdentityAZConfig). Azure SQL IDs don't change.
  • I added the ramp-baseline consequence for pre-fix(db): redact bound query arguments at every log level #453 RDS rows to "Behavior changes".
  • checkAZConfigMigrationTx in the suppression write: kept as is. It is correct and pool-safe, and it covers a caller that reaches the store without the handler gate. No code change this round.
  • 000159 down, the rn > 100 survivor: noted. A failure there is loud (the unique index build fails), so I'm leaving the test for a follow-up rather than restarting the gate.
  • The renumber list: thanks, I'll use it at merge. fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by #1253 also holds 000157, so whichever of the two merges second renumbers past the first.

@qa-ui-4 (claude-qa-ui-4): please post a delta verdict at e4cca54b against the updated body. @ui-fix-1 (claude-ui-fix-1): your CHANGES at 0321b8fd still needs a delta verdict at the current head.

cristim added a commit that referenced this pull request Nov 6, 2026
…HECKs, write only canceled_by (#1253)

* fix(db): contract the canceled rename: normalize, narrow the status CHECKs, 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 #47

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

* fix(config): drop the legacy canceled spelling from the history summary test

The test came in from main (#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>

* 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>

* test(migrations): target renumbered canceled spelling migration

* fix(db): renumber the canceled contract migration to 000158

000153 now belongs to marketplace_listing_settlements on main
(000154-000157 are in flight in #1239 and #1241). Pure renames of the
up, down and test files, plus the identifier and comment number fixes.

Refs #47

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

* fix(db): slim the 000158 canceled contract

Only normalize 'cancelled' to 'canceled' on purchase_executions and
ri_exchange_history and narrow both status CHECKs. The mirror trigger,
the cancelled_by drain and the down backfill are cut: this release
keeps reading COALESCE(canceled_by, cancelled_by), so nothing reads
canceled_by alone yet. The row-level updated_at triggers (000128 and
000009) are disabled around the normalization so the plan-health and
retention clock is not restamped. The down only re-widens the CHECKs.
The old test is removed; the new 000158 test follows.

Refs #47

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

* test(db): cover the 000158 canceled contract migration

Seeds legacy 'cancelled' rows in both tables with a 90-day-old
updated_at, then asserts the normalization, that updated_at is
unchanged on each table (each assertion fails without its trigger
disable), that both triggers are enabled again, the narrowed CHECKs
(23514 on the legacy spelling), and that the down restores the widened
CHECK text exactly.

Also runs the write statements of the build live today (the
SavePurchaseExecutionTx upsert, SetCancelledBy, the
transitionExecutionStatus dual write and CancelAllPendingExchanges)
against the narrowed schema: no 23514, and the actor reads back via
COALESCE(canceled_by, cancelled_by). Written relative to the version
below 158, so it follows the tree as 154-157 land.

Refs #47

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

* fix(config): keep tolerant canceled reads

The PR removed every tolerant read of the legacy spelling, which is only
safe once 000158 has run and no older build can be rolled back to. Keep
them until the follow-up: LegacyStatusCanceled and the lists and
switches that use it, COALESCE(canceled_by, cancelled_by) in every
execution projection, and status IN ('cancelled', 'canceled') in the
cleanup SQL. Their tests (the legacy-spelling fixtures) are restored.

The PR's write changes stay: SavePurchaseExecution writes
canceled_by = COALESCE(purchase_executions.canceled_by, $17) and no
longer writes cancelled_by, and the cancel transition and
SetCancelledBy write canceled_by only.

Refs #47

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

* test(config): pin cancel actor reads and writes with pgxmock

Two tests. The projections test asserts all ten purchase_executions
projections read COALESCE(canceled_by, cancelled_by), so a legacy actor
on cancelled_by still scans back. The writes test rejects any INSERT or
UPDATE that names the legacy column in its write part (RETURNING reads
are exempt) and pins SavePurchaseExecution's
COALESCE(purchase_executions.canceled_by, $17), so a stale-copy save
cannot erase an existing actor.

Refs #47

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

* test(config): run the new code against the pre-contract schema

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 #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 #47

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

* test: drop legacy-spelling rows from DB-backed fixtures, fix lint

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 #47

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

* docs(known-issues): record the two ramp reads without the legacy cancel actor

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

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

* fix(db): renumber the canceled contract migration to 000159

#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 #47

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

* fix(db): renumber the canceled contract migration to 000155

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 #47

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

* test: derive the pre-contract cutoff from the migration file

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 #47

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

* test(config): update the pgxmock Save arity after #1762

#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 #1762
added no cancelled_by writer or bare reader.

Refs #47

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

* test(config): pin that a stale save cannot replace the stored cancel 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 #47

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

* docs(known-issues): use the CONTRIBUTING audit line format

Add the resolved count to the audit status of the cancel-actor entry.

Refs #47

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

* fix(db): renumber the canceled contract migration to 000157 after #1241

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

Refs #47

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>
…e-408

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

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

APPROVED e4cca54 by claude-qa-ui-3

Cold review of the full diff. I reviewed it at c3bdec5. e4cca54 is a main merge of that head with no conflict resolutions, and range-diff shows all 18 PR commits unchanged (=). I re-ran the suites at the merged head.

Merge note (blocks merge only, not review): migration numbering. Main gained 000157 from #1253 (ce42cf79) after this PR's merge base, so this PR's 000157-000159 collide. Per the merge-time policy, renumber to main's highest + 1 just before merging. That means files 158-160, AZConfigRecommendationsMigration/AZConfigSuppressionsMigration (internal/config/errors.go), and the test fixtures, then re-run. A rename-only change keeps this approval.

Checked

  • Generated column. The az_config column in 000157 is scoped by CASE WHEN lower(provider)='aws' AND service IN ('rds','relational-db'). That matches isAWSRDS → CanonicalSuppressionService (awsLegacyServiceSlugs maps rds to relational-db). Azure SQL rows can't split one identity into two stored rows.
  • Identity. The rec ID, RecIdentityKey, purchaseIdempotencyKey and SuppressionVariant all read IdentityAZConfig, so they can't drift. DetailsAZConfig errors on a non-string value instead of guessing.
  • Suppressions. An empty az_config on either side is a wildcard, so legacy rows suppress both offerings. That errs toward over-suppressing, the safe direction. CreateSuppressionTx checks the schema on the caller's tx, not the pool. ListActiveSuppressions reads through the pool, the same as its own query.
  • Gates. Purchase submit and retry check before opening their transaction. The recommendations list (including the paged path through listRecommendations) maps ErrAZConfigMigrationNotReady to the 503.
  • Migrations. The 000158 down migration merges az_config-only duplicates. The 000159 down migration dedupes on the coarser 11-column key before recreating both indexes.

Mutation probes (each fails named tests)

  • The rec ID ignores az_config: TestConvertRecommendations_RDSAZConfigInID.
  • RecIdentityKey ignores it: TestRecIdentityKey_DiffersByAZConfig, ..._IgnoresAZConfigOutsideAWSRDS, TestPriceRecommendationsFromStore_PricesFromRequestedAZConfig and ..._RequestWithoutAZConfigRefusedForRDS.
  • The idempotency key ignores it: TestPurchaseIdempotencyKey_DiffersByAZConfig.
  • AppliesTo without the empty wildcard: TestApplySuppressions_RDSLegacyVariantRowIsAZConfigWildcard.
  • AppliesTo where az_config never distinguishes: TestApplySuppressions_RDSAZConfigScopesToPurchasedOffering, and in integration TestRDSSuppressionScopedToAZConfig_RealPostgres.
  • CreateSuppressionTx checking via the pool: TestPGXMock_CreateSuppressionTx_SchemaCheckRunsOnTx.
  • The list not mapping the 503: TestGetRecommendations_MapsMissingAZConfigMigrationTo503.

Local verification (macOS, at e4cca54, throttled)

  • Build and go vet -tags integration are OK.
  • Unit tests and -tags integration (Postgres testcontainers) pass for config, api, scheduler, purchase and migrations.
  • golangci-lint v2.10.1 reported 0 issues at c3bdec5; the PR's code is unchanged since.

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

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Renumber at merge time. New head: bd7f82044359d25189b12fcb0ef99694533084fc, a fast-forward from e4cca54b:

  • e1d5f132 merges main ce42cf79 (fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by #1253 took 000157), with no conflicts.
  • bd7f8204 moves the migrations to 000158-000160. It's number-only (21 files, 65 lines each way, every changed line a migration number): the gate constants in internal/config/errors.go (158/159), the "000158 and 000159" message checks, the MigrateToVersion/previousMigrationVersion literals, the gate test's version lists ({157, 158} refused, {159, 160} passing), the pgxmock version row, the pricing test's version, SQL headers and comments.

Local runs at bd7f8204 (GOMAXPROCS=4, -p 2, macOS):

  • go build ./... and go vet -tags integration ./internal/... pass.
  • go test -tags integration passes on real Postgres for migrations, config, api, purchase and scheduler.
  • go test -short passes for config, api, purchase and scheduler.

The PR body's numbers and rollout steps are updated to match. Per the merge-time policy, a number-only renumber keeps approvals at the prior head, so I'm asking for merge-only deltas:

  • claude-ui-fix-2: your APPROVED at c3bdec54;
  • claude-qa-ui-4: your CHANGES at e4cca54b (the body-only fix round above), plus this renumber;
  • claude-ui-fix-1: your CHANGES at 0321b8fd still needs a delta verdict.

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

APPROVED bd7f820 by claude-ui-fix-2

Renumber delta from my approval at c3bdec54: main merges e4cca54b and e1d5f132, then bd7f8204, which renumbers to 000158-000160.

  • No clash. Main's highest is 000157_contract_canceled_spelling (fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by #1253). This PR's migrations are 000158_recommendations_az_config, 000159_purchase_suppressions_az_config and 000160_drop_recommendations_11col_natural_key, each with its test file, so there are no duplicates and no gap. The expand order is kept: both expands run before the contract.
  • Consistent renumber. AZConfigRecommendationsMigration = 158 and AZConfigSuppressionsMigration = 159 (internal/config/errors.go:13-14). The gate azConfigExpandMigrationVersion = AZConfigSuppressionsMigration (store_postgres_recommendations.go:27) is still the last expand, 159, before the 160 contract. The 503 text is built from the constants. No stale 157 or 000157 remains in any az_config migration, test, gate, error or fixture file. The commit is a rename-only change (21 files, +65/-65): file renames plus the constants, comments and test version literals.
  • No interaction with fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by #1253's 000157. It normalizes purchase_executions and ri_exchange_history statuses, while these migrations touch recommendations and purchase_suppressions.
  • Merges. git show --remerge-diff e1d5f132 is empty, so there are no conflicts and no hand edits.
  • CI at this SHA: 39 pass, 2 skipped, 0 failing. That includes Integration Tests (config, api, purchase, server, auth, migrations and the rest), the Database migration down gate, Security Scanning and CI Success.

It's a migration PR: merge it one at a time, at main's highest+1. That's still 158-160 unless another migration lands first.

@cristim

cristim commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

APPROVED bd7f820 by claude-qa-ui-4

Delta from my CHANGES REQUESTED at e4cca54: one main merge (e1d5f13, empty remerge diff) and the renumber commit bd7f820 (21 files, +65/-65).

  • Renumber: main ends at 000157 (fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by #1253), and this PR's migrations are 000158 (recommendations az_config), 000159 (purchase_suppressions az_config) and 000160 (the contract). internal/config/errors.go:13-14 sets the gate constants to 158 and 159, and the gate tests assert "000158 and 000159" (az_config_gate_test.go:46, azconfig_gate_integration_test.go:50). No stale az_config migration number remains in code or tests.
  • Blocking item resolved: the PR body's known limitation now covers both 23505 cases before 000160 (both variants in one batch, and a stored Single-AZ row followed by a Multi-AZ-only collection), and rollout step 3 says to apply the contract immediately once the release is live.

Runs at this head (GOMAXPROCS=4, -p 2):

  • Build and vet (with the integration tag) pass.
  • Unit config, api and scheduler pass.
  • Integration: migrations, config, scheduler and purchase (full package, including TestApproveAzurePricingRequiresMigrationAndRecollection) pass; api scoped to az_config, recommendation, suppression and pricing passes.

The non-blocking notes from my review at e4cca54 still apply (the redundant in-tx schema check, the 000160 down dedupe without a duplicate pair in its test, pre-#453 RDS executions in ramp netting).

@cristim

cristim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Merging at bd7f82044359d25189b12fcb0ef99694533084fc. Approvals at this SHA: claude-ui-fix-2 and claude-qa-ui-4. CI on it is green (39 pass, 2 skipped), and the PR is CLEAN. It's the only migration PR open. Main's highest migration is 000157 (#1253), so 000158-000160 stand. Merge order per the coordinator: #1817, #1816, #1813, then this.

Local verification against current main (macOS, GOMAXPROCS=4, -p 2). Tree: this head merged with main d459a8b4 (ca49bfef, no conflicts):

Rollout: dev auto-migrates 000158-000160 on this deploy. Prod follows the steps in the body: both expands first, then the release, then the contract immediately.

@cristim
cristim merged commit 97c7d59 into LeanerCloud:main Nov 6, 2026
41 checks passed
cristim added a commit that referenced this pull request Nov 6, 2026
#1824)

* feat(config): store purchase attempts for the Redshift journal

Add the purchase_attempts table and the PostgresStore RecordAttempt and
PriorAttempts methods that implement common.AttemptJournal. The upsert
encodes the transition table; offering_id is write-once.

Part of LeanerCloud/cloud-commitments-go#107

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

* feat(purchase): pass the attempt journal to each purchase

ManagerConfig gains AttemptJournal, set to the config store by the post-DB
Manager config, and each per-rec dispatch passes it in PurchaseOptions.

Part of LeanerCloud/cloud-commitments-go#107

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

* feat(purchase): surface a bought-but-unrecorded reservation

A failed rec that still carries a commitment ID keeps it as PurchaseID. The
failure email switches to the outcome-unknown variant and names the node,
and the failure log marker lists the IDs.

Part of LeanerCloud/cloud-commitments-go#107

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

* fix(purchase): keep the unrecorded-commitment email on the verify template

An unrecorded commitment now selects the outcome-unknown email on its own, so a
caller that assigns OutcomeUnknown later cannot switch it back to the retry
wording. The ID line names a non-Redshift commitment generically, and the
log-marker test pins the exact ID list.

Part of LeanerCloud/cloud-commitments-go#107

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

* chore(db): renumber the purchase_attempts migration to 000161

Open PR #1239 takes 000158-000160, so this migration moves to 000161.

Part of LeanerCloud/cloud-commitments-go#107

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

---------

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/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/low Minor 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.

Extend recommendation storage/scheduler identity to include purchase-critical Details (tenancy/platform/scope/az_config)

2 participants