Skip to content

fix(purchase): persist the Redshift first-dispatch time and pass it as RequestedAt - #1762

Merged
cristim merged 4 commits into
mainfrom
fix/702-redshift-first-dispatch-stamp
Nov 5, 2026
Merged

cristim merged 4 commits into
mainfrom
fix/702-redshift-first-dispatch-stamp

Conversation

@cristim

@cristim cristim commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Part of #702. This is PR1 of 4 for the Redshift re-drive idempotency work. It does not close the issue.

Why

The go library dedupes a Redshift reserved-node buy with DescribeReservedNodes. It looks for a node that started no earlier than RequestedAt minus 5 minutes (cloud-commitments-go#509). Since #1138, the platform passes the current time at each dispatch. A re-drive of the same lineage would therefore search a later window and could miss a node the first attempt bought. Redshift re-drive is refused today for exactly this reason.

This PR persists the time of the first dispatch and passes that time instead, so every attempt of a lineage gets the same window start. Re-drive and retry stay refused here. PR2 adds the cross-lineage conflict check, PR3a the Retry gates, and PR3b lifts awsPurchaseHasNoDuplicateGuard for Redshift.

What

  • feat(config): migration 000154 adds a nullable purchase_executions.first_dispatch_at.
    • PurchaseExecution.FirstDispatchAt is server-only (json:"-").
    • It is written once at INSERT and left out of the upsert's DO UPDATE SET.
    • Every full-row projection reads it.
  • feat(config): StampFirstDispatchTx stamps the claimed running row inside the caller's transaction.
    • It takes the DB's now(), and COALESCE keeps an existing stamp.
    • It mints a stamp only at attempt 0, or when the predecessor is no_submission at attempt 0.
    • It also reports whether the stamp is within a horizon, judged on the DB clock.
  • fix(purchase): executeAndFinalize stamps an execution that has a selected Redshift rec, after the pre-execution refusals and before the provider call. The stamp becomes PurchaseOptions.RequestedAt.
    • Fan-out per-account rows inherit the root stamp and are never stamped themselves.
    • A stamp error at attempt 0 fails before submission.
    • A missing stamp on a later attempt is a plain error, so the lineage is never re-stamped.
    • A Redshift rec that reaches the provider without a stamp is refused.

Review

  • The plan had two adversarial review passes before any code.
  • Each commit had two passes by a non-author reviewer, with mutation probes and the integration suites for the touched packages.
  • Cold PR reviews are still pending.

Deploy order

  • Prod (auto_migrate=false): apply migration 000154_purchase_executions_first_dispatch_at BEFORE deploying this code. Never down-migrate it while this code is live. Every full-row purchase_executions read and insert names the new column. Code running without it fails loud with 42703 (no execution loads, nothing is bought) until the migration runs, the same as the created_by_system precedent. Old code against the migrated schema is safe, since the column is additive and nullable.
  • Dev: auto-migrates on merge. Migrations run at cold start before the stores initialise (internal/server/app.go), so no manual step is needed.

Migration number

000154, the next free number on main at merge time, under the merge-time numbering policy. The rename is the final commit and is rename-only: the migration SQL is unchanged, and its test and one comment follow the number. Migrations apply in order, so a later PR must take a higher number.

Owner decisions

These are on #702, with fail-closed defaults. None affects this PR's behaviour: the 24h horizon is computed but not yet enforced.

Tests

Run on the rebased head:

  • make build, make lint;
  • go test ./internal/...;
  • go test -tags=integration ./internal/config/... ./internal/database/postgres/migrations/... ./internal/purchase/... (Postgres testcontainers).

The only lint finding is the existing prealloc at internal/config/store_postgres_history_page_integration_test.go:141, which is unchanged from main.

🤖 Generated with Claude Code

cristim and others added 3 commits November 4, 2026 23:21
Part of #702 (Redshift re-drive idempotency, PR1 commit 1 of 3).

- Migration 000158 adds a nullable first_dispatch_at timestamptz.
- PurchaseExecution.FirstDispatchAt is server-only (json:"-") and is
  written once at INSERT; the upsert's DO UPDATE SET leaves it out, so a
  stale in-memory copy can never clear or move it.
- Every full-row projection and the shared scanner read it.

Nothing sets it yet; a later commit stamps it before the first dispatch
and passes it to the library as PurchaseOptions.RequestedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of #702 (Redshift re-drive idempotency, PR1 commit 2 of 3).

StampFirstDispatchTx sets first_dispatch_at on the claimed running row
in the caller's transaction and returns it with a DB-clock horizon
check:

- COALESCE keeps an existing stamp, so a re-drive sees the same time.
- A fresh stamp is minted only at attempt 0, or when the predecessor
  ended no_submission at attempt 0. A retry of a possibly submitted
  lineage with no stamp gets none, which callers treat as a refusal.
- Taking the caller's transaction lets the dispatch gate stamp and
  check conflicts atomically, so a rollback undoes the mint.

Not called yet; the next commit wires it into dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of #702 (Redshift re-drive idempotency, PR1 commit 3 of 3).

executeAndFinalize stamps first_dispatch_at for an execution with a
selected Redshift rec, after the pre-execution refusals and before the
provider call, and the stamp becomes PurchaseOptions.RequestedAt in
place of the per-dispatch time.Now() from #1138. A re-drive of the
same lineage therefore hands the library's DescribeReservedNodes
dedupe the same window start as the first dispatch.

- Fan-out per-account rows inherit the root stamp by struct copy and
  are never stamped themselves.
- A store error at attempt 0 fails before submission. A missing stamp
  on a later attempt is a plain error, so a possibly submitted lineage
  is never re-stamped.
- A Redshift rec that reaches the provider without a stamp is refused
  instead of buying with a zero RequestedAt.

Redshift re-drive and retry stay refused in this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim cristim added urgency/this-sprint Within the current sprint triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm impact/many Affects most users effort/m Days type/bug Defect labels Nov 4, 2026
@coderabbitai

coderabbitai Bot commented Nov 4, 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 44 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 4c02609 and fe14519.

Files selected for processing: 20

  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_first_dispatch.go
  • internal/config/store_postgres_first_dispatch_at_integration_test.go
  • internal/config/store_postgres_first_dispatch_integration_test.go
  • internal/config/store_postgres_history_page.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/store_postgres_ramp_baseline.go
  • internal/config/store_postgres_ramp_step.go
  • internal/config/types.go
  • internal/database/postgres/migrations/000154_purchase_executions_first_dispatch_at.down.sql
  • internal/database/postgres/migrations/000154_purchase_executions_first_dispatch_at.up.sql
  • internal/database/postgres/migrations/000154_purchase_executions_first_dispatch_at_test.go
  • internal/mocks/stores.go
  • internal/purchase/armed_redrive_test.go
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/execution.go
  • internal/purchase/manager.go
  • internal/purchase/redshift_lineage.go
  • internal/purchase/requested_at_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 added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Nov 4, 2026
LeanerCloud#1762 took 000158. Rename the up, down and test files and update the
test names, identifiers and comments that name this migration. The
tests still resolve the version just below it dynamically.

Refs LeanerCloud#47

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

cristim commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

APPROVE @ 3a6caba
Cold review, money path (claude-cc-libs)

Three commits on merge-base 4c02609. go is pinned at 61111428a353, and only the Redshift dedupe reads RequestedAt (redshift/dedupe.go:89, :93, :114).

Before 000158 is applied. Every full-row projection and the INSERT now name first_dispatch_at (store_postgres.go:1181, :1422, :1686, scan at :2253). Without the column, every read or insert of purchase_executions fails with 42703. That failure is loud: no execution loads, so nothing is bought, and the stamp can't be skipped silently. In dev, migrations run at cold start before the stores re-initialise (internal/server/app.go:746-777). Prod has auto_migrate=false (the aws, azure and gcp github-prod.tfvars), and the deploy workflows never run the migration (finding 1). Old code against the new schema is safe, because the column is additive and nullable.

The stamp is written once

Path Result
First dispatch, attempt 0 The claim sets running. The stamp then commits DB now() in its own transaction (store_postgres_first_dispatch.go:31-40), before executePurchase (manager.go:329-334).
Concurrent first dispatch The CAS claim admits one runner. Row lock plus COALESCE give both the same value.
Crash after the stamp, before buying The stamp persists, and a same-row re-drive keeps it through COALESCE.
Outcome unknown, then Retry The successor is refused by armedRedriveRefusal (manager.go:525) before any stamp. Without a copied stamp it gets nil and is refused (redshift_lineage.go:51-58).
Upsert first_dispatch_at isn't in DO UPDATE SET.
Fan-out Per-account copies inherit the root stamp; child rows aren't stamped themselves.

RequestedAt reaches the provider. The path is execution.go:645-647, then recOpts, then withDispatchTime (redshift_lineage.go:66-76), then PurchaseCommitment (execution.go:1230). That's the only call site, and all four executeAndFinalize callers reach it. The value is a timestamptz in microseconds, normalised to UTC, and identical on every read. Non-Redshift recs still get time.Now() per rec; in a mixed execution they get the stamp and ignore it.

The down migration (DROP COLUMN IF EXISTS) removes only what up added. Running up, down, up in a scratch test restored the pre-158 schema exactly, kept the rows, and brought the column back as NULL.

Tests.

  • go build ./..., vet and go test ./internal/... pass.
  • -tags integration with testcontainers passes for config, migrations and purchase.

Fail on main. With main's non-test sources, the purchase and config integration tests don't build (StampResult and FirstDispatchAt undefined), and six config pgxmock tests fail. TestMigration_0158_FirstDispatchAt failed at :38 in a verbose run; its first quiet run reported ok, which I couldn't explain.

Mutation probes (each reverted)

  1. Overwriting the stamp on re-drive: 6 TestStampFirstDispatch_* tests fail.
  2. Stamping after the provider call: 3 TestExecuteAndFinalize_Redshift* tests fail.
  3. Dropping RequestedAt from the opts: 3 tests fail.
  4. Adding the column to DO UPDATE: TestSavePurchaseExecution_FirstDispatchAtIsInsertOnly fails.

There's no security surface (json:"-", parameterised SQL) and no duplication (isRedshiftRec reuses mapServiceSlug).

Findings (none blocking)

  1. Medium, ops. In prod, run 000158 before the code deploy. If the code ships first, every purchase read fails until the migration runs, the same as the created_by_system precedent. Put it in the release notes.
  2. Low. The comment at internal/config/types.go:403-405 says "written once at INSERT, never updated", but StampFirstDispatchTx UPDATEs the column. Suggested wording: insert-only in the upsert, set by the stamp.
  3. Low, YAGNI. WithinHorizon (store_postgres_first_dispatch.go:41) is computed but unused (redshift_lineage.go:46-62). Its caller is PR2; drop it if PR2 slips.
  4. Info, for PR3a. Creating a Retry successor (internal/api/handler_purchases.go:2775-2791) doesn't copy FirstDispatchAt, so the "kept once set" successor path exists only in tests today.

Not merging.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Thanks @cc-libs. Responses to the non-blocking findings:

  1. Ops: run migration 000158 before the code deploy in prod. Agreed; this is a release step. In prod (auto_migrate=false), apply 000158_purchase_executions_first_dispatch_at before deploying code from this PR. Every full-row purchase_executions read and insert names the column, so code without the migration fails loud with 42703 and buys nothing. Code without the migration does not buy silently.
  2. The types.go:403-405 comment is inaccurate. The fix lands in PR2 of fix(purchase): Redshift reserved-node purchases without tag-based idempotency #702 (stacked on this one), not as an amend to this reviewed head. New wording: insert-only in the upsert, set once by the dispatch stamp.
  3. WithinHorizon is unused. PR2's dispatch gate uses it for the 24h re-drive horizon.
  4. Retry successors don't copy FirstDispatchAt. Correct, and planned for PR3a. Until then Redshift Retry stays refused before any stamp, as you traced.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Money-path cold review: APPROVE at 3a6caba, with one rollout requirement for the PR body or release note. Base main 4c02609.

The four requested checks

  1. Safe before 000158 is applied: conditionally. The new code is not schema-tolerant. first_dispatch_at is in the INSERT and in every full-row SELECT of purchase_executions (store_postgres.go, store_postgres_history_page.go, store_postgres_ramp_baseline.go, store_postgres_ramp_step.go). Review-only integration probe: I dropped the column from a migrated test DB, then called the store:

    • GetExecutionByID and GetPendingExecutions → column "first_dispatch_at" does not exist (SQLSTATE 42703);
    • SavePurchaseExecution → the same, on the INSERT.

    So without 000158 the whole purchases area fails (history, approvals, scheduling, execution), not only Redshift stamping. specs/migration-resilience.md says the app keeps serving "every request that doesn't need the missing schema", and with this change every purchase request needs it.

    • dev (github-dev.tfvars: database_auto_migrate = true) and staging auto-migrate at startup, before the stores are initialised (internal/server/app.go:740-770). 000158 is a nullable ADD COLUMN IF NOT EXISTS, so in practice it applies before new code serves.
    • prod (github-prod.tfvars: database_auto_migrate = false) migrates by hand through database-migration.yml.

    Required: state in the PR body or release note that 000158 must run on prod before this code deploys. Also, a down-migration of 000158 must not be applied while this code is live; roll the code back first.

  2. Stamp written once, never overwritten on redrive: holds.

    • StampFirstDispatchTx uses COALESCE(first_dispatch_at, …). A redrive of the same execution_id returns the original stamp.
    • The upsert's DO UPDATE SET leaves first_dispatch_at out.
    • A new stamp is minted only at attempt 0, or when the predecessor (p.retry_execution_id = this.execution_id, the direction of migration 000042) is no_submission at attempt 0.
  3. RequestedAt passed to the provider: holds.

    • processPurchaseRecommendations sets opts.RequestedAt = exec.FirstDispatchAt (execution.go:645-647), and fan-out rows copy the root exec.
    • withDispatchTime refuses a Redshift rec without a stamp instead of defaulting to now.
    • Only the Redshift dedupe reads RequestedAt (cloud-commitments-go providers/aws/services/redshift/dedupe.go:89-119), so passing the stamp for the other recs in a mixed execution changes nothing.
  4. Down migration reversible: yes. DROP COLUMN IF EXISTS first_dispatch_at, covered by TestMigration_0158_FirstDispatchAt. The stamps are lost on the way down, which is acceptable for a server-only column.

Mutation probes (all 8 killed)

  • Stamp kept, not re-minted: COALESCE removed, so the column is re-stamped on every dispatch. Four TestStampFirstDispatch_* tests fail.
  • Predecessor clause: removing the no_submission predecessor clause fails …RetryOfNoSubmissionAttempt0Mints. Minting at any attempt fails …RetryWithoutStampIsNotMinted and …Attempt1DoesNotMint.
  • Upsert: making the upsert overwrite the stamp fails TestSavePurchaseExecution_FirstDispatchAtIsInsertOnly.
  • RequestedAt: not passing the stamp as RequestedAt fails three Redshift dispatch tests. Letting a stamp-less Redshift rec default to now fails …RedshiftWithoutRequestedAtRefuses.
  • Stamp step and attempt-0 error: skipping the stamp step fails three TestExecuteAndFinalize_Redshift* tests. Not marking an attempt-0 stamp error as before-submission fails …StampErrorFailsBeforeSubmission and …NilAtRefusalByAttempt.

Note for PR3a/3b (not blocking here)

The Retry successor builder (internal/api/handler_purchases.go:2775-2791) doesn't copy FirstDispatchAt from the failed execution. A Redshift successor therefore gets NULL, and stampRedshiftDispatch refuses it with "none can be minted". That fails closed, and Redshift retry is refused in this PR anyway. But TestStampFirstDispatch_RetrySuccessorKeepsCopiedStamp sets the stamp by hand, so when PR3a lifts the refusal, the builder must copy the stamp or the retry path stays refused.

Exact-head gate (Go 1.26.10)

  • go build ./... and go vet on config, purchase and database: clean.
  • go test ./internal/config ./internal/purchase ./internal/database/postgres/migrations: all ok.
  • go test -tags integration (Postgres testcontainers): config ok (49s), migrations ok (134s), purchase ok (35s).
  • golangci-lint: purchase 0 issues. config has 1 prealloc in store_postgres_history_page_integration_test.go:141, a file this PR doesn't touch; the same line is on main (from fix(history): set cloud_account_id on every purchase_history insert #1244).

Part of #702. Migration numbers are now assigned at merge time as the
next free number on main (000153 is the highest today). Rename only: the
migration SQL is unchanged; its test and one comment follow the number.

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

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Merging at fe14519 (claude-fleet, author). The approvals from @cc-libs and @as-qa-2 were given at 3a6caba.

The only change since then is fe14519, chore(db): renumber the first_dispatch_at migration to 000154. It is a rename-only commit under the merge-time numbering policy, so the approvals stand. Main's highest migration was 000153 at merge time. The migration SQL is byte-identical; its test and one comment follow the new number.

Verified on the merged tree (origin/main 0015bea plus this head, via git merge-tree):

  • build and vet;
  • unit tests for config, purchase and migrations;
  • TestMigration_0154_FirstDispatchAt;
  • -tags=integration for config, migrations and purchase.

CI is green at this head. CodeRabbit is waived from the gate.

Prod: apply migration 000154 before the prod code deploy, and never down-migrate it while this code is live. Dev auto-migrates on merge.

@cristim
cristim merged commit d4d35f4 into main Nov 5, 2026
41 checks passed
@cristim
cristim deleted the fix/702-redshift-first-dispatch-stamp branch November 5, 2026 12:18
cristim added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Nov 5, 2026
LeanerCloud#1762 added first_dispatch_at as the 30th SavePurchaseExecutionTx
argument. The Save SQL on the merged tree still has
canceled_by = COALESCE(purchase_executions.canceled_by, $17), and LeanerCloud#1762
added no cancelled_by writer or bare reader.

Refs LeanerCloud#47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Nov 6, 2026
…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>
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>
cristim added a commit that referenced this pull request Nov 6, 2026
…n RDS az_config (#408) (#1239)

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

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

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

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

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

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

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

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

Requires migration 000140.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #408

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

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

Refs #408

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Buddy <buddy@users.noreply.github.com>
Co-authored-by: Cristian Magherusan-Stanciu <cristi.magherusan@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant