Skip to content

fix(purchase): AZ-scoped EC2 recs: the purchase guard, idempotency key and recommendations natural key ignore the AZ #792

Closed

Description

@cristim

Problem

Once go#305 lands in the platform (zonal EC2 RIs keep their AZ), three platform paths still ignore the AZ. Found by the Opus review of go#305 (F-notes). Line numbers are on origin/main 7e2a3d8.

  1. Purchase detail guard. internal/api/purchase_pricing.go:320 computeDetailMismatch compares tenancy, platform, scope and memory_gb, but not the AZ. A re-matched rec whose AZ differs from the original passes the guard.
    Fix: add an availability_zone mismatch check with the same asymmetry as the other fields. When the original knows the AZ, a match whose AZ is empty or different is a mismatch.
  2. Purchase idempotency key. internal/api/handler_purchases.go:2901 purchaseIdempotencyKey hashes provider, service, region, resource type, engine, account, count, term and payment, but not the AZ. Two submits that differ only by AZ dedupe into one within the 2-minute window.
    Fix: include the AZ in the tuple.
  3. Recommendations natural key. internal/config/store_postgres_recommendations.go:227 ON CONFLICT (account_key, provider, service, region, resource_type, engine, term, payment_option) has no AZ, so two AZ recs for the same type and term collapse into one row. This does not buy in the wrong AZ, but it loses a rec.
    Fix: add the AZ to the key, which needs a migration and an index change.
  4. AZ names are per account (F4). us-east-1a in one account can be a different physical zone in another. When the buying account differs from the account the rec was computed for, use AvailabilityZoneId, not the name.

Notes

Mostly money-adjacent. Item 1 is the purchase-time check. Item 2 can drop a legitimate second buy as a duplicate. Item 4 can buy in the wrong physical zone. This blocks the go provider bump (filed alongside).

Related: go#289, go#310.

Activity

  1. cristim commented on Oct 12, 2026

    @cristim
    MemberAuthor

    PR1 (#807: AZ-scoped EC2 recs in the purchase guard, idempotency key, recIdentityKey, rec ID, payer-account drop) merged as 3fde4ef after an Opus gate at f4b05f4. Leaving this issue open: PR2a, PR3, PR2b and PR2c remain.

  2. cristim commented on Oct 12, 2026

    @cristim
    MemberAuthor

    PR3 (frontend: availability_zone in purchase variant identity and cell key) landed via #809 as b197104. Issue stays open for the remaining PRs (PR2b/PR2c).

  3. cristim commented on Oct 12, 2026

    @cristim
    MemberAuthor

    PR2a landed via #808 (merge 631fb9c): migration 000109 adds the generated recommendations.availability_zone column and the 9-column unique index, and keeps the 8-column index. Dev applies it on deploy because auto-migrate runs at startup. Prod still needs the runbook step: dispatch database-migration.yml manually to apply 000109 before PR2b is released. Issue stays open for PR3, PR2b and PR2c.

  4. cristim commented on Oct 12, 2026

    @cristim
    MemberAuthor

    PR2b landed on main via #810 (merge 26470c3): the recommendations upsert now targets the 9-column recommendations_natural_key_az_idx. Dev already has migration 000109 (auto_migrate). PR2c (drop the 8-column index, plus the multi-AZ batch test) is still pending; until it lands, an AZ shift on one 8-tuple fails the dev collection write (documented limitation). Prod must follow the runbook: dispatch database-migration.yml for 000109 first, release PR2b together with PR2c, then dispatch the PR2c migration immediately after PR2b is live. Never run PR2c before PR2b. Leaving this issue open.

  5. cristim commented on Oct 12, 2026

    @cristim
    MemberAuthor

    All four items have landed on main:

    1. Purchase detail guard checks the AZ (fix(purchase): make zonal EC2 AZ part of the guard, identity key, idempotency key and rec ID #807).
    2. The purchase idempotency key includes the AZ, read from the rec Details (fix(purchase): make zonal EC2 AZ part of the guard, identity key, idempotency key and rec ID #807).
    3. The recommendations natural key includes the AZ:
    4. Per-account AZ names: zonal EC2 recs computed for a member account other than the collecting one are now dropped with a warning instead of being purchasable (fix(purchase): make zonal EC2 AZ part of the guard, identity key, idempotency key and rec ID #807).

    Prod release must follow the runbook in #811:

    1. Dispatch the 000109 migration first.
    2. Release fix(config): upsert recommendations on the AZ-aware 9-column index #810 and fix(db): drop the 8-column recommendations unique index so zonal EC2 AZ rows coexist (#792) #811 together.
    3. Dispatch 000110 only after PR2b is live in prod, never before. The old 8-column ON CONFLICT needs that index, so applying 000110 early breaks every collection.

    Dev ran #810 (26470c3) on AWS, GCP and Azure before #811 merged and auto-migrated.

    Follow-ups:

  6. cristim commented on Oct 13, 2026

    @cristim
    MemberAuthor

    Prod release runbook for #792 and the #806 chain (owner item 65). Drafted by an agent from origin/main on 2026-10-14; every prod step is run by a human owner and nothing is dispatched by this comment. Verified items were checked against the workflow file and the migration files on main; UNVERIFIED items are marked in the text.

    Prod release runbook: #792 (AZ-scoped EC2 recs) and #806 (platform/tenancy)

    This runbook dispatches nothing. Every prod step below is run by a human owner (dispatch, approval, SQL, force). Facts were read from origin/main and the PRs on 2026-10-14. Anything not verified is marked UNVERIFIED. Migration paths are under internal/database/postgres/migrations/ (the workflow's MIGRATIONS_PATH).

    1. database-migration.yml inputs (read from origin/main)

    Input Values Semantics
    cloud aws, gcp, azure, all One job per cloud, each bound to GitHub environment <cloud>-db-<env>. all runs the three concurrently. Enter only the clouds that run prod (UNVERIFIED which).
    environment dev, staging, prod Selects the *-db-<env> environment. Per .github/workflows/README.md prod DB environments require approvals and are restricted to main; the actual GitHub settings are UNVERIFIED (check Settings > Environments).
    direction up (default), down up: migrate up / migrate up N. down: migrate down N.
    steps integer, default 0 up: 0 = migrate up (ALL pending files); N>0 = migrate up N, i.e. apply the next N pending migration FILES in version order. It cannot target a version. down: must be >=1; migrate down N reverts the N HIGHEST applied versions, not a named one.
    confirm string, default empty Only for direction=down + prod: must be exactly rollback-prod, otherwise validate fails. Ignored otherwise.
    compute_platform lambda (default), fargate AWS only. Two independent RDS instances exist (one per platform). Migrate each platform that is deployed in prod; ignored for gcp/azure.
    • Dispatch with --ref main: the job checks out the dispatched ref and migrates from its migration files.
    • Concurrency group per cloud+env, cancel-in-progress: false. A queued run can be evicted by a later dispatch and the summary job stays green: read each cloud's result, then verify with SQL (below).
    • The workflow itself logs the version only for AWS ("Get migration version" step). Always verify with SQL for every cloud.
    • Prod apps do not auto-migrate: github-prod.tfvars sets auto_migrate = false for aws, gcp and azure. Prod schema changes happen only through this workflow.
    • Pick steps from the live prod version, never from memory. Pending files = every file with a version above prod's current version. main now holds 000109-000117, so a bare steps=0 would apply all of them.

    2. Current state on origin/main and PR map

    Migration PR Does Reversible Lock / rewrite
    000109 recommendations_availability_zone #808 merged Generated stored column availability_zone + unique index recommendations_natural_key_az_idx (9 cols). Keeps the 8-col index. Yes, down drops index + column (derived data, no loss). ADD COLUMN GENERATED STORED rewrites recommendations; index build blocks writes. ACCESS EXCLUSIVE for the duration. Table is a regenerable cache.
    000110 drop_recommendations_8col_natural_key #811 merged Drops recommendations_natural_key_idx. Yes, but down DELETEs rows that collide on the 8 cols (keeps newest collected_at) then recreates the index. Loses AZ-only sibling rows from the cache; next collection repopulates. Metadata only.
    000111 recommendations_platform_tenancy #815 merged Generated platform, tenancy columns + 11-col index recommendations_natural_key_az_tp_idx. Keeps the 9-col index. Yes, down drops index + columns (derived, no loss). Single table rewrite, same lock profile as 000109.
    000112 plan_step_cap_auto_purchase_switch merged (unrelated to #806) Adds purchase_plans.max_step_commitment, global_config.auto_purchase_enabled (default false). Yes, drops both columns (data loss of any values set). Metadata only.
    000113-000117 merged, unrelated Other chains. Read each header before a steps=0. See files. See files.

    Code PRs:

    BLOCKER for #806, found on main: #821 names its migration 000112_drop_recommendations_9col_natural_key, but 000112_plan_step_cap_auto_purchase_switch already exists on main and 000113-000117 follow. Two files with version 112 make golang-migrate refuse to load the source; and any DB already past 112 would silently skip a lower-numbered file. #821 must be renumbered above the highest number on main AT MERGE TIME. 000118 is claimed by open PR #861 (000118_purchase_plans_ramp_baseline), so the next free number is 000119 or later (final number UNVERIFIED, re-read git ls-tree origin/main before merge). Below, the #806 drop is called "DROP-9COL (000119+)". #819's and #821's bodies still say 112; their runbooks are stale on the number only.

    3. #792 order and per-step checks

    Order: (A) 000109 -> (B) code release containing #810 (and #807) -> (C) 000110. Never C before B: old code's ON CONFLICT on the 8 cols needs that index, so every collection write fails. 000110's header says the same.

    Step A: migrate 000109 (no code dependency; old and new code both work, nothing reads the column yet).

    • Pre: SELECT version, dirty FROM schema_migrations; must be clean (dirty = f). Count pending files up to and including 109 (e.g. prod at 105 means steps=4; prod at 108 means steps=1). A prod version above 109 means A already happened.
    • Dispatch: cloud=, environment=prod, direction=up, steps=, confirm empty, compute_platform=. Approve the *-db-prod gate.
    • Verify (read-only): SELECT version, dirty FROM schema_migrations; expect 109, f. SELECT column_name FROM information_schema.columns WHERE table_name='recommendations' AND column_name='availability_zone'; one row. SELECT indexname FROM pg_indexes WHERE tablename='recommendations' AND indexname IN ('recommendations_natural_key_idx','recommendations_natural_key_az_idx'); BOTH rows present.
    • Rollback: direction=down, steps=1, confirm=rollback-prod. Safe only while 110 is not applied (it undoes the highest applied version). No data loss (derived). Code: nothing to roll back.

    Step B: release main (or a tag) containing #810 and #807 to prod through the normal deploy workflow (human-run).

    • Verify: curl -s https://<prod-host>/version shows the expected git_sha; curl -s https://<prod-host>/health is healthy. Smoke: after the next collection cycle, the recommendations list loads and the collection logs show no upsert errors (UNVERIFIED which log or alarm to watch).
    • Rollback: redeploy the previous artifact. Safe while the 8-col index exists (old code arbiter is intact); after step C it is NOT safe, see C.

    Step C: migrate 000110 (only after B is confirmed live on every prod instance, including the fargate platform if used).

    • Pre: /version sha confirmed; SELECT version, dirty ... shows 109, f (then steps=1; if other pending files sit below 110, count them).
    • Verify: SELECT version, dirty ... expect 110, f. SELECT indexname FROM pg_indexes WHERE tablename='recommendations'; shows recommendations_natural_key_az_idx, NOT recommendations_natural_key_idx. Smoke: trigger or wait for a collection; zonal EC2 recs for one instance type in two AZs both appear.
    • Rollback: if collection fails after C, check the index list first. To revert: down, steps=1, confirm=rollback-prod recreates the 8-col index after deleting AZ-only duplicate cache rows (repopulated next cycle). Per 000110's down header this pairs with the pre-fix(config): upsert recommendations on the AZ-aware 9-column index #810 code; the 9-col arbiter in fix(config): upsert recommendations on the AZ-aware 9-column index #810 also works with both indexes present, so reverting only the migration is safe for current code.

    4. #806 order and per-step checks

    Order: (D) 000111 -> (E) code release containing #819 (+#817, #818) -> (F) DROP-9COL (000119+, #821 renumbered). Prerequisites: #819 and #821 merged, #821 renumbered, #819 deployed to dev first (per #821 body).

    Step D: migrate 000111. Same pre-checks as A. Pending count: if prod is at 110, steps=1; the later files 112-117 stay pending on purpose unless their code ships in the same release (read their headers; whether HEAD code needs 112-117 columns is UNVERIFIED, apply them separately and earlier if so).

    • Verify: 110->111, f; SELECT column_name FROM information_schema.columns WHERE table_name='recommendations' AND column_name IN ('platform','tenancy'); two rows; both recommendations_natural_key_az_idx and recommendations_natural_key_az_tp_idx present.
    • Rollback: down, steps=1, confirm=rollback-prod ONLY if 111 is the highest applied version; otherwise it reverts a different migration (e.g. 117). No data loss (derived columns).

    Step E: release code with #819. Verify /version sha and /health. Transitional defect (documented in #819): at schema 111 with both indexes, a Linux then Windows rec for the same 9-tuple raises 23505 and the recommendation cache stops refreshing until F. Keep E to F as short as possible; do F right after E is confirmed.

    • Rollback: redeploy previous artifact (safe while the 9-col index exists).

    Step F: DROP-9COL. Pre: E live everywhere; 111, f (or the highest unrelated applied version, with exactly one pending DROP-9COL file; verify with ls of the migrations dir at the dispatched ref).

    5. Dirty state and recovery (human only)

    • A failed or cancelled run leaves schema_migrations at version=N, dirty=t. The app starts anyway (fail-open) and reports via /health and the migration alarm. Auto-heal does NOT clear it silently (migrate.go, maybeAutoHealDirty).
    • Inspect the real schema first. If N's DDL landed, force to N; if it rolled back (every migration here is transactional), force to N-1 and re-run up. Mechanisms: env CUDLY_FORCE_MIGRATION_VERSION=<n> on the app (runs Force(n) before Up()), or migrate -path <migrations> -database <url> force <n>. A wrong value permanently desyncs the version record from the schema (the 000074 class). A human owner decides the value and runs it; no agent runs force.
    • Prod app has auto_migrate=false, so the env var path only works if someone enables it; the manual force is the realistic route (UNVERIFIED which the team prefers).

    6. Hazards

    1. golang-migrate up applies only versions ABOVE the current one. A file numbered below the DB's version is skipped silently with no error. Never merge a migration numbered below main's highest, and re-check before dispatch.
    2. Duplicate version numbers fail source parsing: fix(db): drop the 9-column recommendations natural key index (#806) #821's 000112 vs the existing 000112 (see section 2). feat(config): ramp baseline column and plan committed ledger (631 C2) #861 holds 000118.
    3. History check on main: numbering is contiguous from 000086 to 000117 (gaps only at 61-62, 68-69, 82, 84-85, historical). No collisions on main today. 000116 has no _test.go (cosmetic).
    4. DEV auto-migrates on merge (DB_AUTO_MIGRATE=true in github-dev.tfvars, as in staging). Dev and staging are AHEAD of prod. A green dev proves nothing about prod's pending count. Dev auto-migrate failures are non-fatal, so read dev's schema_migrations too.
    5. steps counts files, down N hits the highest versions: a steps that is right today is wrong once other migrations merge. Re-derive it at dispatch time from prod's live version.
    6. Multi-instance rollouts: old and new code overlap during deploy; C and F must wait until no old instance remains.
    7. A wrong environment or cloud combination migrates the wrong database. For AWS, compute_platform selects a different RDS.

    7. Pre-flight checklist (owner)

    • Prod SELECT version, dirty FROM schema_migrations; recorded for every prod DB (each cloud, each AWS platform). Clean and as expected.
    • Fresh snapshot or PITR point on each prod DB; restore path known (UNVERIFIED: team snapshot procedure).
    • Maintenance window agreed for A and D (table rewrite blocks writes to recommendations for the duration; size of prod table UNVERIFIED, check pg_total_relation_size('recommendations')).
    • *-db-prod environment reviewers identified and available to approve; branch restricted to main.
    • Migration files at the dispatch ref match the intended numbers (fix(db): drop the 9-column recommendations natural key index (#806) #821 renumbered, no clash with feat(config): ramp baseline column and plan committed ledger (631 C2) #861).
    • Code PRs for the next step merged, deployed to dev, and dev version read.
    • Owner named per step; force and down only by that owner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions