Skip to content

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

Draft
IAmJSD wants to merge 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-408
Draft

IAmJSD wants to merge 4 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 (four commits, expand/contract like the #792/#806 chains)

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

    • a generated az_config column, COALESCE(payload->'details'->>'az_config', ''), verbatim. 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 000140.

  4. fix(db) migration 000141 (contract). Drops the 11-column index and 000120's recommendations_natural_key_source_idx. Each has a strictly finer replacement from 000140, 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.

Prod deploy order (auto_migrate=false)

  1. Dispatch database-migration.yml for 000140 only (steps counted from the live prod version).
  2. Release this code.
  3. Dispatch 000141 only after step 2 is live everywhere. Old code's 11-column ON CONFLICT needs the index that 000141 drops, so never run steps=0 before the release.

Dev auto-migrates at startup, so the window there is one deploy. If a maintainer prefers separate PRs as in #808/#810/#811, the four commits split cleanly.

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 000141 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 000140;
  • the known 23505 between 000140 and 000141;
  • 000141 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 140, 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 suppression keys are untouched (fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought #1110 is reworking them for AZ, platform and tenancy).

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

Maintainer: please mirror the issue's triage labels on this PR: priority/p2, type/chore, triaged, severity/low, urgency/this-quarter, impact/few, effort/m.

🤖 Generated with Claude Code

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

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

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

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

This branch has not been deployed

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

Labels

effort/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)

1 participant