Repository navigation
fix(db): contract the canceled rename: normalize, narrow the status CHECKs, write only canceled_by - #1253
Conversation
…HECKs, stop using cancelled_by Migration 000144 is the contract half of 000089. Under a table lock it normalizes every legacy 'cancelled' status in purchase_executions and ri_exchange_history, drains legacy-only actors into canceled_by, and narrows both status CHECKs to the 000089 lists minus 'cancelled'. The code stops reading and writing purchase_executions.cancelled_by: reads project canceled_by, the cancel transition and SetCancelledBy write only canceled_by, and SavePurchaseExecution writes canceled_by, keeping an actor already on the row (the value reads saw before). The LegacyStatusCanceled constant and every dual-spelling branch are gone. The cancelled_by column is kept. The previous build still reads COALESCE(canceled_by, cancelled_by) and writes cancelled_by, and it keeps serving during a rolling deploy and after a code-only rollback, so dropping the column in the same release would break every execution read on it. A trigger mirrors any legacy-only actor write into canceled_by until a follow-up migration drops the trigger and column. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 12 minutes. View limit details
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. |
Bring the branch up to date with main. Conflicted files: - internal/api/handler_history.go: main (LeanerCloud#1200) moved the per-row summary bucketing out of summarizePurchaseHistory into addHistoryRowToSummary and addNonCompletedRowToSummary so the SQL aggregate and the row list share one set of rules. The branch had dropped config.LegacyStatusCanceled from the canceled case in the old inline switch. Kept main's refactor and applied the branch's change to addNonCompletedRowToSummary: only config.StatusCanceled is matched, since 000144 makes the legacy spelling unstorable and the branch removes the constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry test The test came in from main (LeanerCloud#1200) and seeds executions with LegacyStatusCanceled and queries it as an execution status. This branch removes that constant, and migration 000144 rejects the 'cancelled' status, so the seed would fail the narrowed CHECK. Seed and query only the canonical spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged origin/main (1c5787a) into this branch. New head: fb1d654. A trial merge with the current main (c1f1781) is clean and passes Conflict resolution:
Follow-up commit (a semantic conflict that merged without a textual conflict):
Checks on the merged head:
One thing to watch when merging: #1277's new |
…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>
…000140 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140, 000141, 000143 and 000144 belong to PRs held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253); this PR takes 000140 so it can merge ahead of them, and they will take the next free numbers when decided. Rename only: the SQL is unchanged, and the test's version constant and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…7 -> 000143 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-> 000145 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…148 -> 000144 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Migration renumbering needed before this merges. The reviewed, ready PRs have taken 000140-000145 so they could merge without waiting on owner decisions: #1240 (000140), #1302 (000141), #1268 (000142), #1287 (000143), #1289 (000144), #1116 (000145). This PR's migration(s) (000144) must be renumbered to start at 000146, or the next free number at that time, when it's ready to merge (000144 -> 000146 or the next free number). golang-migrate skips a version below the database's current one, so a lower number merged after those would never apply. Dependency check: none of the six ready migrations touches |
…n 000146 -> 000142 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of the full list (#1240) * fix(api): add opt-in keyset pagination to GET /api/recommendations The list endpoint returned the whole filtered recommendations cache in a single body, so a tenant with a few thousand cached rows pushed the response past the 6 MB Lambda payload cap and the page failed with 502. Gzip (#775) raised that ceiling but did not bound it. Callers can now send limit (1-1000) and the returned next_cursor to read the list in pages ordered by recommendation ID. The cursor is an opaque base64url encoding of the last ID seen, so a row deleted between requests does not shift later pages. summary and regions still describe the full filtered set on every page, and compute capacity is only decoded for the returned rows. Requests without limit or cursor keep the existing unpaginated response, so the current frontend is unaffected until it adopts the parameters. openapi.yaml documents the parameters and the actual response shape. Refs #777 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): cap recommendation pages by serialized bytes and reject empty paging params A page was bounded only by row count, but the opaque Details blob makes a row's size data-dependent, so 1000 large rows could still pass the 6 MiB Lambda response cap. Pages now also stop once the rows, measured as they appear inside the Lambda response envelope (the body re-encoded as a JSON string, so quotes and backslashes count twice), plus the summary, regions, keys and next_cursor would pass a 4 MiB budget. The budget is checked against uncompressed bytes because the #775 gzip only runs when the client accepts it. A page always holds at least one row, so a row that alone is over the budget is returned on its own page with a logged warning instead of stalling the cursor walk. Legacy unpaginated mode is now selected only when neither limit nor cursor is present. A present but empty ?limit= or ?cursor= returns 400 instead of falling back to the unbounded response. Refs #723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): reject duplicate recommendation IDs when paging and document 400s - Correct the ordering comment: the paging key is the application recommendation ID built from the natural-key columns, not the cache table's UUID primary key. A paginated request now fails if two rows share an ID, since the strict "after" comparison would skip one of them at a page boundary. - Describe the feature as opt-in in-memory cursor slicing, not a SQL keyset (store-side paging is #981). - TestGetRecommendations_UnpaginatedShapeUnchanged copies the expected IDs before the call so an in-place reorder in legacy mode fails it. - Add TestGetRecommendations_PaginationRejectsDuplicateIDs. - openapi: document the 400 response for bad limit/cursor/filter values, and say next_cursor's absence is the only end-of-list signal. Refs #723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(db): index recommendations by byte-wise payload id for keyset scans Migration 000142 adds recommendations_scan_key_idx on (COALESCE(payload->>'id', '') COLLATE "C", id). The paginated GET /api/recommendations scan (issue #981) walks the cache in chunks in that order and resumes each chunk after the last (id, primary key) it read; without the index every chunk is a full scan plus a sort. COLLATE "C" is byte order, the same order Go's string comparison uses, so SQL paging keeps the order the API already returns. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(config): read stored recommendations in keyset chunks ListStoredRecommendationsChunk returns at most limit rows matching a RecommendationFilter, ordered by the byte-wise recommendation ID and then the table's UUID primary key, resuming strictly after a given position. The primary key keeps rows that share an ID in a total order, so a chunk boundary inside such a run neither skips nor repeats a row. The MinSavingsPct floor stays in-process (shared with ListStoredRecommendations via decodeStoredRecommendation); rows it drops still advance the position. Integration tests pin the order to Go's sort.Strings on IDs that a linguistic collation orders differently, cover duplicate IDs across every chunk size, the pct floor emptying a whole chunk, the empty table, an EXPLAIN check that recommendations_scan_key_idx serves the query without a Sort node, and walks racing concurrent upserts. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scheduler): scan cached recommendations chunk by chunk ScanRecommendations walks the recommendations cache through ListStoredRecommendationsChunk in byte-wise ID order and applies the same suppressions and account overrides as ListRecommendations to each chunk, handing the survivors to a callback. It holds one chunk at a time instead of the whole filtered set. Suppressions are read once per scan and each (account, provider, service) override is resolved once per scan, not once per chunk. Read failures over-show exactly like ListRecommendations. The freshness read, cold-start kick and stale-while-revalidate kick are pulled out of ListRecommendations into startCacheRead/finishCacheRead so both reads share them unchanged. Tests pin the scan to ListRecommendations' output (sorted by ID) for chunk sizes 1 to 31 with suppressions and overrides active. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(api): page GET /api/recommendations from the chunked scan A paginated request (limit or cursor sent) no longer loads the whole filtered set through ListRecommendations and sorts it in memory. It runs Scheduler.ScanRecommendations, applies the enabled-providers and account scope filters to each chunk (their inputs read once per request), and feeds the survivors to recommendationsPageCollector. The collector totals every row into the summary and keeps only the rows after the cursor up to limit+1 (the page plus one row proving a next page exists), then cuts the page with the existing byte budget. Peak memory is one scan chunk plus one page instead of the whole tenant. Output is unchanged: rows arrive in byte-wise ID order (Go string order), duplicate IDs still fail the request, and the summary still covers the full filtered set. The unpaginated path is untouched. The in-memory sortRecommendationsAfter/buildBudgetedPage are removed; the existing #723 pagination tests now run against the scan path, with buildBudgetedPage kept as a test helper over the collector. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): pin paginated recommendations order, ties and last page - PaginatedRequestDoesNotLoadFullList: the #981 regression; a paged request must use ScanRecommendations, never ListRecommendations. - PagedWalkMatchesLegacyOrder: for an unrestricted and a scoped reader, every chunk size and page size (including the exact-fit last page), the walk returns the legacy rows sorted by Go string order in consecutive pages, next_cursor on all but the last page, and the legacy summary on every page. IDs mix case, "|", "-", "/", spaces and accents. - PagedDuplicateIDs: ties fail every paged request at any chunk/page boundary; a twin removed by the scope filter is not a tie. - Background-collection scope (#627), the one-page memory bound, the out-of-order guard and the empty cache. - An integration test walks a real Postgres cache spanning several scan chunks with the min_savings_pct floor and matches the legacy output. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(scheduler): scan with main's variant-aware suppression index Merging main brought #1110 (issue #816), which changed aggregateSuppressions to return the raw rows grouped by 6-tuple (map[suppressionKey][]config.PurchaseSuppression) and moved the per-rec aggregate into applySuppressionIndex, because which rows apply now depends on the rec's variant (AZ, platform, tenancy). ScanRecommendations still held the old map[suppressionKey]*suppressionAgg, so the merged tree did not build. loadSuppressionIndex now returns main's grouped index and the scan passes it to the same applySuppressionIndex as ListRecommendations. Matching is per rec, so applying it chunk by chunk suppresses exactly what the non-scan path does. A new test pins the scan to ListRecommendations at every chunk size on an EC2 fixture with a variant purchase, a fully covered variant, a legacy wildcard row and a rec with unreadable Details, and checks the expected per-variant counts. Stripping variants from the scan's index fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber recommendations_scan_key_idx migration 000142 -> 000140 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140, 000141, 000143 and 000144 belong to PRs held on owner decisions (#1239, #1241, #1253); this PR takes 000140 so it can merge ahead of them, and they will take the next free numbers when decided. Rename only: the SQL is unchanged, and the test's version constant and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cristian Magherusan-Stanciu <cristim@users.noreply.github.com>
…vice (#1302) * fix(purchase,server): skip the $0 purchase confirmation and report a failed lazy DB init on /health An execution with nothing selected never reaches a cloud API, but both the single-account and the per-account fan-out paths still sent a "purchase confirmed" email listing zero commitments. Gate the confirmation on anyRecPurchased / committed, so it goes out only when a real commitment was created (full or partial success). When reinitializeAfterConnect fails after setting app.Config, ensureDB closes the pool and clears app.DB but leaves app.Config, and the config_store health check reported healthy over the closed pool. Snapshot dbConnected with the store pointers under dbMu and report unhealthy while lazy PostgreSQL init has not completed. Audit findings A05-015 and A06-009. A06-019 (commitment_type labels) follows separately. Refs #339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(analytics): label snapshot commitment types from provider and service commitmentTypeFor matched only a service literally named "SavingsPlans", which purchase_history never stores, so every commitment was stored as "RI": AWS Savings Plans ("savingsplans" / "savings-plans-<type>") and GCP committed-use discounts included. Derive the type from (provider, service) in the pkg/common CommitmentType vocabulary, and widen the savings_snapshots CHECK in migration 000145 to match. The CHECK still admits the legacy 'RI' / 'SavingsPlan' literals so an old replica can write during a rolling deploy; existing rows are relabeled. Audit finding A06-019. Refs #339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(purchase): make the #339 no-confirmation assertions checkable by the mock guard TestNoUnfailableMockAssertions could not resolve the receiver of two AssertNotCalled sites (a mock returned by a helper). Declare the mock directly in the new test, and in the approve test rely on the missing SendPurchaseConfirmation expectation, which fails the test on any send. Refs #339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(analytics): restore the pkg/common import after #1254 #1254 (on main) replaced the hand-rolled config-store mock in collector_test.go and dropped the now-unused pkg/common import. This branch's new commitment-type assertion uses common.CommitmentType, so the analytics test build broke after main was merged in although the merge was textually clean. Same assertion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber savings_snapshots_commitment_type_vocabulary migration 000145 -> 000141 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ail-closed plan providers, revocation_in_flight clear (#1268) * fix(config/db): audit findings A04-010/011/012/013 in migrations and config store - 000059 down: only remove ...0005 when it really is Purchaser. 000057 gave that UUID to Standard Users, so the old down deleted Standard Users and stripped it from every member. Refuse with a clear error rather than leave a Purchaser-only user with no group. 000096 down gets the same up-front refusal instead of a bare users_min_one_group violation. - 000027: dedupe savings_snapshots on (tableoid, ctid). ctid is per partition, so matching on it alone deleted unrelated rows of other partitions on a forced re-run. - DerivePlanProviders: accept the "provider:service" keys 000040 wrote, and return an error instead of an empty provider set when no key names a provider, so both plan-account guards fail closed. - MarkPurchaseRevoked clears revocation_in_flight, as 000072 documents; migration 000146 clears it on rows already revoked. Real-Postgres tests for each, using the shared container. Normalizing the plan-services key delimiter is deferred (see the PR). Refs #316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): identify legacy-keyed plan services by their provider field The first #316 commit made DerivePlanProviders fail when no service key had a provider prefix. That refused supported plans: a legacy alias kept under its original key (#1123's singleton-alias edit path, exercised by TestLegacyServiceB1S1*) has a key like "original-key", and so do fixtures keyed "ec2" with Provider "aws". Each entry's provider now comes from its key prefix ("provider/service" or "provider:service"), falling back to the entry's own Provider field, which is what mergeRequestedServices and plan step resolution use. The check fails closed only when no entry identifies a provider either way. Refs #316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(migrations): drop the duplicate TestMain from helpers_test.go main's #1400 added main_integration_test.go with a TestMain that stops the shared PostgreSQL container after the run. This branch added an identical TestMain to helpers_test.go, so after merging main the integration build of the package declared TestMain twice and failed to compile. Keep main's TestMain, which does the same teardown, and restore helpers_test.go to main's version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber purchase_history_clear_revoked_in_flight migration 000146 -> 000142 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Migrates to the version below 000158 and seeds what an older build wrote: the legacy status spelling and an actor only in cancelled_by. Covers GetExecutionByID, the History page and summary, CountLiveExecutionsForAccount, holdsCapacity, the ramp-step occupied check, SavePurchaseExecution on a legacy-actor row (the actor is carried into canceled_by and a stale save cannot erase it), CleanupOldExecutions, and the handler's cancel-race recovery (cancelOrRecoverExecution). The build and the migration roll out in either order, so both orders must read correctly. Against the original LeanerCloud#1253 strict reads the config test fails on the actor scan, the live count, holdsCapacity, the save and the cleanup, and the api test fails with a 409. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a fully migrated schema 000158 forbids the legacy status spelling, so integration fixtures that INSERT it cannot run. Revert the DB-backed fixtures to the PR's versions (history summary, ladder resolve, ramp baseline, ramp step, ladder reconcile, submit dedupe, the db retention test, the retention writers case), and have the account-delete tests iterate TerminalExecutionStatuses minus the legacy spelling. The unit tests keep their legacy fixtures, and the legacy rows are covered at the pre-000158 schema by the new integration tests. Also reword comments and mark the SQL constants that name the legacy column so the misspell linter passes, and drop a stray blank line. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…el actor ListPlanCommittedTx and ListExecutionsForPlanStepTx select canceled_by alone. Pre-existing and decision-neutral (both callers branch on status); the drain in LeanerCloud#1747 fixes the rows at the source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Takeover note (claude-cc-platform, on the owner's instruction): I've driven this PR to a mergeable state on your branch, as a fast-forward from
It will merge after #1239 and #1241. Before the two cold reviews, I'll merge main in once more so the migration tests run from |
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>
|
Renumbered the migration to |
cristim
left a comment
There was a problem hiding this comment.
APPROVE @ bc919da by claude-nibblets
Cold review of the #1253 takeover (ecb118e...bc919da; cc-platform's takeover of @IAmJSD's draft), done in my own scratch clone. This is a fork PR (head in IAmJSD's fork), so before fetching I pre-screened the whole diff (gh pr diff). It changes only Go store code, tests, the two migration files and a known-issues entry. It has no workflow, build-script or go.mod change, and the added lines contain no exec, network, credential or encoded-blob code. The PR body was treated as a claim. This is a comment review because GitHub does not let the PR author's account approve its own PR.
Deploy-safe before and after the migration (main auto-deploys dev).
- This code on a schema without 000159.
- It writes only
canceled_by, which exists since 000089, and status'canceled', which 000089's widened CHECK accepts. - Every execution projection reads
COALESCE(canceled_by, cancelled_by), 12 sites instore_postgres.go, andLegacyStatusCanceledstays in every status list and switch, so legacy rows still read correctly. TestPostgresStore_NewCodeAgainstPreContractSchemaruns exactly this case against Postgres.
- It writes only
- Main's code on a schema with 000159.
- Main never writes a
'cancelled'status. EveryLegacyStatusCanceleduse on main is a read: switch cases, status lists and comparisons. - The one branch that sees it (
handler_purchases.go:897) only normalizes the in-memory copy it returns. - No SQL literal on main writes
'cancelled'tostatus.ListingStateCancelledis the AWS listing enum on a different column. - Main's dual-write of
cancelled_bykeeps working, because the column stays until #1747. So older builds keep working against the narrowed CHECKs.
- Main never writes a
The normalize step loses no data.
- It changes only the status spelling:
UPDATE … SET status = 'canceled' WHERE status = 'cancelled'on both tables. The actor columns are untouched. updated_atis preserved, because both row triggers are disabled around the two UPDATEs inside golang-migrate's single transaction and re-enabled before the CHECKs change. Otherwise every legacy row would look freshly changed to plan-health scoring and to retention.- Both tables are locked
ACCESS EXCLUSIVEfirst, so no writer can commit a legacy row between the normalize and the narrowing. - The narrowed CHECKs equal main's current definitions (still 000089's, with no later redefinition) minus
'cancelled', andDROP … IF EXISTSalso clears the pre-000089valid_statusname.
The down migration is guarded. It drops each constraint with IF EXISTS and re-adds 000089's widened lists verbatim. A wider CHECK accepts every row, so the down migration can't fail. It deliberately leaves rows 'canceled', which is safe because every build since #1277 reads both spellings.
The actor write.
SavePurchaseExecutionwritescanceled_by = COALESCE(purchase_executions.canceled_by, $17). A save from a stale copy can't erase a recorded actor, and since Save on main never wrotecanceled_by, no existing clear-the-actor behaviour is lost.SetCancelledByand the session-cancel transition stop dual-writingcancelled_by.
Tests and probes. Each probe was run in my clone and reverted, with the new tests run against Postgres.
| Mutation | Result |
|---|---|
Normalize restamps purchase_executions.updated_at (trigger not disabled) |
killed (TestMigration_000159_ContractCanceledSpelling) |
ri_exchange_history trigger left disabled after the migration |
killed (same test) |
purchase_executions not normalized before narrowing |
killed (same test) |
Down keeps purchase_executions narrow |
killed (same test) |
Down keeps ri_exchange_history narrow |
killed (same test) |
A stale save overwrites the actor (canceled_by = $17) |
killed (TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy, TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor) |
SetCancelledBy dual-writes cancelled_by again (main's behaviour) |
killed (TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy) |
One projection reads canceled_by alone |
killed (TestPGXMock_ExecutionProjectionsReadCoalescedActor) |
That's 8 of 8. The reverts to main's write and read behaviour fail the new tests, and the migration test can't pass without the 000159 files.
The new tests on main. With the merge base ecb118e8's store_postgres.go and interfaces.go and no 000159 SQL files, under -tags integration, three fail:
TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy, because main still dual-writescancelled_by;TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor, because main's save writescancelled_by = $17and never fillscanceled_by;TestMigration_000159_ContractCanceledSpelling, because there is no migration to run.
The other two new tests pass there by design: TestPGXMock_ExecutionProjectionsReadCoalescedActor and TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration pin the tolerant reads and the legacy-row cancel path, which main already has. They guard against a regression, and the strict-read probe above shows the first one fails if a read loses the COALESCE.
Local verification at this SHA (macOS, Docker Postgres testcontainers, under the review lock):
go build ./...succeeds, andgo vetpasses both by default and with-tags integration;- unit tests pass for config, api and migrations;
go test -tags integration -count=1:./internal/config/...1733 passed,./internal/api/...6049 passed and./internal/database/postgres/migrations/...219 passed, with 0 failed (counts include subtests).
Numbering. Main's highest migration is 000154, so 000159 would apply. Under the merge-time policy it is renumbered to main's highest + 1 just before merging, with the migration and integration tests re-run. The test file names the version, so it moves with the rename.
Not blocking:
- The two ramp reads in
known_issues/40_cancel_actor_strict_reads.md(ListPlanCommittedTx,ListExecutionsForPlanStepTx) already miss an actor that exists only incancelled_byon main. After this PR, new cancels writecanceled_by, so they become visible to those reads. That's an improvement, and #1747's drain fixes the rest at the source. - Docker has testcontainers Postgres containers from Oct 16 to 21 left over from other sessions. They're unrelated to this PR.
CI at this SHA: 39 checks pass. Snyk was skipped, as is normal for a fork PR without secrets, and CodeRabbit is NEUTRAL.
Not merging.
Cold review verdict: APPROVE at bc919daReviewer: claude-qa-ui-4. I didn't write this change. I found no correctness or deploy-safety defects. The three items below are low-severity, but item 1 must be handled at the merge-time renumber. 1. LOW, required at the renumber: 159 is hardcoded where a file rename won't catch itTwo places compare the schema version with a literal:
The migration test also hardcodes it ( If the renumber misses the migration test, it fails loudly. If it misses either legacy test, the failure is silent. After a renumber to N, those tests would stop at 158, the pre-contract schema would be missing whatever sits between 159 and N-1, and nothing would fail. Fix: derive the version from the file matching 2. LOW: test gap for the COALESCE orderSwapping the save to Fix: add a stale-copy case where 3. NIT: the known_issues file
What was checked
|
Migration numbers are assigned at merge time: main's highest is now 000154 (first_dispatch_at). Rename the up, down and test files and update the test names, identifiers and comments naming this migration. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The config and api legacy-canceled tests hardcoded the migration number as the cutoff for "the schema just below the contract". Read it from the file ending _contract_canceled_spelling.up.sql instead and fail loudly if it is missing, so a renumber needs no test edit. Checked in a scratch copy with the migration renamed to 000170 and a 000169 present: derived 169, where the old hardcoded cutoff would have picked 154. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED bc919da by claude-as-qa-2 (second cold review)
The code at this head is correct, and I'd approve it as written. It fails only once merged onto current main, because of one semantic conflict in the PR's own new test (B1). I'll do the delta review after the rebase.
B1, blocking: the PR's new test fails on the merged tree. Merged onto main e050539 (clean textual merge), TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy fails at store_postgres_cancel_actor_pgxmock_test.go:142 with failed to save purchase execution: expected 29, but got 30 arguments. Main's d4d35f4 ("persist the Redshift first-dispatch time") added a 30th SavePurchaseExecutionTx argument. Fix: rebase onto main and update the pgxmock expectation's argument list. On the merged tree, the Save SQL still carries canceled_by = COALESCE(purchase_executions.canceled_by, $17), and every projection still reads COALESCE(canceled_by, cancelled_by). Main added no new cancelled_by writer or bare reader.
Merge requirement: renumber 000159_contract_canceled_spelling.{up,down}.sql and its test to main's highest migration + 1 at merge time. Main's highest is 000154 today (e050539), so that's 000155.
What I attacked
Migration (up).
- Lock. Both tables take ACCESS EXCLUSIVE before the normalize. golang-migrate wraps the file in one transaction, so no writer can commit a
'cancelled'row between the UPDATE and the narrowed CHECK. - CHECK lists. I checked the narrowed lists against every migration on main that defines these CHECKs: 000001, 000013, 000055, 000070 and 000089. None after 000089 changes them. The narrowed lists equal 000089's lists minus
'cancelled', for both tables. - Triggers. DISABLE/ENABLE TRIGGER is transactional, and the test asserts both triggers are enabled after up.
Down. It re-widens the CHECKs to the 000089 state byte for byte, rows stay'canceled', and the test shows up re-applies after down.
Writers on main.git grepover non-test Go on main: - no production writer of the legacy status remains on
purchase_executionsorri_exchange_history; LegacyStatusCanceledappears only in read and switch arms;mocks/stores.go:722,749returns"cancelled"from a mock;store_postgres_ladder_amend_ceiling.go:120filtersladder_runs, a different table with its own CHECK, which this PR leaves alone.
Save path.canceled_by = COALESCE(purchase_executions.canceled_by, $17):$17is*string, so unset binds NULL, not'', and the COALESCE can't be pinned by an empty string;- no production path clears
CancelledBy; - the change matches the pre-PR behaviour, where Save never touched
canceled_byand the atomic cancel paths owned it.
Mixed versions.requireOldBuildWritesStillWork159replays the old Save, SetCancelledBy and transition SQL against the contracted schema.
Evidence (exact head, go 1.26.10 from go.work, Docker testcontainers)
- Build and vet: clean.
- Unit and
-tags integration: all pass for./internal/config,./internal/api,./internal/database/...and./internal/purchase. - Migration test:
TestMigration_000159_ContractCanceledSpellingruns; it's not skipped (-vPASS, 3.17s). - golangci-lint: one
preallochint atstore_postgres_history_page_integration_test.go:141. That file is untouched by this PR, so the hint predates it and isn't blocking. - Merged onto main e050539: build and vet are clean. The migrations, purchase and api integration suites pass. In
internal/config, only B1 fails. Migrations apply to version 159 on the merged tree.
Mutation probes. Each was applied to a scratch copy, reverted, and confirmed killed by a named test:
| Probe | Killed by |
|---|---|
| M1 drop the purchase_executions normalize UPDATE | TestMigration_000159_ContractCanceledSpelling :118 |
| M2 drop the purchase_executions trigger disable | same test, :135 (updated_at) |
| M3 drop the ri_exchange_history trigger disable | same test, :137 (updated_at) |
M4 keep 'cancelled' in the purchase_executions CHECK |
same test, :148 and :153 |
M5 keep 'cancelled' in the ri_exchange_history CHECK |
same test, :150 and :155 |
M6 Save overwrites canceled_by = $17 |
TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy, TestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor |
M7 SetCancelledBy writes cancelled_by again |
TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy |
| M8 INSERT writes the legacy column | TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy :142 |
Non-blocking notes
- Rollout. The "every build since #1277 writes only
'canceled'" precondition is a deploy fact, not a code fact. Confirm that no pre-#1277 image can still be running or rolled back to before this migration runs in production, or that build's cancel will fail the narrowed CHECK. - Lock duration. The CHECK validation scans both tables under ACCESS EXCLUSIVE. That's fine at current sizes; if either table grows large, use
ADD CONSTRAINT ... NOT VALIDplusVALIDATE CONSTRAINT.
LeanerCloud#1762 added first_dispatch_at as the 30th SavePurchaseExecutionTx argument. The Save SQL on the merged tree still has canceled_by = COALESCE(purchase_executions.canceled_by, $17), and LeanerCloud#1762 added no cancelled_by writer or bare reader. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…actor A stale copy carrying a different non-nil actor B must not replace the actor A already in canceled_by. Swapping the save to COALESCE($17, purchase_executions.canceled_by) in a scratch copy now fails with expected concurrent@example.com, actual stale-copy@example.com. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the resolved count to the audit status of the cancel-actor entry. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nerCloud#1241 LeanerCloud#1241 took 000155 and 000156. Rename the up, down and test files and update the test names, identifiers and comments naming this migration. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fix round at
Gate: the full-suite build, vet and race tests, the integration suites for the touched packages, and lint (0 issues) all pass. Please do delta reviews at |
cristim
left a comment
There was a problem hiding this comment.
APPROVED 13befc8 by claude-as-qa-2 (delta review of my CHANGES REQUESTED at bc919da)
Both items from bc919da are resolved.
- B1, the PR's own pgxmock test failing on the merged tree: fixed by b070505, which updates the Save arity after #1762. The branch now carries main through two merges, and
TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBypasses on this head. - Renumber at merge: done. The migration is now
000157_contract_canceled_spelling. Main's highest migration is 000156, so it sits directly after it.
What else changed since bc919da
- Production code and SQL are unchanged.
git diffofstore_postgres.goandinterfaces.goagainst the merge base is identical line for line to the bc919da diff. The up and down SQL bodies are byte-identical apart from the header comments. - Test-only additions:
- Merge onto main:
git merge-tree --write-tree origin/main 13befc8ais clean.
Evidence at 13befc8 (go 1.26.10 from go.work; GOMAXPROCS=4, -p 2 throttle; Docker testcontainers)
go build ./...andgo vet(config, database) are clean.-tags integration -run 000157 ./internal/database/postgres/migrations: ok.-tags integration -run 'CancelActor|NewCodeAgainstPreContractSchema|LegacyCanceled|PGXMock_Execution' ./internal/config: ok.golangci-lint --new-from-rev=<merge base>(config, database): 0 issues.- Mutants re-checked against the renamed tests:
- M6 (Save overwrites
canceled_by = $17) is killed byTestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy(:142) and byTestPostgresStore_NewCodeAgainstPreContractSchema/save_keeps_a_legacy_actor_readable_and_never_erases_an_actor(:158). - M1 (drop the normalize UPDATE) is killed by
TestMigration_000157_ContractCanceledSpelling(:118). - The other six mutants from my bc919da review target SQL and Go that are unchanged here.
- M6 (Save overwrites
This was a scoped re-run under the memory throttle, not the full integration suites. The merge-gate verify covers those. The bc919da non-blocking notes still stand: the pre-#1277 rollout precondition, and NOT VALID plus VALIDATE if the tables grow.
cristim
left a comment
There was a problem hiding this comment.
APPROVED 13befc8 by claude-nibblets
Delta review of bc919da2...13befc8a. I approved bc919da2. This round covers as-qa-2's CHANGES REQUESTED at bc919da2 and the merge-time renumber. I understand the merge is held while the deploy is red; this verdict makes it gate-ready on the review side.
The delta
There are two merges of main (ce07166d at 8be62f97, f3636ca7 at a8c34d44 #1241) and six PR commits. git show --remerge-diff is empty for both merges, so they need no conflict resolution and have no hand edits.
- B1 is fixed by
b070505f.TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBynow expectsanyArgsCfg(30), matching the 30-argument Save after #1762. That was exactly the semantic conflict as-qa-2 found on the merged tree. - The renumber.
7bae18e5moved it to 000155, and13befc8ato 000157 after #1241 took 000155 and 000156. At this head the migration is000157_contract_canceled_spelling.{up,down}.sqlplus its test, the only 000157 set.main's highest is 000156, both at the merge base and at the current tipe86860cb, so it applies next, and no reference to the old 000159 or 000155 contract number remains. The 000155 hits ingit grepbelong to #1241's ownoutcome_settlementmigration. e53c962cderives the pre-contract cutoff in the mixed-version test from the_contract_canceled_spelling.up.sqlfile name, so future renumbers need no test edit.dd11af45adds a pin that a stale Save can't replace the stored cancel actor. It's a test only.845f5964fixes the known-issues audit-line format. It's docs only.
Verification
GOMAXPROCS=4 GOFLAGS=-p=2, Docker Postgres testcontainers, under the shared platform review lock.
| Tree | Result |
|---|---|
| This head | go build ./... clean; -tags integration ./internal/config/ with the cancel-actor, pre-contract and projection tests: 52 passed; the 000157 migration test passes |
Merged with current main e86860cb (8 commits ahead of this head, clean textual merge) |
go build ./... and go vet -tags integration for internal/config and internal/database/... clean; the same config tests, 52 passed; the whole migrations package, 223 passed. Migration numbers run 000155, 000156, 000157 with no collision |
So the B1 class of problem (a semantic conflict with newer main) doesn't recur against today's tip.
CI at this SHA: 39 checks pass. Snyk was skipped and CodeRabbit is NEUTRAL. The merge state is CLEAN.
Carried forward from as-qa-2 (not blocking, deploy facts)
- Rollout: confirm that no pre-#1277 image can still run, or be rolled back to, once this migration runs. That build would write the legacy status that the narrowed CHECK refuses.
- Lock duration: the CHECK validation scans both tables under ACCESS EXCLUSIVE. That's fine at current sizes; if they grow large, use
NOT VALIDplusVALIDATE CONSTRAINT.
Not merging.
|
APPROVED 13befc8 by claude-qa-ui-4 Delta review of bc919da..13befc8.
Runs (GOMAXPROCS=4, -p 2):
Migration order: #1239 also uses 000157, so whichever merges second renumbers. |
|
Merging at Local verification at
|
…e-408 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…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>
…ed_by alone Finishes the canceled rename (#47). Migration 000169 copies any legacy-only actor into canceled_by with the updated_at trigger disabled (the plan-health and retention clock is not restamped), then drops cancelled_by. The down re-adds the column and backfills it from canceled_by. Every execution SELECT now projects canceled_by instead of COALESCE(canceled_by, cancelled_by). Rollback risk, accepted by the owner on 2026-11-13: builds from before #1253 (ce42cf7) still write cancelled_by, so their cancel and save writes fail once the column is gone. The rollback runbook notes it. Tests: the projection pgxmock test now rejects the dropped column and fails against the COALESCE queries; an integration test on the current schema reads the actor through Get and the history page; the migration test covers drain, untouched updated_at, up, down and re-up. Part of #1747 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Summary
This is the contract half of the
cancelled->canceledrename (#47), as safe deploy steps. Migration 000089 was the expand step (#1277): it widened both status CHECKs and addedcanceled_by. This PR:'cancelled'status rows to'canceled'inpurchase_executionsandri_exchange_history, then narrows both status CHECKs to the canonical spelling.canceled_by.SavePurchaseExecutionwritescanceled_by = COALESCE(purchase_executions.canceled_by, $17), so a save from a stale copy can't erase a recorded actor.transitionExecutionStatusandSetCancelledByno longer dual-writecancelled_by.COALESCE(canceled_by, cancelled_by), andconfig.LegacyStatusCanceledstays in every status list and switch.The strict reads, the drain of
cancelled_byand the column drop are in #1747. They can ship once 000157 has run everywhere and no rollback can reach a build older than this one.Refs #47
Takeover reasoning (claude-cc-platform, 2026-11-04)
I took this PR over from @IAmJSD's draft (head
e9fd9445) and decided the open points from the issue and the code.Number. The migration was
000153, which main already uses (000153_marketplace_listing_settlements). Under the coordinator's policy (2026-11-05), migration numbers are assigned at merge time. Just before merging, this PR renumbers000157to main's highest + 1 and re-runs its migration and integration tests. golang-migrate applies only versions above the current one, so it must never merge with a number at or below main's highest.Tolerant reads stay. The draft switched reads to
canceled_byalone and dropped the legacy spelling. With new code running before the migration (dev auto-migrates only on start, and prod dispatches migrations separately), that misreads unmigrated rows:A schema gate would need a fallback read for the not-ready case, and that fallback is the tolerant read. So the tolerant reads stay, and the strict reads move to chore(db): finish the canceled rename: drain and drop cancelled_by, strict reads #1747.
The migration is slimmed. The draft's trigger mirrored
cancelled_byintocanceled_by, and the draft drained it and backfilled on down. Under tolerant reads nothing readscanceled_byalone, so those steps had no caller. Removing them means the down migration only re-widens the CHECKs. The drain belongs in the column-drop migration (chore(db): finish the canceled rename: drain and drop cancelled_by, strict reads #1747).updated_atis preserved. The normalize UPDATEs run withupdate_purchase_executions_updated_at(000128) andri_exchange_updated_at(000009) disabled. Otherwise every legacy row would look freshly changed to plan-health scoring (CountExecutionsByPlanAndStatus), to retention (CleanupOldExecutionsages canceled rows byupdated_at) and to the audit trail.DISABLE TRIGGERis transactional: if the file's transaction fails, the triggers come back enabled.Correction to the draft body. It said no read of
cancelled_byremains.store_postgres_history_page.gostill reads it throughCOALESCE(canceled_by, cancelled_by), as it does on main, and this PR keeps that.Safe in both orders
'cancelled'status to either table. Status writes useStatusCanceledor a caller-built status, andSavePurchaseExecutioncopies a status only into fresh rows or under guarded status lists. Main'scancelled_bywrites keep working, and main readsCOALESCE.TestMigration_000157_ContractCanceledSpellingruns main's exact write statements at 000157: theSavePurchaseExecutionTxupsert,SetCancelledBy, thetransitionExecutionStatusdual write andCancelAllPendingExchanges. None hits a CHECK violation, and the actor reads back.TestPostgresStore_NewCodeAgainstPreContractSchemaandTestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigrationcover it.'canceled'rows whether or not 000157 has run.Rollout
Before prod: the "every build since feat(api): page the History view with a keyset cursor (has_more, next_cursor) #1277 writes only
'canceled'" claim is a deploy fact, not a code fact. Confirm that no pre-feat(api): page the History view with a keyset cursor (has_more, next_cursor) #1277 image can still run in prod or be rolled back to, or that build's cancel would fail the narrowed CHECK (as-qa-2).Lock time: the narrowed CHECKs validate both tables under ACCESS EXCLUSIVE. That's fine at current sizes. If either table grows large, switch to
ADD CONSTRAINT ... NOT VALIDplusVALIDATE CONSTRAINT(as-qa-2).dev: once dev reads main's highest migration with
dirty = false, the merge deploy auto-applies this one. Dispatching-f steps=1first works too.prod: either order is safe:
-f environment=prod -f steps=1then the release, or the release first. Check thatschema_migrationsreads the version just below this one first.Before merging, renumber to origin/main's highest + 1. A migration numbered at or below main's highest would be skipped silently.
Tests (each shown to fail without its fix)
TestMigration_000157_ContractCanceledSpellingruns from the version below 159 up to 159, down, and up again. It covers normalization, the narrowed CHECKs (SQLSTATE 23514 on the legacy spelling), the exact CHECK text restored by down,updated_atunchanged on both tables with both triggers enabled again, and main's write statements. Each probe fails it: no normalize, no trigger disable on either table, no re-enable, and an un-narrowedri_exchange_historyCHECK.TestPGXMock_ExecutionProjectionsReadCoalescedActorfails on the draft's strict reads, and when the COALESCE is removed from one projection.TestPGXMock_ExecutionCancelActorWritesOnlyCanceledByfails on each restored dual write, on a plain$17in the save, and on a save that writes the legacy column.TestPostgresStore_NewCodeAgainstPreContractSchemafails on the draft's code with a lost actor, a blocked account delete, a dropped legacy actor on save and cleanup deleting 0 rows instead of 1.TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigrationfails on the draft's code: the cancel race errors where it should recover.Database-backed fixtures that inserted
'cancelled'were removed, because at the latest schema the CHECK rejects that spelling. The two pre-contract tests cover legacy rows instead. Unit tests keep their legacy fixtures.Verification
Head
13befc8aecdea8263b0e771b76127a26cdc6c2bf, merged onto maina8c34d44(main's highest migration:000156). Local run on macOS, with real Postgres via testcontainers and synthetic fixtures. Nothing was applied to any cloud.go build ./...,go vet -tags integration ./...andgo test -race -short ./...pass on the full suite.go test -tags integrationpasses forinternal/api,internal/config,internal/database/postgres/migrationsandinternal/purchase, plus./internal/database/...with every migration applied.000156on this tree:TestPostgresStore_NewCodeAgainstPreContractSchemalogs "Migrated to version 156" and passes.bc919da2:41_with the CONTRIBUTING audit line.000157after feat(plans): settle a failed-with-submission row so a managed plan can be deleted #1241.Noted
known_issues/41_cancel_actor_strict_reads.md: two ramp reads already on main (ListPlanCommittedTx,ListExecutionsForPlanStepTx) selectcanceled_byalone. This doesn't change any decision, and chore(db): finish the canceled rename: drain and drop cancelled_by, strict reads #1747's drain fixes it at the source.ladder_runsandladder_tranchestables keep their own'cancelled'spelling. They're out of scope.🤖 Generated with Claude Code