Repository navigation
fix(db): make purchase_history.ramp_step and purchase_executions.error NOT NULL - #1505
Conversation
…r NOT NULL Both columns have been nullable since migration 000001, but the Go readers scan them into plain values (ramp_step into an int; error into a string, tolerated since LeanerCloud#1473). The writers never store NULL, so a NULL only comes from a legacy or hand-written row, and one such purchase_history row fails the scan of the whole history listing. Migration 000146 backfills NULL to 0 / '' and sets both columns NOT NULL with that default. Both tables are locked ACCESS EXCLUSIVE before the backfill so a NULL committed by a concurrent older writer cannot slip in between the UPDATE and SET NOT NULL, and the purchase_executions updated_at trigger is disabled for the backfill statement only, so the lifecycle clock of legacy rows is not reset. Down makes both columns nullable again. Adds real-Postgres migration tests (backfill, rows survive, updated_at kept, defaults, NULL rejected, GetAllPurchaseHistory lists the formerly-NULL row, idempotent up, down, and a concurrent in-flight NULL writer). The LeanerCloud#1465 regression test now relaxes the constraint in its own database to keep covering the scanner's NULL tolerance. Closes LeanerCloud#1484 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale: none found. Reviewer: rF1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
Root cause
purchase_history.ramp_step(INTEGER) andpurchase_executions.error(TEXT) have been nullable since migration 000001, but the Go readers scan them into plain values:ramp_stepintoPurchaseHistoryRecord.RampStep int(scanPurchaseHistoryRowand the other history readers),errorinto a string (tolerated throughsql.NullStringsince #1473). No Go writer stores NULL in either column (both are bound from a Goint/string), so a NULL can only come from a legacy or hand-written row. One suchpurchase_historyrow fails the scan of the whole listing (GetAllPurchaseHistoryreturns a scan error).Fix
Migration
000146_history_ramp_step_execution_error_not_null:LOCK TABLE purchase_executions, purchase_history IN ACCESS EXCLUSIVE MODEfirst, in one statement;ramp_stepNULL to0anderrorNULL to''(the values the Go zero values already mean);SET DEFAULT 0/SET DEFAULT ''andSET NOT NULLon both columns.Down drops the NOT NULL and the default on both columns. The backfilled
0/''stay, which is what the readers already returned for NULL.Two details the reviewers will want to check:
schema_migrationsdirty. With the lock taken first, the migration waits for the writer, then backfills its row. Taking ACCESS EXCLUSIVE up front also avoids upgrading the lock from ROW EXCLUSIVE to ACCESS EXCLUSIVE partway through.TestMigration146_ConcurrentNullWritercovers this race on real Postgres (see the probe below).update_purchase_executions_updated_atwould stampNOW()on every backfilled execution.updated_atis a lifecycle clock: plan health windows failed/canceled counts on it, andCleanupOldExecutionsuses it to age canceled rows (see 000128). So the trigger is disabled for the backfill statement only, the same pattern as 000127 and 000145. Because the file is one transaction, a failure rolls back the disable too.purchase_historyhas no UPDATE trigger.The existing #1465 regression test (
TestIntegration_ExecutionReaders_NullErrorColumn) seeded a NULL withUPDATE ... SET error = NULL, which the new constraint rejects (23502). It now runsALTER COLUMN error DROP NOT NULLin its own per-test database first, so it still covers the scanner's NULL tolerance on a schema rolled back below 000146.Closes #1484
Verification
All commands were run in the worktree with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. Integration evidence comes from real PostgreSQL 16 (testcontainers, shared container with a per-test database). No cloud APIs were called.go build ./...,go vet ./...,go vet -tags integration ./...: clean.go test -count=1 ./internal/database/postgres/migrations/(unit, including the transactional-safety scan): ok.go test -count=1 ./internal/config/: ok.go test ./internal/mocks/ -count=1: ok.golangci-lint run --allow-parallel-runners --build-tags integration ./internal/database/postgres/migrations/ ./internal/config/(v2.10.1): 0 issues. No non-test Go files changed, so gocyclo has nothing to check.go test -tags integration -count=1 -timeout 20m -run 'TestMigration146_' ./internal/database/postgres/migrations/: both PASS.TestMigration146_BackfillsAndEnforcesNotNull: at version 145 it seeds a NULLramp_steprow, aramp_step = 3row, a NULL-error execution and anerror = 'boom'execution. Fail-before (asserted as a precondition): at 145,GetAllPurchaseHistoryreturns a scan error. After 146: NULL becomes 0 /'', the set values are unchanged, the backfilled execution'supdated_atis unchanged,GetAllPurchaseHistoryreturns both rows, both columns are NOT NULL with defaults0/''::text, an insert that omits the columns gets the defaults, an explicit NULL fails with 23502, the trigger is enabled again (tgenabled = 'O') and stamps a later update, and re-running the up file succeeds. Down to 145 makes both columns nullable with no default, NULL inserts work again and existing rows survive.TestMigration146_ConcurrentNullWriter: an open transaction inserts NULL into both tables, the migration starts and blocks (a non-grantedpg_locksentry on one of the tables), the writer commits, and the migration succeeds with the row backfilled.go test -tags integration -count=1 -timeout 20m -run 'TestIntegration_ExecutionReaders_NullErrorColumn' ./internal/config/: PASS.Mutation probes (each change reverted afterwards):
LOCK TABLElineConcurrentNullWriterFAILS:failed to migrate to version 146: migration failed: column "ramp_step" of relation "purchase_history" contains null valuesBackfillsAndEnforcesNotNullFAILS:the backfill must not stamp updated_at: got 19:14:05, want 18:14:05BackfillsAndEnforcesNotNullFAILS:the updated_at trigger is enabled after the migration/a real update stamps updated_at againTestIntegration_ExecutionReaders_NullErrorColumnFAILS with SQLSTATE 23502 onSET error = NULLCombined-tree verification at local queue commit
89fe5df6= origin/main3bfc2fe5+fix/issue-1484,fix/issue-1498,fix/issue-1489andfix/issue-1494merged in that order. Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationoninternal/api,internal/configandinternal/database/postgres/migrations; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Out of scope
ramp_stepthroughsql.NullInt32only until the migration lands. This PR lands the migration, so the history scanners are unchanged; after 000146 they can no longer see a NULL. Theerrorscanner keeps its fix(config): tolerate a NULL error when scanning purchase executions #1473 NULL tolerance.Frontend follow-up for the UI owner
None. API responses are unchanged:
ramp_stepwas already0/ omitted anderror""/ omitted for these rows.Maintainer: please mirror the issue's triage labels on this PR:
priority/p3,severity/low,urgency/this-quarter,impact/few,effort/xs,type/bug(the issue has notriagedlabel).🤖 Generated with Claude Code