Skip to content

fix(db): contract the canceled rename: normalize, narrow the status CHECKs, stop using cancelled_by - #1253

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-47
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-47

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the contract half of the 000089 cancelled -> canceled rename, minus the column drop. The column drop can't safely ship in the same release, so it comes in a follow-up (explained below).

What was still open on main. 000089 (EXPAND, #1277) widened both status CHECKs, added canceled_by and deferred everything else. Nothing after it touches these constraints or the column; I checked every migration through 000134 plus the open PRs holding 000135-000143. Main still:

  • accepts and reads both spellings: config.LegacyStatusCanceled appears in about ten switches and lists, and the cleanup SQL uses status IN ('cancelled','canceled').
  • reads the legacy column everywhere: all 11 execution read projections use COALESCE(canceled_by, cancelled_by).
  • still writes cancelled_by:
    • SavePurchaseExecution writes only cancelled_by (never canceled_by).
    • transitionExecutionStatus and SetCancelledBy dual-write both columns.

Nothing on main writes a 'cancelled' status to purchase_executions or ri_exchange_history. The remaining double-l literals belong to ladder_runs / ladder_tranches / AWS listing state, which are different tables and out of scope.

Changes

Migration 000144 contract_canceled_spelling (up), one transaction:

  1. LOCK TABLE purchase_executions, ri_exchange_history IN ACCESS EXCLUSIVE MODE. The CHECK rebuild below takes this lock anyway. Taking it first means no writer can commit between the normalize/drain and the narrowed CHECKs, or between the drain and the trigger.
  2. Create trigger purchase_executions_mirror_legacy_cancel_actor, BEFORE INSERT OR UPDATE ... WHEN (NEW.canceled_by IS NULL AND NEW.cancelled_by IS NOT NULL). It sets canceled_by := cancelled_by, so canceled_by always equals COALESCE(canceled_by, cancelled_by), which is exactly what older builds read.
  3. Normalize the data before narrowing:
    • status 'cancelled' -> 'canceled' in both tables
    • drain legacy-only actors into canceled_by
  4. Rebuild purchase_executions_status_check and ri_exchange_history_status_check as the 000089 lists minus 'cancelled'. The test checks the new constraint text is the old one with exactly that one entry removed.

Down: widens both CHECKs back to the exact 000089 definitions, drops the trigger and function, and backfills cancelled_by from canceled_by where it is NULL. Rows stay canceled. The widened CHECKs accept both spellings and every build since #1277 reads both, so there is no need to convert data back.

Code (no read or write of purchase_executions.cancelled_by remains):

  • All execution reads project plain canceled_by.
  • The cancel transition and SetCancelledBy write only canceled_by.
  • SavePurchaseExecution inserts canceled_by and on conflict sets canceled_by = COALESCE(purchase_executions.canceled_by, $17).
    • The effective value is the same as before: the old save wrote the legacy column and reads took COALESCE(canceled_by, cancelled_by), so a save could never change an actor already in canceled_by.
    • Writing $17 straight into canceled_by would let a save from a stale copy (read before a concurrent cancel) erase that cancel's actor.
  • LegacyStatusCanceled is removed, along with every dual-spelling branch: the history filter/annotation/KPI, cancel-recovery normalization, plan health, ramp settled/terminal/health-scored lists, ladder reconcile/resolve, ramp baseline and schedule clock. The cleanup query is now status = 'canceled'.
  • Tests are updated to match. Legacy-spelling fixtures are removed: a 'cancelled' row can no longer be stored. The 000089 test keeps its //nolint:misspell because it still exercises the legacy spelling at version 89.

Why cancelled_by is not dropped here (deploy order)

Migrations run at app startup (ensureDB -> RunMigrations). So the first instance of the release that ships 000144 applies it while instances of the previous build (P) are still serving. rollback.yml also redeploys an older image without touching the schema. P reads COALESCE(canceled_by, cancelled_by) in every execution query and writes cancelled_by. If this release dropped the column, every execution read on P would fail with "column does not exist" for the rest of the rollout, and so would any code-only rollback. So:

Step Ships Safe because
this PR (release R) 000144 + code that never touches cancelled_by P never writes 'cancelled' (verified on main), so the narrowed CHECKs don't affect it. P's legacy-column writes are mirrored by the trigger, so R can read canceled_by alone. P's COALESCE reads still work, since the column exists.
follow-up (release R+1, once no build older than R can run or be rolled back to) migration: drop trigger + function, final drain, DROP COLUMN cancelled_by R and later builds never reference the column.

That follow-up is the rest of #47, which is why this PR says Refs, not Closes. It needs a follow-up issue (or #47 left open) to track it.

Rollback story with the DB at 000144

Failure mode: migrations fail open (ensureDB logs and keeps serving). If 000144 failed, R would run on the unnormalized schema: surviving 'cancelled' rows and any un-drained legacy-only actors would read as unknown status or missing attribution. Nothing breaks outright, and /health reports the failed migration. The migration is a few UPDATEs and two CHECK rebuilds on two tables under one lock, so the main risk is lock wait on a busy table.

Lock ordering: 000144 locks purchase_executions then ri_exchange_history. I found no app transaction that writes both, so I don't expect deadlocks. If one happened, Postgres aborts one side; for the migration that means a rolled-back transaction and the existing dirty-flag recovery.

Refs #47

Verification

Run on this branch (62feb4fc), Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null.

  • go build ./..., go vet ./..., go vet -tags integration ./...: clean.
  • Unit (no tag, -count=1): ./internal/config, ./internal/api, ./internal/purchase, ./internal/mocks, ./internal/database/postgres/migrations: all ok.
  • golangci-lint run --allow-parallel-runners (v2.10.1), with and without --build-tags integration, on ./internal/config/... ./internal/api/... ./internal/purchase/... ./internal/database/postgres/migrations/... ./cmd/cleanup-lambda/...: 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" on every changed non-test file: nothing over 10.

New tests

  • TestPGXMock_ExecutionCancelActorUsesOnlyCanceledBy (pgxmock, unit). Its query matcher rejects any statement naming the legacy column. It drives GetExecutionByID, TransitionExecutionStatus(... canceled), SetCancelledBy and SavePurchaseExecution, and pins the COALESCE(purchase_executions.canceled_by, $17) save.
    • Fail-before: with store_postgres.go swapped back to HEAD~1, it FAILS (statement still references the legacy cancel actor column).
    • Pass-after: PASS.
  • TestMigration_000144_ContractCanceledSpelling (integration, real Postgres 16 via testcontainers). From previousMigrationVersion(t, 144) it seeds legacy-status, drain-only, both-columns and conflicting-actor rows plus a legacy ri_exchange_history row, then migrates to 144 and asserts:
    • normalization and drain;
    • each CHECK equals its pre-migration text minus exactly the legacy entry;
    • the legacy spelling is rejected (23514, named constraint) on both tables;
    • an older-build legacy-only INSERT/UPDATE is mirrored into canceled_by, and a legacy write never overrides an existing canceled_by;
    • after down, both CHECKs are byte-identical to 000089's, the legacy spelling is accepted again, the legacy column is backfilled and the trigger is gone;
    • re-up works.
  • TestMigration_000144_ConcurrentLegacyWritersKeepAttribution (integration, race). Four writers insert legacy-only actor rows (the shape an older build's save produces) continuously before, during and after MigrateToVersion(144). It requires at least 20 inserts after the migration, no writer errors, and zero rows with cancelled_by set and canceled_by NULL (about 1,260 rows in the run).

Mutation probes (each reverted afterwards):

Mutation of 000144.up Result
trigger removed ContractCanceledSpelling FAILS (a legacy-only insert must be mirrored into canceled_by); ConcurrentLegacyWriters... FAILS (every legacy-only actor must be in canceled_by, of 1264 rows)
drain UPDATE removed ContractCanceledSpelling FAILS on the drain-only row
purchase_executions status normalization removed migration fails: check constraint "purchase_executions_status_check" ... is violated by some row (SQLSTATE 23514)

Changed integration tests, run narrowly:

go test -tags integration -count=1 -timeout 20m -run 'TestMigration_000144_|TestMigration_000089_' ./internal/database/postgres/migrations   # ok
go test -tags integration -count=1 -timeout 20m -run 'TestDeletePlannedPurchase_RecordsCancelActor|TestTransitionExecutionStatus_NonCancelLeavesCancelColumnsNull|TestPersistExecution_RetryAfterRowLeftPending' ./internal/api   # ok
go test -tags integration -count=1 -timeout 20m -run 'TestRampRetention_OldCleanupWritersPreserveHeldRows|TestListPlanCommittedTx_RealRowShapes|TestResolveLadderTrancheFailed_ProvenChain|TestRampStepStatusListsPartitionEveryWrittenStatus|TestPostgresStoreDB_CleanupOldExecutions_RetainsCanceledInsideHealthWindow' ./internal/config   # ok
go test -tags integration -count=1 -timeout 20m -run 'TestReconcileLadderTranches_Mappings' ./internal/purchase   # ok
go test -tags integration -count=1 -timeout 20m -run 'TestCleanupStatusListsCoverSchema|TestDeleteExpiredKeepsLiveAndHealthScoredRows' ./cmd/cleanup-lambda   # ok

TestPersistExecution_RetryAfterRowLeftPending failed once in the first combined run on a loaded machine, and the error detail was not captured. It passed on two reruns, alone and together with the other two tests. TestRampRetention_OldCleanupWritersPreserveHeldRows needed its expected delete count lowered from 3 to 2 once the legacy fixture row was removed.

All of the above is local evidence: unit tests, pgxmock and testcontainers Postgres. Nothing was run against a deployed environment.

Combined-tree verification. This branch was tested together with six other queued branches on a local, never-pushed tree: queue/platform @ 77d116c6 = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-47 62feb4f
fix/issue-76 3b8c241
fix/issue-1233 e1638b7
fix/issue-153 8729074
fix/issue-532 0971624
fix/issue-64 fefdc43
fix/issue-54 91827b9

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks (changed packages): golangci-lint v2.10.1 found 0 issues, with and without --build-tags integration, and gocyclo -over 10 is silent on the changed non-test files. These ran at b61a9c30, which differs from 77d116c6 only by one .gitallowed line on fix/issue-1233.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1):
    • These packages pass: cmd/cleanup-lambda, internal/analytics, internal/auth, internal/database/postgres/migrations and internal/purchase.
    • internal/api passes, except two tests whose Postgres container didn't start, or whose migrations hit a timeout, under machine load. Rerun alone on the same tree, both pass: TestDeleteManagedPlanByExecutionStatus and TestNotifiedRow_RescheduleDoesNotOverwriteConcurrentCancel.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • Packages were run with a 60-minute timeout (rather than CI's 20) because the VM was shared with other test runs. Some reruns used TESTCONTAINERS_RYUK_DISABLED=true to avoid a testcontainers reaper race under load.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope

  • The DROP COLUMN cancelled_by follow-up, described above. Its migration should also drop the trigger and function and do a final drain.
  • ramp_ledger_holds() (000124) still lists both spellings. That is harmless: the function is IMMUTABLE SQL, and redefining it is churn for no behavior change now that 'cancelled' can't be stored.
  • Mock and fixture double-l status strings with no DB effect: internal/mocks/stores.go default returns and handler_ri_exchange_test.go TestApproveRIExchange_AlreadyCancelled.
  • ladder_runs.cancelled_by / ladder 'cancelled' statuses are a separate table and spelling (000080) and are not part of this rename.
  • Go names kept to avoid churn: the field PurchaseExecution.CancelledBy (JSON cancelled_by, which the frontend consumes) and the method name SetCancelledBy are unchanged.

Frontend follow-up for the UI owner

After 000144 the API never returns cancelled for purchase executions or RI exchanges, so the dual-spelling branches added for 000089 can go:

  • frontend/src/history.ts: status badge (case 'cancelled'), the status counts (s === 'canceled' || s === 'cancelled') and the two filter predicates around lines 1187 and 1207. The 'cancelled' chip key itself is UI-internal and can stay.
  • frontend/src/riexchange.ts around line 2363: case 'cancelled' in the exchange status badge.

None of this is required for correctness. It is dead code once 000144 has run.

Maintainer: please mirror the issue's triage labels onto this PR: priority/p2, severity/medium, impact/internal, type/chore, triaged, urgency/this-quarter, effort/s.

🤖 Generated with Claude Code

…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>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged urgency/this-quarter Within the quarter effort/s Hours labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

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

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

This branch has not been deployed

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

Labels

effort/s Hours impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant