From 62feb4fc5eae65ec8dddb5ee5ed693a08a1669d3 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 04:14:14 +0000 Subject: [PATCH 01/19] 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 --- cmd/cleanup-lambda/main_integration_test.go | 3 +- internal/api/handler_history.go | 20 +- internal/api/handler_history_test.go | 13 +- internal/api/handler_purchases.go | 18 +- ...purchases_cancel_actor_integration_test.go | 25 +- ...urchases_submit_dedupe_integration_test.go | 2 +- internal/api/handler_purchases_test.go | 61 ----- internal/api/plan_health.go | 6 +- internal/api/plan_health_test.go | 9 +- internal/config/interfaces.go | 9 +- internal/config/store_postgres.go | 65 +++-- internal/config/store_postgres_db_test.go | 11 - .../config/store_postgres_ladder_resolve.go | 4 +- ...ostgres_ladder_resolve_integration_test.go | 1 - .../store_postgres_ladder_resolve_test.go | 2 +- .../config/store_postgres_pgxmock_test.go | 146 ++++++----- .../store_postgres_plan_schedule_clock.go | 2 +- .../config/store_postgres_ramp_baseline.go | 2 +- ...postgres_ramp_baseline_integration_test.go | 3 +- .../store_postgres_ramp_baseline_test.go | 2 +- ...ore_postgres_ramp_step_integration_test.go | 2 +- ...ore_postgres_retention_integration_test.go | 3 +- internal/config/types.go | 23 +- ...000144_contract_canceled_spelling.down.sql | 33 +++ .../000144_contract_canceled_spelling.up.sql | 70 +++++ .../000144_contract_canceled_spelling_test.go | 243 ++++++++++++++++++ internal/purchase/ladder_reconcile.go | 2 +- .../ladder_reconcile_integration_test.go | 1 - internal/purchase/ladder_reconcile_test.go | 1 - 29 files changed, 505 insertions(+), 277 deletions(-) create mode 100644 internal/database/postgres/migrations/000144_contract_canceled_spelling.down.sql create mode 100644 internal/database/postgres/migrations/000144_contract_canceled_spelling.up.sql create mode 100644 internal/database/postgres/migrations/000144_contract_canceled_spelling_test.go diff --git a/cmd/cleanup-lambda/main_integration_test.go b/cmd/cleanup-lambda/main_integration_test.go index 4f0afbcde..6f8951e65 100644 --- a/cmd/cleanup-lambda/main_integration_test.go +++ b/cmd/cleanup-lambda/main_integration_test.go @@ -15,7 +15,6 @@ import ( "testing" "time" - "github.com/LeanerCloud/cloud-commitments-platform/internal/config" "github.com/LeanerCloud/cloud-commitments-platform/internal/database" "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" @@ -30,7 +29,7 @@ var ( cleanupDeletable = []string{"completed", "partially_completed", "expired", "revocation_requested"} // Live (an executor, scheduler or reaper may still act on it), or counted // by the health score and left to CleanupOldExecutions: always kept. - cleanupKept = []string{"pending", "notified", "approved", "running", "scheduled", "paused", "failed", "canceled", config.LegacyStatusCanceled} + cleanupKept = []string{"pending", "notified", "approved", "running", "scheduled", "paused", "failed", "canceled"} ) func cleanupTestDB(ctx context.Context, t *testing.T) *database.Connection { diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index 40e9fb678..79d24b6b4 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -131,12 +131,7 @@ var actionableExecutionStatuses = []string{"pending", "notified", "scheduled", " // partial-failure marker and is flagged IsAuditGap so its execution-level // dollars are excluded from the dashboard totals — the committed dollars are // already counted via the per-rec purchase_history rows that succeeded. -// Both the US-spelling status (config.StatusCanceled) and the legacy British -// spelling (config.LegacyStatusCanceled) are listed: during the expand-contract -// rename (migration 000089) old code may still write the legacy value before -// the rolling deploy completes. The contract migration (#1278) normalizes the -// data once the deploy is verified stable; drop the legacy entry here then. -var terminalExecutionStatuses = []string{"completed", "partially_completed", "failed", "expired", config.StatusCanceled, config.LegacyStatusCanceled} +var terminalExecutionStatuses = []string{"completed", "partially_completed", "failed", "expired", config.StatusCanceled} // approvalExpiryWindow is how long a pending approval stays actionable // before the History view flips it to "expired". Aligns with the @@ -423,10 +418,7 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P row.StatusDescription = exec.Error case "expired": row.StatusDescription = "approval link expired (not approved within 7 days)" - case config.StatusCanceled, config.LegacyStatusCanceled: - // The legacy British spelling is still matched during the - // expand-contract rename (migration 000089) until the contract - // migration (#1278) normalizes and drops it. + case config.StatusCanceled: annotateCancelled(row, exec, approver) default: // In-flight (approved/running/scheduled/paused) and audit-gap @@ -1156,14 +1148,10 @@ func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistoryS case "expired": summary.TotalExpired++ continue - case config.StatusCanceled, config.LegacyStatusCanceled: + case config.StatusCanceled: // A canceled purchase represents zero committed spend and zero // realized savings (issue #736). Exclude from all dollar KPIs and - // from TotalCompleted -- the money was never committed. The legacy - // British spelling is matched alongside the US one during the - // expand-contract rename (migration 000089) so legacy rows can't - // inflate KPIs mid-deploy; the contract migration (#1278) drops it - // once the deploy is stable. + // from TotalCompleted -- the money was never committed. continue } summary.TotalCompleted++ diff --git a/internal/api/handler_history_test.go b/internal/api/handler_history_test.go index 617477408..5bd10fc0e 100644 --- a/internal/api/handler_history_test.go +++ b/internal/api/handler_history_test.go @@ -1775,16 +1775,9 @@ func TestSummarizePurchaseHistory_CancelledExcludedFromKPIs(t *testing.T) { // One pending row that should be counted as pending, not completed. {Status: "pending", UpfrontCost: new(float64(999.0)), EstimatedSavings: 99.0}, // Two canceled rows — the regression case from issue #736. Neither must - // appear in the dollar KPIs or TotalCompleted. One uses the new US - // spelling (config.StatusCanceled) and one the legacy British spelling - // (config.LegacyStatusCanceled): during the expand-contract rename - // (migration 000089) a mixed fleet still emits the legacy spelling, so - // the dual-spelling read path must exclude BOTH. The constant carries - // the legacy value without a literal the US-locale misspell linter would - // flag (and without a nolint). Drop the legacy fixture once the contract - // migration (#1278) normalizes the data. + // appear in the dollar KPIs or TotalCompleted. {Status: config.StatusCanceled, UpfrontCost: new(float64(500.0)), EstimatedSavings: 50.0}, - {Status: config.LegacyStatusCanceled, UpfrontCost: new(float64(750.0)), EstimatedSavings: 75.0}, + {Status: config.StatusCanceled, UpfrontCost: new(float64(750.0)), EstimatedSavings: 75.0}, } summary := summarizePurchaseHistory(purchases) @@ -1921,7 +1914,7 @@ func TestSummarizePurchaseHistory_CancelPendingDoesNotChangeKPIs(t *testing.T) { // After: same rows plus one canceled execution (the pending that got canceled). withCancelled := append(baseline, config.PurchaseHistoryRecord{ //nolint:gocritic - Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + Status: config.StatusCanceled, UpfrontCost: new(float64(999.0)), EstimatedSavings: 99.0, }) diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index ce5212ded..9f7629d61 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -770,27 +770,11 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri if getErr != nil { return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr) } - // Accept both spellings: during the expand-contract rename (migration - // 000089) a concurrent legacy cancel may have written the legacy value - // before the rolling deploy completes. The contract migration (#1278) - // normalizes the data, after which LegacyStatusCanceled can be removed. - if existing.Status != config.StatusCanceled && existing.Status != config.LegacyStatusCanceled { + if existing.Status != config.StatusCanceled { return nil, NewClientError(409, fmt.Sprintf( "execution %s cannot be canceled (status=%s)", executionID, existing.Status)) } - // Normalize the response Status so the idempotent recovery path returns the - // canonical US spelling even when the DB row still carries the legacy value. - // Without this, a caller that received an in-flight 200 from this branch - // would see status="canceled" while a caller that hit the happy-path - // transition above would see status="canceled" for the same execution_id - // during the rolling deploy. The legacy value is preserved in storage -- - // only the in-memory copy returned to the handler is normalized -- so the - // contract migration's authoritative status backfill still observes every - // legacy row. - if existing.Status == config.LegacyStatusCanceled { - existing.Status = config.StatusCanceled - } if delErr := h.config.DeleteSuppressionsByExecution(ctx, executionID); delErr != nil { return nil, fmt.Errorf("cancel execution %s: failed to release suppressions: %w", executionID, delErr) } diff --git a/internal/api/handler_purchases_cancel_actor_integration_test.go b/internal/api/handler_purchases_cancel_actor_integration_test.go index 45dbee3b6..eb7030270 100644 --- a/internal/api/handler_purchases_cancel_actor_integration_test.go +++ b/internal/api/handler_purchases_cancel_actor_integration_test.go @@ -39,7 +39,7 @@ func TestDeletePlannedPurchase_RecordsCancelActor(t *testing.T) { require.Equal(t, config.StatusCanceled, row.Status) require.NotNil(t, row.CancelledBy, "cancel actor must be readable after a UI cancel") require.Equal(t, "creator@example.com", *row.CancelledBy) - requireRawCancelColumns(ctx, t, f, execID, "creator@example.com") + requireRawCanceledBy(ctx, t, f, execID, "creator@example.com") // The record TransitionExecutionStatus returns must use the same projection. directID := uuid.NewString() @@ -55,7 +55,7 @@ func TestDeletePlannedPurchase_RecordsCancelActor(t *testing.T) { // TestTransitionExecutionStatus_NonCancelLeavesCancelColumnsNull pins the // cancel-only guard: a non-cancel transition with an actor stamps -// transitioned_by but must not write either cancel column. +// transitioned_by but must not write canceled_by. func TestTransitionExecutionStatus_NonCancelLeavesCancelColumnsNull(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) t.Cleanup(cancel) @@ -72,26 +72,19 @@ func TestTransitionExecutionStatus_NonCancelLeavesCancelColumnsNull(t *testing.T _, err := f.store.TransitionExecutionStatus(ctx, execID, []string{"pending"}, config.StatusFailed, &creator) require.NoError(t, err) - var canceledBy, cancelledBy *string + var canceledBy *string require.NoError(t, f.pool.QueryRow(ctx, - "SELECT canceled_by, "+legacyCancelColumn+" FROM purchase_executions WHERE execution_id=$1", execID, - ).Scan(&canceledBy, &cancelledBy)) + "SELECT canceled_by FROM purchase_executions WHERE execution_id=$1", execID, + ).Scan(&canceledBy)) require.Nil(t, canceledBy, "canceled_by must stay NULL on a non-cancel transition") - require.Nil(t, cancelledBy, legacyCancelColumn+" must stay NULL on a non-cancel transition") } -func requireRawCancelColumns(ctx context.Context, t *testing.T, f *createConcurrencyFixture, execID, want string) { +func requireRawCanceledBy(ctx context.Context, t *testing.T, f *createConcurrencyFixture, execID, want string) { t.Helper() - var canceledBy, cancelledBy *string + var canceledBy *string require.NoError(t, f.pool.QueryRow(ctx, - "SELECT canceled_by, "+legacyCancelColumn+" FROM purchase_executions WHERE execution_id=$1", execID, - ).Scan(&canceledBy, &cancelledBy)) + "SELECT canceled_by FROM purchase_executions WHERE execution_id=$1", execID, + ).Scan(&canceledBy)) require.NotNil(t, canceledBy, "canceled_by column") - require.NotNil(t, cancelledBy, legacyCancelColumn+" column") require.Equal(t, want, *canceledBy) - require.Equal(t, want, *cancelledBy) } - -// legacyCancelColumn is the pre-migration-000089 British spelling, split so the -// misspell linter does not flag the column name. -const legacyCancelColumn = "cancel" + "led_by" diff --git a/internal/api/handler_purchases_submit_dedupe_integration_test.go b/internal/api/handler_purchases_submit_dedupe_integration_test.go index 5f89a65d7..f56ddd70a 100644 --- a/internal/api/handler_purchases_submit_dedupe_integration_test.go +++ b/internal/api/handler_purchases_submit_dedupe_integration_test.go @@ -122,7 +122,7 @@ func TestPersistExecution_RetryAfterRowLeftPending(t *testing.T) { }{ {"pending", true}, {"notified", true}, {"approved", true}, {"running", true}, {"completed", true}, {"partially_completed", true}, - {config.StatusFailed, false}, {config.StatusCanceled, false}, {config.LegacyStatusCanceled, false}, {config.StatusExpired, false}, + {config.StatusFailed, false}, {config.StatusCanceled, false}, {config.StatusExpired, false}, } { t.Run(tc.status, func(t *testing.T) { recs, key := submitFixture(t) diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 2bda5e715..a1865edbd 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -2019,67 +2019,6 @@ func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing assert.Contains(t, ce.message, "running", "error must include actual status") } -// TestHandler_deletePlannedPurchase_ConflictRetryLegacyCanceledNormalizes is -// the regression guard for the CR outside-diff comment on PR #1277: when the -// CAS conflict recovery branch finds the row in the LEGACY British spelling -// (the row was canceled by old code during the rolling deploy window), the -// API response Status must be normalized to the canonical US spelling so a -// caller doesn't observe two different status values for the same execution -// depending on which code instance handled the request. The stored row is -// untouched -- only the in-memory copy returned to the handler is normalized. -func TestHandler_deletePlannedPurchase_ConflictRetryLegacyCanceledNormalizes(t *testing.T) { - ctx := context.Background() - mockStore := new(MockConfigStore) - mockAuth := new(MockAuthService) - t.Cleanup(func() { mockStore.AssertExpectations(t) }) - t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - - adminSession := &Session{ - UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - Email: "admin@example.com", - } - - planID := "12121212-1212-1212-1212-121212121212" - execID := "13131313-1313-1313-1313-131313131313" - - conflictErr := fmt.Errorf("%w: execution %s cannot transition", config.ErrExecutionNotInExpectedStatus, execID) - - // Old code already canceled this row during the deploy window: the DB - // holds the legacy British spelling. The handler must accept it (idempotent - // recovery) but normalize the response to the canonical US spelling. - existingExec := &config.PurchaseExecution{ - ExecutionID: execID, - PlanID: planID, - Status: config.LegacyStatusCanceled, - } - plan := &config.PurchasePlan{ - ID: planID, - Name: "Legacy Cancel Plan", - Enabled: true, - } - - mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) - mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything).Return(nil, conflictErr) - mockStore.On("GetExecutionByID", ctx, execID).Return(existingExec, nil) - mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) - mockStore.On("UpdatePurchasePlan", ctx, mock.MatchedBy(func(p *config.PurchasePlan) bool { - return p.ID == planID && !p.Enabled - })).Return(nil) - - handler := &Handler{config: mockStore, auth: mockAuth} - - req := &events.LambdaFunctionURLRequest{ - Headers: map[string]string{"Authorization": "Bearer admin-token"}, - } - result, err := handler.deletePlannedPurchase(ctx, req, execID) - require.NoError(t, err) - assert.Equal(t, config.StatusCanceled, result.Status, - "legacy 'cancelled' must be normalized to the canonical US 'canceled' in the response") - assert.NotEqual(t, config.LegacyStatusCanceled, result.Status, - "the response must never leak the legacy spelling once the recovery branch ran") -} - // TestHandler_deletePlannedPurchase_BackendErrorReturns5xx is the regression // guard for CodeRabbit round-2 finding #1 (same class as PR #1276): a // TransitionExecutionStatus error that is NOT ErrExecutionNotInExpectedStatus diff --git a/internal/api/plan_health.go b/internal/api/plan_health.go index 0210854d5..f37fcd1cb 100644 --- a/internal/api/plan_health.go +++ b/internal/api/plan_health.go @@ -222,11 +222,9 @@ func failedExecutionsFactor(counts config.ExecutionStatusCounts) (PlanHealthFact } // canceledExecutionsFactor: -5 per canceled execution in the last -// planHealthLookbackDays, capped at 4 (-20 max). Counts both spellings of -// "canceled" (config.StatusCanceled and the legacy -// config.LegacyStatusCanceled) so pre-#1278 rows aren't invisible to scoring. +// planHealthLookbackDays, capped at 4 (-20 max). func canceledExecutionsFactor(counts config.ExecutionStatusCounts) (PlanHealthFactor, bool) { - count := counts[config.StatusCanceled] + counts[config.LegacyStatusCanceled] + count := counts[config.StatusCanceled] if count == 0 { return PlanHealthFactor{}, false } diff --git a/internal/api/plan_health_test.go b/internal/api/plan_health_test.go index 7f6b5605a..47eb6c10d 100644 --- a/internal/api/plan_health_test.go +++ b/internal/api/plan_health_test.go @@ -149,7 +149,7 @@ func TestComputePlanHealth_FailedExecutionsPenaltyCapsAtFour(t *testing.T) { assert.Equal(t, 100-40, score) } -func TestComputePlanHealth_CanceledExecutionsPenaltyCapsAtFourAndCountsBothSpellings(t *testing.T) { +func TestComputePlanHealth_CanceledExecutionsPenaltyCapsAtFour(t *testing.T) { now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) plan := config.PurchasePlan{ Enabled: true, @@ -160,11 +160,10 @@ func TestComputePlanHealth_CanceledExecutionsPenaltyCapsAtFourAndCountsBothSpell StartDate: now.AddDate(0, 0, -1), }, } - // 3 canonical + 2 legacy-spelled = 5 total canceled; penalty caps at - // 4 * 5 = 20 but the note reports the true count of 5. + // 5 canceled; penalty caps at 4 * 5 = 20 but the note reports the true + // count of 5. execCounts := counts(map[string]int{ - config.StatusCanceled: 3, - config.LegacyStatusCanceled: 2, + config.StatusCanceled: 5, }) score, factors := computePlanHealth(plan, now, execCounts, config.RampStepBlock{}) diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index 9a078709a..4459f0acb 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -251,11 +251,10 @@ type StoreInterface interface { // (and writes nothing) when the row has left those statuses, so a cancel or // approve that lands first is never overwritten. RescheduleAwaitingExecution(ctx context.Context, executionID string, scheduledDate time.Time) (bool, error) - // SetCancelledBy stamps canceled_by and the legacy cancelled_by column on - // an execution without overwriting any other columns. Used after - // TransitionExecutionStatus to fold the actor attribution into the same - // logical write without the full-row SavePurchaseExecution clobber risk - // (Finding #5 / PR #889). + // SetCancelledBy stamps canceled_by on an execution without overwriting + // any other columns. Used after TransitionExecutionStatus to fold the actor + // attribution into the same logical write without the full-row + // SavePurchaseExecution clobber risk (Finding #5 / PR #889). SetCancelledBy(ctx context.Context, executionID string, cancelledBy string) error // LinkRetryExecutionAtomic stamps retry_execution_id on a failed // execution row without a full-row overwrite, conditional on diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 668d1bd27..ca9e39ebd 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -1083,12 +1083,16 @@ func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, capacityPercent = 100 } + // canceled_by keeps an actor already on the row: a save from a copy read + // before a concurrent cancel must not erase that cancel's attribution. + // This is the value reads saw before migration 000144, when the save wrote + // the legacy column and reads took COALESCE(canceled_by, cancelled_by). query := ` INSERT INTO purchase_executions ( plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1107,7 +1111,7 @@ func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, cloud_account_id = $14, source = $15, approved_by = $16, - cancelled_by = $17, + canceled_by = COALESCE(purchase_executions.canceled_by, $17), capacity_percent = $18, approval_token_expires_at = $22, executed_by_user_id = $23, @@ -1239,8 +1243,8 @@ func (s *PostgresStore) RotatePendingApprovalToken(ctx context.Context, executio // actor is the UUID of the user performing the transition (nil for system-initiated paths); // it is stamped onto transitioned_by and transitioned_at is always set to NOW(). // A transition to StatusCanceled also stamps the actor's email onto canceled_by -// and the legacy cancelled_by (issue #119), matching what the other cancel paths -// record and what History reads. +// (issue #119), matching what the other cancel paths record and what History +// reads. func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error) { return s.transitionExecutionStatus(ctx, s.db, executionID, fromStatuses, toStatus, actor, nil, nil) } @@ -1319,8 +1323,7 @@ func (s *PostgresStore) transitionExecutionStatus(ctx context.Context, q executi UPDATE purchase_executions SET status = $2, updated_at = NOW(), transitioned_by = $4, transitioned_at = NOW(), - canceled_by = COALESCE((SELECT email FROM users WHERE id = $5), canceled_by), - cancelled_by = COALESCE((SELECT email FROM users WHERE id = $5), cancelled_by) + canceled_by = COALESCE((SELECT email FROM users WHERE id = $5), canceled_by) WHERE execution_id = $1 AND status = ANY($3) AND ($6::boolean IS NULL OR (recommendations IN ('[]'::jsonb, 'null'::jsonb)) = $6) AND ($7::text IS NULL OR (approval_token_hash = $7 @@ -1328,7 +1331,7 @@ func (s *PostgresStore) transitionExecutionStatus(ctx context.Context, q executi RETURNING plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1377,16 +1380,13 @@ func transitionRejection(ctx context.Context, q executionQuerier, executionID st return fmt.Errorf("%w: execution %s cannot transition from %q to %q", ErrExecutionNotInExpectedStatus, executionID, existing.Status, toStatus) } -// SetCancelledBy stamps both the canceled_by and legacy cancelled_by columns -// for a single execution without touching any other column. This avoids the -// full-row overwrite that a SavePurchaseExecution follow-up would perform, -// eliminating the lost-update window between TransitionExecutionStatus and -// the attribution write (Finding #5). Writing both columns keeps this path -// symmetric with CancelExecutionAtomic/CancelScheduledExecutionAtomic (which -// write canceled_by only) so revoke-attribution keeps working unchanged if a -// future contract migration (#1278) drops the legacy column. +// SetCancelledBy stamps canceled_by for a single execution without touching +// any other column. This avoids the full-row overwrite that a +// SavePurchaseExecution follow-up would perform, eliminating the lost-update +// window between TransitionExecutionStatus and the attribution write +// (Finding #5). func (s *PostgresStore) SetCancelledBy(ctx context.Context, executionID, cancelledBy string) error { - q := `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() + q := `UPDATE purchase_executions SET canceled_by = $2, updated_at = NOW() WHERE execution_id = $1` _, err := s.db.Exec(ctx, q, executionID, cancelledBy) return err @@ -1545,7 +1545,7 @@ func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses [] SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1659,7 +1659,7 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1684,7 +1684,7 @@ func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderTha SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1726,7 +1726,7 @@ func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []stri SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1747,7 +1747,7 @@ func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExe SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1771,7 +1771,7 @@ func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ( SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1858,7 +1858,7 @@ func (s *PostgresStore) ListRecentSubmitsTx(ctx context.Context, tx pgx.Tx, crea SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1893,7 +1893,7 @@ func getExecutionByIDOn(ctx context.Context, q executionQuerier, executionID str SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1920,7 +1920,7 @@ func (s *PostgresStore) GetExecutionByPlanAndDate(ctx context.Context, planID st SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -2161,7 +2161,7 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, + cloud_account_id, source, approved_by, canceled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -2192,10 +2192,8 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha // clock here because a completed row executed on (or very near) the day // it was scheduled for. // -// 2. Canceled-state cleanup: `status IN ('cancelled', 'canceled') AND -// updated_at < NOW() - retention`. Both spellings are included to cover -// legacy rows ('cancelled') and new code rows ('canceled', canonical US -// spelling per StatusCanceled / migration 000089). +// 2. Canceled-state cleanup: `status = 'canceled' AND updated_at < NOW() - +// retention`. // // This branch retains on `updated_at`, NOT `scheduled_date`, and must // stay that way: CountExecutionsByPlanAndStatus windows the plan health @@ -2266,11 +2264,6 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha // are safe from expiry-based cleanup -- which, per the reachability note // above, is every row current code writes. func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error) { - // Branch 2 lists both spellings: 'cancelled' (legacy, pre-migration - // 000089 rows) and 'canceled' (canonical, written by new code after the - // follow-up to #1277). #1278 will drop 'cancelled' once all rows are - // normalized. - // // Branch 3's exclusion is parameterized from // HealthScoredExecutionStatuses instead of repeating a literal list, so // a status added to the plan health score extends this exclusion @@ -2283,7 +2276,7 @@ func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays AND scheduled_date < NOW() - INTERVAL '1 day' * $1 ) OR ( - status IN ('cancelled', 'canceled') + status = 'canceled' AND updated_at < NOW() - INTERVAL '1 day' * $1 ) OR ( diff --git a/internal/config/store_postgres_db_test.go b/internal/config/store_postgres_db_test.go index 4761b8f85..583545239 100644 --- a/internal/config/store_postgres_db_test.go +++ b/internal/config/store_postgres_db_test.go @@ -1289,17 +1289,6 @@ func TestPostgresStoreDB_CleanupOldExecutions_RetainsCanceledInsideHealthWindow( shouldSurvive: true, why: "expires_at branch must not purge what the canceled branch retains", }, - { - // Deliberately the LegacyStatusCanceled constant, i.e. the - // pre-migration-000089 double-l DB value. The label avoids - // spelling it out only to keep the misspell linter quiet. - name: "legacy-spelling-canceled-today-old-scheduled-date", - status: LegacyStatusCanceled, - scheduledDays: -40, - updatedDays: 0, - shouldSurvive: true, - why: "the legacy spelling is retained on updated_at too", - }, { // The `failed` counterpart of the canceled case above, and the // reason branch 3's exclusion is sourced from diff --git a/internal/config/store_postgres_ladder_resolve.go b/internal/config/store_postgres_ladder_resolve.go index aea265e56..b92b30790 100644 --- a/internal/config/store_postgres_ladder_resolve.go +++ b/internal/config/store_postgres_ladder_resolve.go @@ -61,7 +61,7 @@ type LadderResolveResult struct { // still move money, so it blocks the resolve. func ladderResolvableNodeStatus(status string) bool { switch status { - case StatusFailed, StatusCanceled, LegacyStatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted: + case StatusFailed, StatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted: return true } return false @@ -71,7 +71,7 @@ func ladderResolvableNodeStatus(status string) bool { // the provider. func ladderNodeProvenUnsent(n LadderChainNode) bool { switch n.Status { - case StatusCanceled, LegacyStatusCanceled, StatusExpired: + case StatusCanceled, StatusExpired: return true case StatusFailed: return n.NoSubmission diff --git a/internal/config/store_postgres_ladder_resolve_integration_test.go b/internal/config/store_postgres_ladder_resolve_integration_test.go index b16b9d966..7ed2bda6c 100644 --- a/internal/config/store_postgres_ladder_resolve_integration_test.go +++ b/internal/config/store_postgres_ladder_resolve_integration_test.go @@ -91,7 +91,6 @@ func TestResolveLadderTrancheFailed_ProvenChain(t *testing.T) { for name, nodes := range map[string][]resolveNode{ "no_submission head": {{"failed", true, time.Minute}}, "canceled after proof": {{"failed", true, time.Hour}, {"canceled", false, time.Minute}}, - "legacy spelling": {{LegacyStatusCanceled, false, time.Minute}}, "expired": {{"expired", false, time.Minute}}, } { t.Run(name, func(t *testing.T) { diff --git a/internal/config/store_postgres_ladder_resolve_test.go b/internal/config/store_postgres_ladder_resolve_test.go index ba4c4287d..aeb09f332 100644 --- a/internal/config/store_postgres_ladder_resolve_test.go +++ b/internal/config/store_postgres_ladder_resolve_test.go @@ -9,7 +9,7 @@ import ( // The chain-node allowlist fails closed: a live status or one added later is // not resolvable (the status CHECK keeps such rows out of the database tests). func TestLadderResolvableNodeStatus(t *testing.T) { - for _, s := range []string{StatusFailed, StatusCanceled, LegacyStatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted} { + for _, s := range []string{StatusFailed, StatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted} { assert.True(t, ladderResolvableNodeStatus(s), s) } for _, s := range []string{"pending", "notified", "scheduled", "approved", "running", "", "future-status"} { diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index 25a76d932..994abe913 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -882,51 +882,29 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestPGXMock_GetExecutionByID_ProjectsCoalescedCancelledBy guards the #1277 / -// #1453 read-side follow-up: CancelExecutionAtomic and -// CancelScheduledExecutionAtomic write the canceling actor to the NEW -// canceled_by column, but migration 000089's expand-contract contract states -// every SELECT must read back COALESCE(canceled_by, cancelled_by) so a row -// written by either the old or the new column is attributed correctly. Before -// this fix GetExecutionByID selected the bare legacy cancelled_by, so an -// actor written only to canceled_by (the atomic-cancel paths) scanned back as -// NULL and the History page lost the attribution. -// -// The mock uses regexp query matching, so ExpectQuery requires the issued -// SELECT to contain the COALESCE expression; a bare `cancelled_by` projection -// (the pre-fix query) does not match, the mock returns no rows, and -// GetExecutionByID surfaces ErrNotFound instead of the expected execution -- -// that is how this test fails against the pre-fix code and passes once the -// projection is fixed. -func TestPGXMock_GetExecutionByID_ProjectsCoalescedCancelledBy(t *testing.T) { - mock := newMock(t) - store := storeWith(mock) - ctx := context.Background() - - recsJSON, _ := json.Marshal([]RecommendationRecord{}) - now := time.Now().Truncate(time.Second) - cols := []string{ - "plan_id", "execution_id", "status", "step_number", "scheduled_date", - "notification_sent", "approval_token", "recommendations", - "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", - "created_by_user_id", "retry_execution_id", "retry_attempt_n", - "approval_token_expires_at", - "executed_by_user_id", "executed_at", "pre_approval_skip_reason", - "idempotency_key", - "scheduled_execution_at", - "no_submission", "created_by_system", - } - // Simulates the exact row shape the atomic-cancel paths produce: the - // actor lives only in canceled_by (legacy cancelled_by is NULL). What - // Postgres would actually return for COALESCE(canceled_by, cancelled_by) - // is the canceled_by value, so the mocked "cancelled_by" scan slot below - // carries that same value to stand in for the server-side COALESCE result. - rows := pgxmock.NewRows(cols).AddRow( - "plan-1", "exec-1", "canceled", 1, now, +// executionRowCols is the scan shape of every purchase_executions read. +var executionRowCols = []string{ + "plan_id", "execution_id", "status", "step_number", "scheduled_date", + "notification_sent", "approval_token", "recommendations", + "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", + "cloud_account_id", "source", "approved_by", "canceled_by", "capacity_percent", + "created_by_user_id", "retry_execution_id", "retry_attempt_n", + "approval_token_expires_at", + "executed_by_user_id", "executed_at", "pre_approval_skip_reason", + "idempotency_key", + "scheduled_execution_at", + "no_submission", "created_by_system", +} + +func canceledExecutionRow(t *testing.T, actor string) *pgxmock.Rows { + t.Helper() + recsJSON, err := json.Marshal([]RecommendationRecord{}) + require.NoError(t, err) + return pgxmock.NewRows(executionRowCols).AddRow( + "plan-1", "exec-1", StatusCanceled, 1, time.Now().Truncate(time.Second), sql.NullTime{}, "tok-123", recsJSON, 100.0, 200.0, sql.NullTime{}, "", sql.NullTime{}, - nil, "", nil, strPtr("cancelling-actor@example.com"), 100, + nil, "", nil, strPtr(actor), 100, nil, nil, 0, sql.NullTime{}, nil, sql.NullTime{}, nil, @@ -934,14 +912,63 @@ func TestPGXMock_GetExecutionByID_ProjectsCoalescedCancelledBy(t *testing.T) { sql.NullTime{}, false, false, ) - mock.ExpectQuery(`COALESCE\(canceled_by,\s*cancelled_by\)`). - WithArgs(pgxmock.AnyArg()). - WillReturnRows(rows) +} + +// TestPGXMock_ExecutionCancelActorUsesOnlyCanceledBy pins the code half of +// the 000089 contract (issue #47): reads, cancel transitions, SetCancelledBy +// and the full-row save use canceled_by alone and never name the legacy +// double-l column, so the follow-up migration can drop that column without +// breaking a build that runs this code. The matcher rejects any statement that +// mentions the legacy column, so each call fails against the pre-#47 queries +// (COALESCE projections, dual writes, the save writing only the legacy column). +// +// The save keeps an actor already on the row (COALESCE(existing, $17)): reads +// used COALESCE(canceled_by, cancelled_by) and the save wrote only the legacy +// column, so a save from a stale copy could never erase canceled_by. Writing +// $17 straight into canceled_by would let it. +func TestPGXMock_ExecutionCancelActorUsesOnlyCanceledBy(t *testing.T) { + legacyColumn := "cancel" + "led_by" + mock, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherFunc(func(expected, actual string) error { + if strings.Contains(actual, legacyColumn) { + return errors.New("statement still references the legacy cancel actor column: " + actual) + } + return pgxmock.QueryMatcherRegexp.Match(expected, actual) + }))) + require.NoError(t, err) + store := storeWith(mock) + ctx := context.Background() + mock.ExpectQuery(`approved_by, canceled_by, capacity_percent`). + WithArgs("exec-1"). + WillReturnRows(canceledExecutionRow(t, "reader@example.com")) exec, err := store.GetExecutionByID(ctx, "exec-1") require.NoError(t, err) - require.NotNil(t, exec.CancelledBy, "actor written to canceled_by must round-trip via the COALESCE read projection") - assert.Equal(t, "cancelling-actor@example.com", *exec.CancelledBy) + require.NotNil(t, exec.CancelledBy) + assert.Equal(t, "reader@example.com", *exec.CancelledBy) + + actor := "11111111-1111-1111-1111-111111111111" + mock.ExpectQuery(`(?s)SET status = \$2.*canceled_by = COALESCE\(\(SELECT email FROM users WHERE id = \$5\), canceled_by\)\s+WHERE`). + WithArgs(anyArgsCfg(7)...). + WillReturnRows(canceledExecutionRow(t, "canceler@example.com")) + returned, err := store.TransitionExecutionStatus(ctx, "exec-1", []string{"pending"}, StatusCanceled, &actor) + require.NoError(t, err) + require.NotNil(t, returned.CancelledBy) + assert.Equal(t, "canceler@example.com", *returned.CancelledBy) + + mock.ExpectExec(`SET canceled_by = \$2, updated_at = NOW\(\)`). + WithArgs("exec-1", "revoker@example.com"). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + require.NoError(t, store.SetCancelledBy(ctx, "exec-1", "revoker@example.com")) + + mock.ExpectBegin() + mock.ExpectExec(`(?s)approved_by, canceled_by, capacity_percent.*canceled_by = COALESCE\(purchase_executions\.canceled_by, \$17\)`). + WithArgs(anyArgsCfg(29)...). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + require.NoError(t, store.SavePurchaseExecution(ctx, &PurchaseExecution{ + ExecutionID: "exec-1", PlanID: "plan-1", Status: StatusCanceled, ScheduledDate: time.Now(), + })) + assert.NoError(t, mock.ExpectationsWereMet()) } @@ -3180,18 +3207,17 @@ func TestPGXMock_CancelPendingExchangesByOrigin_Ladder_WritesCanonicalStatus(t * } // TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus verifies that the -// cleanup DELETE includes 'canceled' (canonical) in the terminal-status filter, -// so Plans-canceled rows written by new code are not missed. -// Regression guard for the #1277 follow-up Defect 2 fix. +// cleanup DELETE includes 'canceled' in the terminal-status filter, so +// Plans-canceled rows are not missed. Regression guard for the #1277 +// follow-up Defect 2 fix. func TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus(t *testing.T) { mock := newMock(t) store := storeWith(mock) ctx := context.Background() - // The regex pins on both 'cancelled' (legacy) and 'canceled' (canonical) - // appearing together in the SQL. If either is missing the mock won't - // match, causing an "unexpected query" error from ExpectationsWereMet. - mock.ExpectExec(`'cancelled'.*'canceled'`). + // If 'canceled' is missing the mock won't match, causing an + // "unexpected query" error from ExpectationsWereMet. + mock.ExpectExec(`status = 'canceled'`). WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("DELETE", 4)) @@ -3213,7 +3239,7 @@ func TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus(t *testing.T) { // regressed instead of just "the SQL changed". func TestPGXMock_CleanupOldExecutions_RetainsCanceledOnUpdatedAt(t *testing.T) { anchors := map[string]string{ - "canceled branch retains on updated_at": `(?s)status IN \('cancelled', 'canceled'\)\s+AND updated_at`, + "canceled branch retains on updated_at": `(?s)status = 'canceled'\s+AND updated_at`, "completed branch retains on scheduled_date": `(?s)status = 'completed'\s+AND scheduled_date`, "expires_at branch skips scored statuses": `(?s)status <> ALL\(\$2\)\s+AND expires_at IS NOT NULL`, } @@ -3259,7 +3285,7 @@ func TestPGXMock_CleanupOldExecutions_ExcludesEveryHealthScoredStatus(t *testing assert.NoError(t, mock.ExpectationsWereMet()) // Guards the slice's contents independently of how the query uses it. assert.ElementsMatch(t, - []string{StatusFailed, StatusCanceled, LegacyStatusCanceled}, + []string{StatusFailed, StatusCanceled}, HealthScoredExecutionStatuses) } @@ -3282,13 +3308,13 @@ func TestPGXMock_CountExecutionsByPlanAndStatus_AggregatesInSQL(t *testing.T) { rows := pgxmock.NewRows([]string{"plan_id", "status", "count"}). AddRow("plan-a", "failed", 7). AddRow("plan-a", StatusCanceled, 2). - AddRow("plan-b", LegacyStatusCanceled, 1) + AddRow("plan-b", StatusCanceled, 1) mock.ExpectQuery(`(?s)SELECT plan_id, status, COUNT\(\*\).*FROM purchase_executions.*status = ANY\(\$1\).*plan_id IS NOT NULL.*updated_at >= \$2.*GROUP BY plan_id, status`). WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg()). WillReturnRows(rows) counts, err := store.CountExecutionsByPlanAndStatus(ctx, - []string{"failed", StatusCanceled, LegacyStatusCanceled}, + []string{"failed", StatusCanceled}, time.Now().AddDate(0, 0, -90)) require.NoError(t, err) @@ -3296,7 +3322,7 @@ func TestPGXMock_CountExecutionsByPlanAndStatus_AggregatesInSQL(t *testing.T) { // though DefaultListLimit-style paging would be the temptation here. assert.Equal(t, 7, counts["plan-a"]["failed"]) assert.Equal(t, 2, counts["plan-a"][StatusCanceled]) - assert.Equal(t, 1, counts["plan-b"][LegacyStatusCanceled]) + assert.Equal(t, 1, counts["plan-b"][StatusCanceled]) // Absent statuses read as a zero count rather than needing a guard. assert.Equal(t, 0, counts["plan-b"]["failed"]) assert.Equal(t, 0, counts["plan-missing"]["failed"]) diff --git a/internal/config/store_postgres_plan_schedule_clock.go b/internal/config/store_postgres_plan_schedule_clock.go index 1725ee56d..0c0261d57 100644 --- a/internal/config/store_postgres_plan_schedule_clock.go +++ b/internal/config/store_postgres_plan_schedule_clock.go @@ -129,7 +129,7 @@ func managedPlanNextRoot(plan *PurchasePlan, rows []PurchaseExecution) (*Purchas if row.StepNumber != plan.RampSchedule.CurrentStep+1 { continue } - if row.Status == StatusCanceled || row.Status == LegacyStatusCanceled { + if row.Status == StatusCanceled { blocked = blocked || managedRootPriorAction(row) continue } diff --git a/internal/config/store_postgres_ramp_baseline.go b/internal/config/store_postgres_ramp_baseline.go index 40e76a31a..87d985b51 100644 --- a/internal/config/store_postgres_ramp_baseline.go +++ b/internal/config/store_postgres_ramp_baseline.go @@ -131,7 +131,7 @@ func (s *PostgresStore) ListPlanCommittedTx(ctx context.Context, tx pgx.Tx, plan // capacity. See ListPlanCommittedTx for the rule. func holdsCapacity(e *PurchaseExecution) bool { switch e.Status { - case StatusCanceled, LegacyStatusCanceled, StatusExpired: + case StatusCanceled, StatusExpired: return false case StatusFailed: return !e.NoSubmission diff --git a/internal/config/store_postgres_ramp_baseline_integration_test.go b/internal/config/store_postgres_ramp_baseline_integration_test.go index f37fcd1f9..00747bcee 100644 --- a/internal/config/store_postgres_ramp_baseline_integration_test.go +++ b/internal/config/store_postgres_ramp_baseline_integration_test.go @@ -71,9 +71,8 @@ func TestListPlanCommittedTx_RealRowShapes(t *testing.T) { require.NoError(t, f.store.SavePurchaseExecution(ctx, retry)) f.rowWithRecs(ctx, t, 2, a2, StatusCompleted, false, r2) - // Step 3: canceled, legacy-canceled and a failed row proven unsubmitted hold nothing. + // Step 3: canceled and a failed row proven unsubmitted hold nothing. f.rowWithRecs(ctx, t, 3, a1, StatusCanceled, false, r1) - f.rowWithRecs(ctx, t, 3, a1, LegacyStatusCanceled, false, r1) f.rowWithRecs(ctx, t, 3, a2, StatusFailed, true, r2) got := f.committed(ctx, t) diff --git a/internal/config/store_postgres_ramp_baseline_test.go b/internal/config/store_postgres_ramp_baseline_test.go index d9cda88d9..39e6a98b7 100644 --- a/internal/config/store_postgres_ramp_baseline_test.go +++ b/internal/config/store_postgres_ramp_baseline_test.go @@ -41,7 +41,7 @@ func TestSumPlanCommitted_StatusSet(t *testing.T) { assert.Equal(t, 20, got[key].Count, "status %q holds capacity", status) } - for _, status := range []string{StatusCanceled, LegacyStatusCanceled, StatusExpired} { + for _, status := range []string{StatusCanceled, StatusExpired} { got, err := sumPlanCommitted([]PurchaseExecution{baselineExec("e", 1, status, rec)}) require.NoError(t, err) assert.Empty(t, got, "status %q holds nothing", status) diff --git a/internal/config/store_postgres_ramp_step_integration_test.go b/internal/config/store_postgres_ramp_step_integration_test.go index 1e18b71c0..00ff0b021 100644 --- a/internal/config/store_postgres_ramp_step_integration_test.go +++ b/internal/config/store_postgres_ramp_step_integration_test.go @@ -544,7 +544,7 @@ func TestRampStepStatusListsPartitionEveryWrittenStatus(t *testing.T) { written := []string{ "pending", "notified", "scheduled", "approved", "running", "paused", StatusCompleted, StatusPartiallyCompleted, StatusFailed, StatusExpired, - StatusCanceled, LegacyStatusCanceled, + StatusCanceled, } // In-flight statuses hold a step open on purpose and are deliberately not // reported as stuck; they are listed here so adding a status to the schema diff --git a/internal/config/store_postgres_retention_integration_test.go b/internal/config/store_postgres_retention_integration_test.go index 49b698a5a..b4fbb4e91 100644 --- a/internal/config/store_postgres_retention_integration_test.go +++ b/internal/config/store_postgres_retention_integration_test.go @@ -30,7 +30,6 @@ func TestRampRetention_OldCleanupWritersPreserveHeldRows(t *testing.T) { {StatusFailed, false, `[]`}, {StatusFailed, true, `[]`}, {StatusCanceled, false, `[]`}, - {LegacyStatusCanceled, false, `[]`}, {StatusExpired, false, `[]`}, } for _, tc := range cases { @@ -48,7 +47,7 @@ func TestRampRetention_OldCleanupWritersPreserveHeldRows(t *testing.T) { require.Equal(t, holdsCapacity(&PurchaseExecution{Status: "future-status"}), future) deleted, err := f.store.CleanupOldExecutions(ctx, 30) require.NoError(t, err) - require.EqualValues(t, 3, deleted, "only canceled spellings and expired are eligible and unheld") + require.EqualValues(t, 2, deleted, "only canceled and expired are eligible and unheld") // This is the other production writer, cmd/cleanup-lambda/main.go. tag, err := f.store.db.Exec(ctx, `DELETE FROM purchase_executions WHERE expires_at < NOW()`) require.NoError(t, err) diff --git a/internal/config/types.go b/internal/config/types.go index 4b43d6a0f..88389415a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -439,18 +439,12 @@ type PurchaseExecution struct { NoSubmission bool `json:"no_submission,omitempty" dynamodbav:"no_submission,omitempty"` } -// StatusCanceled is the canonical US-spelling status value new code writes. +// StatusCanceled is the only canceled status value. Migration 000144 (the +// contract half of the 000089 rename) normalized every legacy British-spelled +// row to it and narrowed the status CHECK constraints so no other spelling can +// be stored. const StatusCanceled = "canceled" -// LegacyStatusCanceled is the British-spelling status value old code writes -// during the expand-contract rename (migration 000089). It is constructed by -// concatenation rather than a single literal so the US-locale misspell linter -// does not flag it -- this lets the dual-spelling read paths reference the -// legacy value without a //nolint:misspell directive. The CONTRACT migration -// (#1278) normalizes all rows to StatusCanceled once old code is gone, after -// which every reference to this constant can be deleted. -const LegacyStatusCanceled = "cancel" + "led" - // StatusFailed is the terminal status written when an execution's purchase // attempt errors out, and by the stuck-purchase reaper (internal/purchase/ // reaper.go) for rows left in approved/running past its threshold. @@ -487,8 +481,6 @@ const StatusExpired = "expired" // Canceled counts as settled but neither succeeded nor stuck: to cancel a // step's row is an operator deciding that unit will not buy, and that decision // is what releases a ramp an unrecoverable account would otherwise freeze. -// Both spellings are listed for the duration of the expand-contract rename -// (migration 000089, contract in #1278), same as HealthScoredExecutionStatuses. // // RampStepSucceededStatuses is the same predicate migration 000098 spells out // inline as status IN ('completed','partially_completed') when it decides which @@ -498,7 +490,7 @@ const StatusExpired = "expired" // "bought" means. var ( RampStepSucceededStatuses = []string{StatusCompleted, StatusPartiallyCompleted} - RampStepSettledStatuses = []string{StatusCompleted, StatusPartiallyCompleted, StatusCanceled, LegacyStatusCanceled} + RampStepSettledStatuses = []string{StatusCompleted, StatusPartiallyCompleted, StatusCanceled} RampStepStuckStatuses = []string{StatusFailed, StatusExpired} ) @@ -532,13 +524,9 @@ type RampStepBlock struct { // Keeping one exported slice rather than a literal list on each side is what // makes that agreement structural: adding a status to the score // automatically extends the sweep's exclusion, so the two cannot drift. -// -// Both spellings of canceled are present for the duration of the -// expand-contract rename (migration 000089, contract in #1278). var HealthScoredExecutionStatuses = []string{ StatusFailed, StatusCanceled, - LegacyStatusCanceled, } // StatusRevocationRequested records that an operator asked for a completed @@ -568,7 +556,6 @@ var TerminalExecutionStatuses = []string{ StatusPartiallyCompleted, StatusFailed, StatusCanceled, - LegacyStatusCanceled, StatusExpired, StatusRevocationRequested, } diff --git a/internal/database/postgres/migrations/000144_contract_canceled_spelling.down.sql b/internal/database/postgres/migrations/000144_contract_canceled_spelling.down.sql new file mode 100644 index 000000000..6bb808a17 --- /dev/null +++ b/internal/database/postgres/migrations/000144_contract_canceled_spelling.down.sql @@ -0,0 +1,33 @@ +-- Rollback 000144: back to the 000089 EXPAND state. +-- +-- Widen both status CHECKs again first, so nothing the data steps below touch +-- can violate them. Rows stay 'canceled': the widened CHECKs accept both +-- spellings and every build since #1277 reads both, so converting them back is +-- unnecessary. +-- +-- Then drop the mirror trigger and copy canceled_by into the legacy +-- cancelled_by wherever it is missing, so the column holds what a build that +-- still dual-writes it would have left there. + +LOCK TABLE purchase_executions, ri_exchange_history IN ACCESS EXCLUSIVE MODE; + +ALTER TABLE purchase_executions DROP CONSTRAINT IF EXISTS purchase_executions_status_check; +ALTER TABLE purchase_executions ADD CONSTRAINT purchase_executions_status_check + CHECK (status IN ( + 'pending','notified','approved','running','completed', + 'partially_completed','failed', + 'cancelled','canceled', + 'expired','paused','revocation_requested','scheduled' + )); + +ALTER TABLE ri_exchange_history DROP CONSTRAINT IF EXISTS ri_exchange_history_status_check; +ALTER TABLE ri_exchange_history ADD CONSTRAINT ri_exchange_history_status_check + CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'cancelled', 'canceled')); + +DROP TRIGGER IF EXISTS purchase_executions_mirror_legacy_cancel_actor ON purchase_executions; +DROP FUNCTION IF EXISTS purchase_executions_mirror_legacy_cancel_actor(); + +UPDATE purchase_executions +SET cancelled_by = canceled_by +WHERE cancelled_by IS NULL + AND canceled_by IS NOT NULL; diff --git a/internal/database/postgres/migrations/000144_contract_canceled_spelling.up.sql b/internal/database/postgres/migrations/000144_contract_canceled_spelling.up.sql new file mode 100644 index 000000000..37e946132 --- /dev/null +++ b/internal/database/postgres/migrations/000144_contract_canceled_spelling.up.sql @@ -0,0 +1,70 @@ +-- Migration 000144: CONTRACT half of the 000089 'cancelled' -> 'canceled' +-- rename (issue #47), part 1 of 2. +-- +-- 000089 (EXPAND) widened both status CHECKs to accept both spellings, added +-- canceled_by next to the legacy cancelled_by, and left normalization to this +-- step. Every build since #1277 writes only 'canceled', so the legacy set of +-- status rows is final and can be normalized and forbidden here. +-- +-- The legacy cancelled_by column is NOT dropped yet. The build that runs this +-- migration still has old instances serving during a rolling deploy, and a +-- code-only rollback (rollback.yml) can bring the previous build back with no +-- schema change. That previous build reads COALESCE(canceled_by, cancelled_by) +-- and writes cancelled_by, so dropping the column now would break every +-- execution read on those instances. The build that ships this migration no +-- longer reads or writes cancelled_by; the column drop is a later migration, +-- shipped once no older build can run. +-- +-- Until then a trigger keeps canceled_by authoritative: whenever a write leaves +-- canceled_by NULL but sets cancelled_by (what an older build's +-- SavePurchaseExecution does), it copies cancelled_by into canceled_by. That +-- makes canceled_by equal to COALESCE(canceled_by, cancelled_by), the value +-- older builds read, so new code can read canceled_by alone. +-- +-- Concurrency: both tables are locked up front, so no writer can commit a row +-- between the normalize/drain UPDATEs and the narrowed CHECKs (which would fail +-- the migration) or between the drain and the trigger (which would leave an +-- undrained actor). Writers wait for the migration and then see the trigger and +-- the narrowed CHECKs. golang-migrate runs this file in one transaction. + +LOCK TABLE purchase_executions, ri_exchange_history IN ACCESS EXCLUSIVE MODE; + +-- 1. Mirror legacy actor writes into canceled_by from now on. +CREATE OR REPLACE FUNCTION purchase_executions_mirror_legacy_cancel_actor() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + NEW.canceled_by := NEW.cancelled_by; + RETURN NEW; +END $$; + +DROP TRIGGER IF EXISTS purchase_executions_mirror_legacy_cancel_actor ON purchase_executions; +CREATE TRIGGER purchase_executions_mirror_legacy_cancel_actor + BEFORE INSERT OR UPDATE ON purchase_executions + FOR EACH ROW + WHEN (NEW.canceled_by IS NULL AND NEW.cancelled_by IS NOT NULL) + EXECUTE FUNCTION purchase_executions_mirror_legacy_cancel_actor(); + +-- 2. Normalize the data BEFORE narrowing, or the narrowed CHECKs reject the +-- surviving legacy rows. +UPDATE purchase_executions SET status = 'canceled' WHERE status = 'cancelled'; +UPDATE ri_exchange_history SET status = 'canceled' WHERE status = 'cancelled'; +UPDATE purchase_executions +SET canceled_by = cancelled_by +WHERE canceled_by IS NULL + AND cancelled_by IS NOT NULL; + +-- 3. Narrow purchase_executions: the 000089 list without 'cancelled'. +ALTER TABLE purchase_executions DROP CONSTRAINT IF EXISTS valid_status; +ALTER TABLE purchase_executions DROP CONSTRAINT IF EXISTS purchase_executions_status_check; +ALTER TABLE purchase_executions ADD CONSTRAINT purchase_executions_status_check + CHECK (status IN ( + 'pending','notified','approved','running','completed', + 'partially_completed','failed', + 'canceled', + 'expired','paused','revocation_requested','scheduled' + )); + +-- 4. Narrow ri_exchange_history: the 000089 list without 'cancelled'. +ALTER TABLE ri_exchange_history DROP CONSTRAINT IF EXISTS ri_exchange_history_status_check; +ALTER TABLE ri_exchange_history ADD CONSTRAINT ri_exchange_history_status_check + CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'canceled')); diff --git a/internal/database/postgres/migrations/000144_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000144_contract_canceled_spelling_test.go new file mode 100644 index 000000000..3da2100fe --- /dev/null +++ b/internal/database/postgres/migrations/000144_contract_canceled_spelling_test.go @@ -0,0 +1,243 @@ +//go:build integration + +package migrations_test + +import ( + "context" + "regexp" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The legacy spellings are built by concatenation so the US-locale misspell +// linter does not flag them; they are the values under test. +const ( + contract144 = 144 + legacyStatus144 = "cancel" + "led" + legacyActor144 = "cancel" + "led_by" +) + +// legacyInCheck144 matches the legacy spelling's entry in a status CHECK +// definition as pg_get_constraintdef renders it (the cast follows the column +// type: text or character varying). +var legacyInCheck144 = regexp.MustCompile(`'` + legacyStatus144 + `'::[a-z ]+, `) + +const insertLegacyExecution144 = ` + INSERT INTO purchase_executions (status, scheduled_date, canceled_by, ` + legacyActor144 + `) + VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` + +func insertExecution144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, canceledBy, legacyBy *string) string { + t.Helper() + var id string + require.NoError(t, pool.QueryRow(ctx, insertLegacyExecution144, status, canceledBy, legacyBy).Scan(&id)) + return id +} + +func insertExchange144(ctx context.Context, pool *pgxpool.Pool, status string) (string, error) { + var id string + err := pool.QueryRow(ctx, ` + INSERT INTO ri_exchange_history + (account_id, region, source_ri_ids, source_instance_type, source_count, + target_offering_id, target_instance_type, target_count, status) + VALUES ('123456789012', 'us-east-1', ARRAY['ri-1'], 'm5.large', 1, + 'offer-1', 'm5.xlarge', 1, $1) + RETURNING id::text`, status).Scan(&id) + return id, err +} + +func statusCheckDef144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { + t.Helper() + var def string + require.NoError(t, pool.QueryRow(ctx, + `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1`, constraint).Scan(&def)) + return def +} + +func actors144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, canceledBy, legacyBy *string) { + t.Helper() + require.NoError(t, pool.QueryRow(ctx, + `SELECT status, canceled_by, `+legacyActor144+` FROM purchase_executions WHERE execution_id = $1`, id, + ).Scan(&status, &canceledBy, &legacyBy)) + return status, canceledBy, legacyBy +} + +func requireCheckViolation144(t *testing.T, err error, constraint string) { + t.Helper() + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + assert.Equal(t, "23514", pgErr.Code) + assert.Equal(t, constraint, pgErr.ConstraintName) +} + +func s144(v string) *string { return &v } + +// TestMigration_000144_ContractCanceledSpelling locks down the contract half of +// the 000089 rename (issue #47): legacy status rows are normalized, legacy-only +// actors are drained into canceled_by, both status CHECKs lose exactly the +// legacy spelling, writes from an older build that still sets the legacy actor +// column keep canceled_by authoritative, and the down restores the 000089 state. +func TestMigration_000144_ContractCanceledSpelling(t *testing.T) { + ctx := context.Background() + container := testhelpers.RequirePostgresContainer(ctx, t) + t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) + pool, path := container.DB.Pool(), getMigrationsPath() + prev := previousMigrationVersion(t, contract144) + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) + peBefore := statusCheckDef144(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef144(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck144.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck144.FindAllString(riBefore, -1), 1, riBefore) + + legacyOnly := insertExecution144(ctx, t, pool, legacyStatus144, nil, s144("legacy@example.com")) + // Already canonical status, actor only in the legacy column: only the + // drain reaches it (the status UPDATE does not touch the row). + drainOnly := insertExecution144(ctx, t, pool, canceled144, nil, s144("drain@example.com")) + both := insertExecution144(ctx, t, pool, canceled144, s144("new@example.com"), s144("new@example.com")) + conflicting := insertExecution144(ctx, t, pool, legacyStatus144, s144("kept@example.com"), s144("other@example.com")) + failed := insertExecution144(ctx, t, pool, "failed", nil, nil) + exchange, err := insertExchange144(ctx, pool, legacyStatus144) + require.NoError(t, err) + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract144)) + + // Data: every legacy status normalized, every legacy-only actor drained, + // canceled_by kept where both columns were set, other rows untouched. + for id, want := range map[string]struct{ status, actor string }{ + legacyOnly: {canceled144, "legacy@example.com"}, + drainOnly: {canceled144, "drain@example.com"}, + both: {canceled144, "new@example.com"}, + conflicting: {canceled144, "kept@example.com"}, + } { + status, canceledBy, _ := actors144(ctx, t, pool, id) + assert.Equal(t, want.status, status, id) + require.NotNil(t, canceledBy, id) + assert.Equal(t, want.actor, *canceledBy, id) + } + status, canceledBy, _ := actors144(ctx, t, pool, failed) + assert.Equal(t, "failed", status) + assert.Nil(t, canceledBy) + var exchangeStatus string + require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchange).Scan(&exchangeStatus)) + assert.Equal(t, canceled144, exchangeStatus) + + // Schema: each CHECK is its 000089 definition minus the legacy spelling, + // nothing else, and rejects the legacy spelling. + assert.Equal(t, legacyInCheck144.ReplaceAllString(peBefore, ""), + statusCheckDef144(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck144.ReplaceAllString(riBefore, ""), + statusCheckDef144(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertLegacyExecution144, legacyStatus144, nil, nil) + requireCheckViolation144(t, err, "purchase_executions_status_check") + _, err = insertExchange144(ctx, pool, legacyStatus144) + requireCheckViolation144(t, err, "ri_exchange_history_status_check") + + // An older build still writes the legacy actor column (its save path + // writes only that column). canceled_by must end up with the value that + // build reads, COALESCE(canceled_by, legacy), on insert and on update. + lateInsert := insertExecution144(ctx, t, pool, canceled144, nil, s144("late@example.com")) + _, canceledBy, _ = actors144(ctx, t, pool, lateInsert) + require.NotNil(t, canceledBy, "a legacy-only insert must be mirrored into canceled_by") + assert.Equal(t, "late@example.com", *canceledBy) + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor144+` = 'late-update@example.com' WHERE execution_id = $1`, failed) + require.NoError(t, err) + _, canceledBy, _ = actors144(ctx, t, pool, failed) + require.NotNil(t, canceledBy, "a legacy-only update must be mirrored into canceled_by") + assert.Equal(t, "late-update@example.com", *canceledBy) + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor144+` = 'stale@example.com' WHERE execution_id = $1`, both) + require.NoError(t, err) + _, canceledBy, _ = actors144(ctx, t, pool, both) + assert.Equal(t, "new@example.com", *canceledBy, "a legacy write never overrides an actor already in canceled_by") + + // New code writes canceled_by only. + newOnly := insertExecution144(ctx, t, pool, canceled144, s144("current@example.com"), nil) + + // Down: back to the 000089 CHECKs, trigger gone, legacy column backfilled. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) + assert.Equal(t, peBefore, statusCheckDef144(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef144(ctx, t, pool, "ri_exchange_history_status_check")) + insertExecution144(ctx, t, pool, legacyStatus144, nil, nil) + _, err = insertExchange144(ctx, pool, legacyStatus144) + require.NoError(t, err, "down must accept the legacy spelling again") + _, _, legacyBy := actors144(ctx, t, pool, newOnly) + require.NotNil(t, legacyBy, "down must backfill the legacy actor column") + assert.Equal(t, "current@example.com", *legacyBy) + afterDown := insertExecution144(ctx, t, pool, canceled144, nil, s144("x@example.com")) + _, canceledBy, _ = actors144(ctx, t, pool, afterDown) + assert.Nil(t, canceledBy, "down must drop the mirror trigger") + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract144), "up must re-apply cleanly after down") +} + +// canceled144 is the canonical spelling, kept local so this frozen +// migration test does not move if the config constant ever does. +const canceled144 = "canceled" + +// TestMigration_000144_ConcurrentLegacyWritersKeepAttribution races writers +// shaped like an older build (legacy actor column only) against the migration. +// Whichever side of the migration a write lands on, canceled_by must hold its +// actor afterwards: rows committed before the migration are drained, rows +// written after it are mirrored by the trigger, and the table lock leaves no +// gap between the two. +func TestMigration_000144_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + container := testhelpers.RequirePostgresContainer(ctx, t) + t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) + pool, path := container.DB.Pool(), getMigrationsPath() + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, previousMigrationVersion(t, contract144))) + + var migrated atomic.Bool + var afterMigration atomic.Int64 + stop := make(chan struct{}) + errs := make(chan error, 8) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + done := migrated.Load() + if _, err := pool.Exec(ctx, insertLegacyExecution144, canceled144, nil, "racer@example.com"); err != nil { + errs <- err + return + } + if done { + afterMigration.Add(1) + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract144)) + migrated.Store(true) + require.Eventually(t, func() bool { return afterMigration.Load() >= 20 }, time.Minute, 10*time.Millisecond) + close(stop) + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err, "a legacy writer must not fail across the migration") + } + + var total, undrained int + require.NoError(t, pool.QueryRow(ctx, ` + SELECT count(*), count(*) FILTER (WHERE canceled_by IS NULL) + FROM purchase_executions WHERE `+legacyActor144+` IS NOT NULL`).Scan(&total, &undrained)) + require.Positive(t, total) + assert.Zero(t, undrained, "every legacy-only actor must be in canceled_by, of %d rows", total) +} diff --git a/internal/purchase/ladder_reconcile.go b/internal/purchase/ladder_reconcile.go index 509783241..abdc09145 100644 --- a/internal/purchase/ladder_reconcile.go +++ b/internal/purchase/ladder_reconcile.go @@ -80,7 +80,7 @@ func decideFiredTranche(chain []config.LadderChainNode, visible func(executionID return ladderVerdict{note: "failed without a no_submission proof (outcome unknown)"} } return failedIfPredecessorsProven(chain, ladderReasonNoSubmission) - case config.StatusCanceled, config.LegacyStatusCanceled: + case config.StatusCanceled: return failedIfPredecessorsProven(chain, ladderReasonApprovalCanceled) case config.StatusExpired: return failedIfPredecessorsProven(chain, ladderReasonApprovalExpired) diff --git a/internal/purchase/ladder_reconcile_integration_test.go b/internal/purchase/ladder_reconcile_integration_test.go index 36cbe15a1..7b9f32c81 100644 --- a/internal/purchase/ladder_reconcile_integration_test.go +++ b/internal/purchase/ladder_reconcile_integration_test.go @@ -127,7 +127,6 @@ func TestReconcileLadderTranches_Mappings(t *testing.T) { {"failed without proof (reaper / outcome unknown) keeps netting", "failed", false, true, "fired", ""}, {"proof flag on a running row is ignored", "running", true, true, "fired", ""}, {"canceled approval", "canceled", false, true, "failed", "approval_canceled"}, - {"legacy canceled spelling", config.LegacyStatusCanceled, false, true, "failed", "approval_canceled"}, {"expired approval", "expired", false, true, "failed", "approval_expired"}, {"partially completed stays fired", "partially_completed", false, true, "fired", ""}, {"completed and visible", "completed", false, true, "completed", "commitment_visible"}, diff --git a/internal/purchase/ladder_reconcile_test.go b/internal/purchase/ladder_reconcile_test.go index dae1b24c8..33536f543 100644 --- a/internal/purchase/ladder_reconcile_test.go +++ b/internal/purchase/ladder_reconcile_test.go @@ -33,7 +33,6 @@ func TestDecideFiredTranche(t *testing.T) { {"first attempt reaped, later attempts proven", []config.LadderChainNode{chainNode("e1", "failed", false), chainNode("e2", "failed", true), chainNode("e3", "failed", true)}, visibleAlways, "", ""}, {"every attempt proven", []config.LadderChainNode{chainNode("e1", "failed", true), chainNode("e2", "failed", true)}, visibleAlways, ladder.TrancheStatusFailed, "no_submission"}, {"canceled tail", []config.LadderChainNode{chainNode("e1", "canceled", false)}, visibleAlways, ladder.TrancheStatusFailed, "approval_canceled"}, - {"legacy spelling tail", []config.LadderChainNode{chainNode("e1", config.LegacyStatusCanceled, false)}, visibleAlways, ladder.TrancheStatusFailed, "approval_canceled"}, {"expired tail after unproven attempt", []config.LadderChainNode{chainNode("e1", "failed", false), chainNode("e2", "expired", false)}, visibleAlways, "", ""}, {"proof flag ignored on a non-failed row", []config.LadderChainNode{chainNode("e1", "running", true)}, visibleAlways, "", ""}, {"partially completed", []config.LadderChainNode{chainNode("e1", "partially_completed", false)}, visibleAlways, "", ""}, From fb1d6549dd32625b12e64659543d5044573d49c4 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sun, 25 Oct 2026 16:11:47 +0000 Subject: [PATCH 02/19] 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 --- .../config/store_postgres_history_summary_integration_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/store_postgres_history_summary_integration_test.go b/internal/config/store_postgres_history_summary_integration_test.go index a7afb7774..9b8204e12 100644 --- a/internal/config/store_postgres_history_summary_integration_test.go +++ b/internal/config/store_postgres_history_summary_integration_test.go @@ -80,7 +80,7 @@ func (f *historySummaryFixture) seed(ctx context.Context, t *testing.T) { recA := []RecommendationRecord{{Provider: "aws", CloudAccountID: &a}} mixed := []RecommendationRecord{{Provider: "aws", CloudAccountID: &a}, {Provider: "azure", CloudAccountID: &b}} statuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused", "failed", "expired", - StatusCanceled, LegacyStatusCanceled, "partially_completed"} + StatusCanceled, "partially_completed"} for i, st := range statuses { at := f.base.Add(time.Duration(i) * 36 * time.Hour) f.saveExecution(ctx, t, at, st, "", &a, nil, recA) @@ -188,7 +188,7 @@ func TestIntegration_HistorySummaryAggregate_MatchesRowFetch(t *testing.T) { "all": {Provider: "aws", AccountIDs: []string{f.accountA}, Start: &start, End: &end}, } statuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused", - "completed", "partially_completed", "failed", "expired", StatusCanceled, LegacyStatusCanceled} + "completed", "partially_completed", "failed", "expired", StatusCanceled} for name, filter := range filters { t.Run(name, func(t *testing.T) { agg, err := f.store.HistorySummaryAggregate(ctx, HistorySummaryFilter{ From 9a538afaba3c746b6aec0b7de319295d464b461d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 28 Oct 2026 00:19:39 +0000 Subject: [PATCH 03/19] 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 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5fb3a2f61..abcf4fb6c 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.58.0 - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 // indirect diff --git a/go.sum b/go.sum index 06deac0b9..83df52e66 100644 --- a/go.sum +++ b/go.sum @@ -367,8 +367,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= From 45aedb8483327ad22b5c1cdf09973c660a5c2488 Mon Sep 17 00:00:00 2001 From: Buddy Date: Wed, 28 Oct 2026 02:30:19 +0000 Subject: [PATCH 04/19] test(migrations): target renumbered canceled spelling migration --- .../000151_contract_canceled_spelling_test.go | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/internal/database/postgres/migrations/000151_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000151_contract_canceled_spelling_test.go index 9f35e1ea2..2308a1719 100644 --- a/internal/database/postgres/migrations/000151_contract_canceled_spelling_test.go +++ b/internal/database/postgres/migrations/000151_contract_canceled_spelling_test.go @@ -21,28 +21,28 @@ import ( // The legacy spellings are built by concatenation so the US-locale misspell // linter does not flag them; they are the values under test. const ( - contract144 = 144 - legacyStatus144 = "cancel" + "led" - legacyActor144 = "cancel" + "led_by" + contract151 = 151 + legacyStatus151 = "cancel" + "led" + legacyActor151 = "cancel" + "led_by" ) -// legacyInCheck144 matches the legacy spelling's entry in a status CHECK +// legacyInCheck151 matches the legacy spelling's entry in a status CHECK // definition as pg_get_constraintdef renders it (the cast follows the column // type: text or character varying). -var legacyInCheck144 = regexp.MustCompile(`'` + legacyStatus144 + `'::[a-z ]+, `) +var legacyInCheck151 = regexp.MustCompile(`'` + legacyStatus151 + `'::[a-z ]+, `) -const insertLegacyExecution144 = ` - INSERT INTO purchase_executions (status, scheduled_date, canceled_by, ` + legacyActor144 + `) +const insertLegacyExecution151 = ` + INSERT INTO purchase_executions (status, scheduled_date, canceled_by, ` + legacyActor151 + `) VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` -func insertExecution144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, canceledBy, legacyBy *string) string { +func insertExecution151(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, canceledBy, legacyBy *string) string { t.Helper() var id string - require.NoError(t, pool.QueryRow(ctx, insertLegacyExecution144, status, canceledBy, legacyBy).Scan(&id)) + require.NoError(t, pool.QueryRow(ctx, insertLegacyExecution151, status, canceledBy, legacyBy).Scan(&id)) return id } -func insertExchange144(ctx context.Context, pool *pgxpool.Pool, status string) (string, error) { +func insertExchange151(ctx context.Context, pool *pgxpool.Pool, status string) (string, error) { var id string err := pool.QueryRow(ctx, ` INSERT INTO ri_exchange_history @@ -54,7 +54,7 @@ func insertExchange144(ctx context.Context, pool *pgxpool.Pool, status string) ( return id, err } -func statusCheckDef144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { +func statusCheckDef151(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { t.Helper() var def string require.NoError(t, pool.QueryRow(ctx, @@ -62,15 +62,15 @@ func statusCheckDef144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, co return def } -func actors144(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, canceledBy, legacyBy *string) { +func actors151(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, canceledBy, legacyBy *string) { t.Helper() require.NoError(t, pool.QueryRow(ctx, - `SELECT status, canceled_by, `+legacyActor144+` FROM purchase_executions WHERE execution_id = $1`, id, + `SELECT status, canceled_by, `+legacyActor151+` FROM purchase_executions WHERE execution_id = $1`, id, ).Scan(&status, &canceledBy, &legacyBy)) return status, canceledBy, legacyBy } -func requireCheckViolation144(t *testing.T, err error, constraint string) { +func requireCheckViolation151(t *testing.T, err error, constraint string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) @@ -78,7 +78,7 @@ func requireCheckViolation144(t *testing.T, err error, constraint string) { assert.Equal(t, constraint, pgErr.ConstraintName) } -func s144(v string) *string { return &v } +func s151(v string) *string { return &v } // TestMigration_000151_ContractCanceledSpelling locks down the contract half of // the 000089 rename (issue #47): legacy status rows are normalized, legacy-only @@ -90,97 +90,97 @@ func TestMigration_000151_ContractCanceledSpelling(t *testing.T) { container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - prev := previousMigrationVersion(t, contract144) + prev := previousMigrationVersion(t, contract151) require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - peBefore := statusCheckDef144(ctx, t, pool, "purchase_executions_status_check") - riBefore := statusCheckDef144(ctx, t, pool, "ri_exchange_history_status_check") - require.Len(t, legacyInCheck144.FindAllString(peBefore, -1), 1, peBefore) - require.Len(t, legacyInCheck144.FindAllString(riBefore, -1), 1, riBefore) + peBefore := statusCheckDef151(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef151(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck151.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck151.FindAllString(riBefore, -1), 1, riBefore) - legacyOnly := insertExecution144(ctx, t, pool, legacyStatus144, nil, s144("legacy@example.com")) + legacyOnly := insertExecution151(ctx, t, pool, legacyStatus151, nil, s151("legacy@example.com")) // Already canonical status, actor only in the legacy column: only the // drain reaches it (the status UPDATE does not touch the row). - drainOnly := insertExecution144(ctx, t, pool, canceled144, nil, s144("drain@example.com")) - both := insertExecution144(ctx, t, pool, canceled144, s144("new@example.com"), s144("new@example.com")) - conflicting := insertExecution144(ctx, t, pool, legacyStatus144, s144("kept@example.com"), s144("other@example.com")) - failed := insertExecution144(ctx, t, pool, "failed", nil, nil) - exchange, err := insertExchange144(ctx, pool, legacyStatus144) + drainOnly := insertExecution151(ctx, t, pool, canceled151, nil, s151("drain@example.com")) + both := insertExecution151(ctx, t, pool, canceled151, s151("new@example.com"), s151("new@example.com")) + conflicting := insertExecution151(ctx, t, pool, legacyStatus151, s151("kept@example.com"), s151("other@example.com")) + failed := insertExecution151(ctx, t, pool, "failed", nil, nil) + exchange, err := insertExchange151(ctx, pool, legacyStatus151) require.NoError(t, err) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract144)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract151)) // Data: every legacy status normalized, every legacy-only actor drained, // canceled_by kept where both columns were set, other rows untouched. for id, want := range map[string]struct{ status, actor string }{ - legacyOnly: {canceled144, "legacy@example.com"}, - drainOnly: {canceled144, "drain@example.com"}, - both: {canceled144, "new@example.com"}, - conflicting: {canceled144, "kept@example.com"}, + legacyOnly: {canceled151, "legacy@example.com"}, + drainOnly: {canceled151, "drain@example.com"}, + both: {canceled151, "new@example.com"}, + conflicting: {canceled151, "kept@example.com"}, } { - status, canceledBy, _ := actors144(ctx, t, pool, id) + status, canceledBy, _ := actors151(ctx, t, pool, id) assert.Equal(t, want.status, status, id) require.NotNil(t, canceledBy, id) assert.Equal(t, want.actor, *canceledBy, id) } - status, canceledBy, _ := actors144(ctx, t, pool, failed) + status, canceledBy, _ := actors151(ctx, t, pool, failed) assert.Equal(t, "failed", status) assert.Nil(t, canceledBy) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchange).Scan(&exchangeStatus)) - assert.Equal(t, canceled144, exchangeStatus) + assert.Equal(t, canceled151, exchangeStatus) // Schema: each CHECK is its 000089 definition minus the legacy spelling, // nothing else, and rejects the legacy spelling. - assert.Equal(t, legacyInCheck144.ReplaceAllString(peBefore, ""), - statusCheckDef144(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, legacyInCheck144.ReplaceAllString(riBefore, ""), - statusCheckDef144(ctx, t, pool, "ri_exchange_history_status_check")) - _, err = pool.Exec(ctx, insertLegacyExecution144, legacyStatus144, nil, nil) - requireCheckViolation144(t, err, "purchase_executions_status_check") - _, err = insertExchange144(ctx, pool, legacyStatus144) - requireCheckViolation144(t, err, "ri_exchange_history_status_check") + assert.Equal(t, legacyInCheck151.ReplaceAllString(peBefore, ""), + statusCheckDef151(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck151.ReplaceAllString(riBefore, ""), + statusCheckDef151(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertLegacyExecution151, legacyStatus151, nil, nil) + requireCheckViolation151(t, err, "purchase_executions_status_check") + _, err = insertExchange151(ctx, pool, legacyStatus151) + requireCheckViolation151(t, err, "ri_exchange_history_status_check") // An older build still writes the legacy actor column (its save path // writes only that column). canceled_by must end up with the value that // build reads, COALESCE(canceled_by, legacy), on insert and on update. - lateInsert := insertExecution144(ctx, t, pool, canceled144, nil, s144("late@example.com")) - _, canceledBy, _ = actors144(ctx, t, pool, lateInsert) + lateInsert := insertExecution151(ctx, t, pool, canceled151, nil, s151("late@example.com")) + _, canceledBy, _ = actors151(ctx, t, pool, lateInsert) require.NotNil(t, canceledBy, "a legacy-only insert must be mirrored into canceled_by") assert.Equal(t, "late@example.com", *canceledBy) - _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor144+` = 'late-update@example.com' WHERE execution_id = $1`, failed) + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor151+` = 'late-update@example.com' WHERE execution_id = $1`, failed) require.NoError(t, err) - _, canceledBy, _ = actors144(ctx, t, pool, failed) + _, canceledBy, _ = actors151(ctx, t, pool, failed) require.NotNil(t, canceledBy, "a legacy-only update must be mirrored into canceled_by") assert.Equal(t, "late-update@example.com", *canceledBy) - _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor144+` = 'stale@example.com' WHERE execution_id = $1`, both) + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor151+` = 'stale@example.com' WHERE execution_id = $1`, both) require.NoError(t, err) - _, canceledBy, _ = actors144(ctx, t, pool, both) + _, canceledBy, _ = actors151(ctx, t, pool, both) assert.Equal(t, "new@example.com", *canceledBy, "a legacy write never overrides an actor already in canceled_by") // New code writes canceled_by only. - newOnly := insertExecution144(ctx, t, pool, canceled144, s144("current@example.com"), nil) + newOnly := insertExecution151(ctx, t, pool, canceled151, s151("current@example.com"), nil) // Down: back to the 000089 CHECKs, trigger gone, legacy column backfilled. require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - assert.Equal(t, peBefore, statusCheckDef144(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, riBefore, statusCheckDef144(ctx, t, pool, "ri_exchange_history_status_check")) - insertExecution144(ctx, t, pool, legacyStatus144, nil, nil) - _, err = insertExchange144(ctx, pool, legacyStatus144) + assert.Equal(t, peBefore, statusCheckDef151(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef151(ctx, t, pool, "ri_exchange_history_status_check")) + insertExecution151(ctx, t, pool, legacyStatus151, nil, nil) + _, err = insertExchange151(ctx, pool, legacyStatus151) require.NoError(t, err, "down must accept the legacy spelling again") - _, _, legacyBy := actors144(ctx, t, pool, newOnly) + _, _, legacyBy := actors151(ctx, t, pool, newOnly) require.NotNil(t, legacyBy, "down must backfill the legacy actor column") assert.Equal(t, "current@example.com", *legacyBy) - afterDown := insertExecution144(ctx, t, pool, canceled144, nil, s144("x@example.com")) - _, canceledBy, _ = actors144(ctx, t, pool, afterDown) + afterDown := insertExecution151(ctx, t, pool, canceled151, nil, s151("x@example.com")) + _, canceledBy, _ = actors151(ctx, t, pool, afterDown) assert.Nil(t, canceledBy, "down must drop the mirror trigger") - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract144), "up must re-apply cleanly after down") + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract151), "up must re-apply cleanly after down") } -// canceled144 is the canonical spelling, kept local so this frozen +// canceled151 is the canonical spelling, kept local so this frozen // migration test does not move if the config constant ever does. -const canceled144 = "canceled" +const canceled151 = "canceled" // TestMigration_000151_ConcurrentLegacyWritersKeepAttribution races writers // shaped like an older build (legacy actor column only) against the migration. @@ -194,7 +194,7 @@ func TestMigration_000151_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, previousMigrationVersion(t, contract144))) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, previousMigrationVersion(t, contract151))) var migrated atomic.Bool var afterMigration atomic.Int64 @@ -212,7 +212,7 @@ func TestMigration_000151_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { default: } done := migrated.Load() - if _, err := pool.Exec(ctx, insertLegacyExecution144, canceled144, nil, "racer@example.com"); err != nil { + if _, err := pool.Exec(ctx, insertLegacyExecution151, canceled151, nil, "racer@example.com"); err != nil { errs <- err return } @@ -224,7 +224,7 @@ func TestMigration_000151_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { } time.Sleep(200 * time.Millisecond) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract144)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract151)) migrated.Store(true) require.Eventually(t, func() bool { return afterMigration.Load() >= 20 }, time.Minute, 10*time.Millisecond) close(stop) @@ -237,7 +237,7 @@ func TestMigration_000151_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { var total, undrained int require.NoError(t, pool.QueryRow(ctx, ` SELECT count(*), count(*) FILTER (WHERE canceled_by IS NULL) - FROM purchase_executions WHERE `+legacyActor144+` IS NOT NULL`).Scan(&total, &undrained)) + FROM purchase_executions WHERE `+legacyActor151+` IS NOT NULL`).Scan(&total, &undrained)) require.Positive(t, total) assert.Zero(t, undrained, "every legacy-only actor must be in canceled_by, of %d rows", total) } From a83844aadc44000c8adcf0733bccae713e9d85d2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 22:30:29 +0100 Subject: [PATCH 05/19] 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 --- ...00158_contract_canceled_spelling.down.sql} | 2 +- ... 000158_contract_canceled_spelling.up.sql} | 2 +- ...000158_contract_canceled_spelling_test.go} | 134 +++++++++--------- 3 files changed, 69 insertions(+), 69 deletions(-) rename internal/database/postgres/migrations/{000153_contract_canceled_spelling.down.sql => 000158_contract_canceled_spelling.down.sql} (96%) rename internal/database/postgres/migrations/{000153_contract_canceled_spelling.up.sql => 000158_contract_canceled_spelling.up.sql} (98%) rename internal/database/postgres/migrations/{000153_contract_canceled_spelling_test.go => 000158_contract_canceled_spelling_test.go} (63%) diff --git a/internal/database/postgres/migrations/000153_contract_canceled_spelling.down.sql b/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql similarity index 96% rename from internal/database/postgres/migrations/000153_contract_canceled_spelling.down.sql rename to internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql index fca0100de..99c1839ee 100644 --- a/internal/database/postgres/migrations/000153_contract_canceled_spelling.down.sql +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql @@ -1,4 +1,4 @@ --- Rollback 000153: back to the 000089 EXPAND state. +-- Rollback 000158: back to the 000089 EXPAND state. -- -- Widen both status CHECKs again first, so nothing the data steps below touch -- can violate them. Rows stay 'canceled': the widened CHECKs accept both diff --git a/internal/database/postgres/migrations/000153_contract_canceled_spelling.up.sql b/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql similarity index 98% rename from internal/database/postgres/migrations/000153_contract_canceled_spelling.up.sql rename to internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql index 86fb5ab3c..4debe72af 100644 --- a/internal/database/postgres/migrations/000153_contract_canceled_spelling.up.sql +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql @@ -1,4 +1,4 @@ --- Migration 000153: CONTRACT half of the 000089 'cancelled' -> 'canceled' +-- Migration 000158: CONTRACT half of the 000089 'cancelled' -> 'canceled' -- rename (issue #47), part 1 of 2. -- -- 000089 (EXPAND) widened both status CHECKs to accept both spellings, added diff --git a/internal/database/postgres/migrations/000153_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go similarity index 63% rename from internal/database/postgres/migrations/000153_contract_canceled_spelling_test.go rename to internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go index 8f93794c0..1b18ec4b0 100644 --- a/internal/database/postgres/migrations/000153_contract_canceled_spelling_test.go +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go @@ -21,28 +21,28 @@ import ( // The legacy spellings are built by concatenation so the US-locale misspell // linter does not flag them; they are the values under test. const ( - contract153 = 153 - legacyStatus153 = "cancel" + "led" - legacyActor153 = "cancel" + "led_by" + contract158 = 158 + legacyStatus158 = "cancel" + "led" + legacyActor158 = "cancel" + "led_by" ) -// legacyInCheck153 matches the legacy spelling's entry in a status CHECK +// legacyInCheck158 matches the legacy spelling's entry in a status CHECK // definition as pg_get_constraintdef renders it (the cast follows the column // type: text or character varying). -var legacyInCheck153 = regexp.MustCompile(`'` + legacyStatus153 + `'::[a-z ]+, `) +var legacyInCheck158 = regexp.MustCompile(`'` + legacyStatus158 + `'::[a-z ]+, `) -const insertLegacyExecution153 = ` - INSERT INTO purchase_executions (status, scheduled_date, canceled_by, ` + legacyActor153 + `) +const insertLegacyExecution158 = ` + INSERT INTO purchase_executions (status, scheduled_date, canceled_by, ` + legacyActor158 + `) VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` -func insertExecution153(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, canceledBy, legacyBy *string) string { +func insertExecution158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, canceledBy, legacyBy *string) string { t.Helper() var id string - require.NoError(t, pool.QueryRow(ctx, insertLegacyExecution153, status, canceledBy, legacyBy).Scan(&id)) + require.NoError(t, pool.QueryRow(ctx, insertLegacyExecution158, status, canceledBy, legacyBy).Scan(&id)) return id } -func insertExchange153(ctx context.Context, pool *pgxpool.Pool, status string) (string, error) { +func insertExchange158(ctx context.Context, pool *pgxpool.Pool, status string) (string, error) { var id string err := pool.QueryRow(ctx, ` INSERT INTO ri_exchange_history @@ -54,7 +54,7 @@ func insertExchange153(ctx context.Context, pool *pgxpool.Pool, status string) ( return id, err } -func statusCheckDef153(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { +func statusCheckDef158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { t.Helper() var def string require.NoError(t, pool.QueryRow(ctx, @@ -62,15 +62,15 @@ func statusCheckDef153(ctx context.Context, t *testing.T, pool *pgxpool.Pool, co return def } -func actors153(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, canceledBy, legacyBy *string) { +func actors158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, canceledBy, legacyBy *string) { t.Helper() require.NoError(t, pool.QueryRow(ctx, - `SELECT status, canceled_by, `+legacyActor153+` FROM purchase_executions WHERE execution_id = $1`, id, + `SELECT status, canceled_by, `+legacyActor158+` FROM purchase_executions WHERE execution_id = $1`, id, ).Scan(&status, &canceledBy, &legacyBy)) return status, canceledBy, legacyBy } -func requireCheckViolation153(t *testing.T, err error, constraint string) { +func requireCheckViolation158(t *testing.T, err error, constraint string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) @@ -78,123 +78,123 @@ func requireCheckViolation153(t *testing.T, err error, constraint string) { assert.Equal(t, constraint, pgErr.ConstraintName) } -func s153(v string) *string { return &v } +func s158(v string) *string { return &v } -// TestMigration_000153_ContractCanceledSpelling locks down the contract half of +// TestMigration_000158_ContractCanceledSpelling locks down the contract half of // the 000089 rename (issue #47): legacy status rows are normalized, legacy-only // actors are drained into canceled_by, both status CHECKs lose exactly the // legacy spelling, writes from an older build that still sets the legacy actor // column keep canceled_by authoritative, and the down restores the 000089 state. -func TestMigration_000153_ContractCanceledSpelling(t *testing.T) { +func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { ctx := context.Background() container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - prev := previousMigrationVersion(t, contract153) + prev := previousMigrationVersion(t, contract158) require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - peBefore := statusCheckDef153(ctx, t, pool, "purchase_executions_status_check") - riBefore := statusCheckDef153(ctx, t, pool, "ri_exchange_history_status_check") - require.Len(t, legacyInCheck153.FindAllString(peBefore, -1), 1, peBefore) - require.Len(t, legacyInCheck153.FindAllString(riBefore, -1), 1, riBefore) + peBefore := statusCheckDef158(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck158.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck158.FindAllString(riBefore, -1), 1, riBefore) - legacyOnly := insertExecution153(ctx, t, pool, legacyStatus153, nil, s153("legacy@example.com")) + legacyOnly := insertExecution158(ctx, t, pool, legacyStatus158, nil, s158("legacy@example.com")) // Already canonical status, actor only in the legacy column: only the // drain reaches it (the status UPDATE does not touch the row). - drainOnly := insertExecution153(ctx, t, pool, canceled153, nil, s153("drain@example.com")) - both := insertExecution153(ctx, t, pool, canceled153, s153("new@example.com"), s153("new@example.com")) - conflicting := insertExecution153(ctx, t, pool, legacyStatus153, s153("kept@example.com"), s153("other@example.com")) - failed := insertExecution153(ctx, t, pool, "failed", nil, nil) - exchange, err := insertExchange153(ctx, pool, legacyStatus153) + drainOnly := insertExecution158(ctx, t, pool, canceled158, nil, s158("drain@example.com")) + both := insertExecution158(ctx, t, pool, canceled158, s158("new@example.com"), s158("new@example.com")) + conflicting := insertExecution158(ctx, t, pool, legacyStatus158, s158("kept@example.com"), s158("other@example.com")) + failed := insertExecution158(ctx, t, pool, "failed", nil, nil) + exchange, err := insertExchange158(ctx, pool, legacyStatus158) require.NoError(t, err) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract153)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158)) // Data: every legacy status normalized, every legacy-only actor drained, // canceled_by kept where both columns were set, other rows untouched. for id, want := range map[string]struct{ status, actor string }{ - legacyOnly: {canceled153, "legacy@example.com"}, - drainOnly: {canceled153, "drain@example.com"}, - both: {canceled153, "new@example.com"}, - conflicting: {canceled153, "kept@example.com"}, + legacyOnly: {canceled158, "legacy@example.com"}, + drainOnly: {canceled158, "drain@example.com"}, + both: {canceled158, "new@example.com"}, + conflicting: {canceled158, "kept@example.com"}, } { - status, canceledBy, _ := actors153(ctx, t, pool, id) + status, canceledBy, _ := actors158(ctx, t, pool, id) assert.Equal(t, want.status, status, id) require.NotNil(t, canceledBy, id) assert.Equal(t, want.actor, *canceledBy, id) } - status, canceledBy, _ := actors153(ctx, t, pool, failed) + status, canceledBy, _ := actors158(ctx, t, pool, failed) assert.Equal(t, "failed", status) assert.Nil(t, canceledBy) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchange).Scan(&exchangeStatus)) - assert.Equal(t, canceled153, exchangeStatus) + assert.Equal(t, canceled158, exchangeStatus) // Schema: each CHECK is its 000089 definition minus the legacy spelling, // nothing else, and rejects the legacy spelling. - assert.Equal(t, legacyInCheck153.ReplaceAllString(peBefore, ""), - statusCheckDef153(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, legacyInCheck153.ReplaceAllString(riBefore, ""), - statusCheckDef153(ctx, t, pool, "ri_exchange_history_status_check")) - _, err = pool.Exec(ctx, insertLegacyExecution153, legacyStatus153, nil, nil) - requireCheckViolation153(t, err, "purchase_executions_status_check") - _, err = insertExchange153(ctx, pool, legacyStatus153) - requireCheckViolation153(t, err, "ri_exchange_history_status_check") + assert.Equal(t, legacyInCheck158.ReplaceAllString(peBefore, ""), + statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck158.ReplaceAllString(riBefore, ""), + statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertLegacyExecution158, legacyStatus158, nil, nil) + requireCheckViolation158(t, err, "purchase_executions_status_check") + _, err = insertExchange158(ctx, pool, legacyStatus158) + requireCheckViolation158(t, err, "ri_exchange_history_status_check") // An older build still writes the legacy actor column (its save path // writes only that column). canceled_by must end up with the value that // build reads, COALESCE(canceled_by, legacy), on insert and on update. - lateInsert := insertExecution153(ctx, t, pool, canceled153, nil, s153("late@example.com")) - _, canceledBy, _ = actors153(ctx, t, pool, lateInsert) + lateInsert := insertExecution158(ctx, t, pool, canceled158, nil, s158("late@example.com")) + _, canceledBy, _ = actors158(ctx, t, pool, lateInsert) require.NotNil(t, canceledBy, "a legacy-only insert must be mirrored into canceled_by") assert.Equal(t, "late@example.com", *canceledBy) - _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor153+` = 'late-update@example.com' WHERE execution_id = $1`, failed) + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor158+` = 'late-update@example.com' WHERE execution_id = $1`, failed) require.NoError(t, err) - _, canceledBy, _ = actors153(ctx, t, pool, failed) + _, canceledBy, _ = actors158(ctx, t, pool, failed) require.NotNil(t, canceledBy, "a legacy-only update must be mirrored into canceled_by") assert.Equal(t, "late-update@example.com", *canceledBy) - _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor153+` = 'stale@example.com' WHERE execution_id = $1`, both) + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor158+` = 'stale@example.com' WHERE execution_id = $1`, both) require.NoError(t, err) - _, canceledBy, _ = actors153(ctx, t, pool, both) + _, canceledBy, _ = actors158(ctx, t, pool, both) assert.Equal(t, "new@example.com", *canceledBy, "a legacy write never overrides an actor already in canceled_by") // New code writes canceled_by only. - newOnly := insertExecution153(ctx, t, pool, canceled153, s153("current@example.com"), nil) + newOnly := insertExecution158(ctx, t, pool, canceled158, s158("current@example.com"), nil) // Down: back to the 000089 CHECKs, trigger gone, legacy column backfilled. require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - assert.Equal(t, peBefore, statusCheckDef153(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, riBefore, statusCheckDef153(ctx, t, pool, "ri_exchange_history_status_check")) - insertExecution153(ctx, t, pool, legacyStatus153, nil, nil) - _, err = insertExchange153(ctx, pool, legacyStatus153) + assert.Equal(t, peBefore, statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) + insertExecution158(ctx, t, pool, legacyStatus158, nil, nil) + _, err = insertExchange158(ctx, pool, legacyStatus158) require.NoError(t, err, "down must accept the legacy spelling again") - _, _, legacyBy := actors153(ctx, t, pool, newOnly) + _, _, legacyBy := actors158(ctx, t, pool, newOnly) require.NotNil(t, legacyBy, "down must backfill the legacy actor column") assert.Equal(t, "current@example.com", *legacyBy) - afterDown := insertExecution153(ctx, t, pool, canceled153, nil, s153("x@example.com")) - _, canceledBy, _ = actors153(ctx, t, pool, afterDown) + afterDown := insertExecution158(ctx, t, pool, canceled158, nil, s158("x@example.com")) + _, canceledBy, _ = actors158(ctx, t, pool, afterDown) assert.Nil(t, canceledBy, "down must drop the mirror trigger") - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract153), "up must re-apply cleanly after down") + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158), "up must re-apply cleanly after down") } -// canceled153 is the canonical spelling, kept local so this frozen +// canceled158 is the canonical spelling, kept local so this frozen // migration test does not move if the config constant ever does. -const canceled153 = "canceled" +const canceled158 = "canceled" -// TestMigration_000153_ConcurrentLegacyWritersKeepAttribution races writers +// TestMigration_000158_ConcurrentLegacyWritersKeepAttribution races writers // shaped like an older build (legacy actor column only) against the migration. // Whichever side of the migration a write lands on, canceled_by must hold its // actor afterwards: rows committed before the migration are drained, rows // written after it are mirrored by the trigger, and the table lock leaves no // gap between the two. -func TestMigration_000153_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { +func TestMigration_000158_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, previousMigrationVersion(t, contract153))) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, previousMigrationVersion(t, contract158))) var migrated atomic.Bool var afterMigration atomic.Int64 @@ -212,7 +212,7 @@ func TestMigration_000153_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { default: } done := migrated.Load() - if _, err := pool.Exec(ctx, insertLegacyExecution153, canceled153, nil, "racer@example.com"); err != nil { + if _, err := pool.Exec(ctx, insertLegacyExecution158, canceled158, nil, "racer@example.com"); err != nil { errs <- err return } @@ -224,7 +224,7 @@ func TestMigration_000153_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { } time.Sleep(200 * time.Millisecond) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract153)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158)) migrated.Store(true) require.Eventually(t, func() bool { return afterMigration.Load() >= 20 }, time.Minute, 10*time.Millisecond) close(stop) @@ -237,7 +237,7 @@ func TestMigration_000153_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { var total, undrained int require.NoError(t, pool.QueryRow(ctx, ` SELECT count(*), count(*) FILTER (WHERE canceled_by IS NULL) - FROM purchase_executions WHERE `+legacyActor153+` IS NOT NULL`).Scan(&total, &undrained)) + FROM purchase_executions WHERE `+legacyActor158+` IS NOT NULL`).Scan(&total, &undrained)) require.Positive(t, total) assert.Zero(t, undrained, "every legacy-only actor must be in canceled_by, of %d rows", total) } From dc3d78e629ca4bc4f3cd32df0a456cdc2aab5104 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 22:30:44 +0100 Subject: [PATCH 06/19] 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 --- ...000158_contract_canceled_spelling.down.sql | 23 +- .../000158_contract_canceled_spelling.up.sql | 67 ++--- .../000158_contract_canceled_spelling_test.go | 243 ------------------ 3 files changed, 31 insertions(+), 302 deletions(-) delete mode 100644 internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql b/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql index 99c1839ee..9267664f8 100644 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql @@ -1,15 +1,10 @@ --- Rollback 000158: back to the 000089 EXPAND state. +-- Rollback 000158: re-widen both status CHECKs to the 000089 EXPAND state. -- --- Widen both status CHECKs again first, so nothing the data steps below touch --- can violate them. Rows stay 'canceled': the widened CHECKs accept both --- spellings and every build since #1277 reads both, so converting them back is --- unnecessary. --- --- Then drop the mirror trigger and copy canceled_by into the legacy --- cancelled_by wherever it is missing, so the column holds what a build that --- still dual-writes it would have left there. +-- Rows stay 'canceled': the widened CHECKs accept both spellings and every +-- build since #1277 reads both, so converting them back is unnecessary. -LOCK TABLE purchase_executions, ri_exchange_history IN ACCESS EXCLUSIVE MODE; +LOCK TABLE purchase_executions IN ACCESS EXCLUSIVE MODE; +LOCK TABLE ri_exchange_history IN ACCESS EXCLUSIVE MODE; ALTER TABLE purchase_executions DROP CONSTRAINT IF EXISTS purchase_executions_status_check; ALTER TABLE purchase_executions ADD CONSTRAINT purchase_executions_status_check @@ -23,11 +18,3 @@ ALTER TABLE purchase_executions ADD CONSTRAINT purchase_executions_status_check ALTER TABLE ri_exchange_history DROP CONSTRAINT IF EXISTS ri_exchange_history_status_check; ALTER TABLE ri_exchange_history ADD CONSTRAINT ri_exchange_history_status_check CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'cancelled', 'canceled')); - -DROP TRIGGER IF EXISTS purchase_executions_mirror_legacy_cancel_actor ON purchase_executions; -DROP FUNCTION IF EXISTS purchase_executions_mirror_legacy_cancel_actor(); - -UPDATE purchase_executions -SET cancelled_by = canceled_by -WHERE cancelled_by IS NULL - AND canceled_by IS NOT NULL; diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql b/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql index 4debe72af..e4f02dbdb 100644 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql @@ -6,54 +6,39 @@ -- step. Every build since #1277 writes only 'canceled', so the legacy set of -- status rows is final and can be normalized and forbidden here. -- --- The legacy cancelled_by column is NOT dropped yet. The build that runs this --- migration still has old instances serving during a rolling deploy, and a --- code-only rollback (rollback.yml) can bring the previous build back with no --- schema change. That previous build reads COALESCE(canceled_by, cancelled_by) --- and writes cancelled_by, so dropping the column now would break every --- execution read on those instances. The build that ships this migration no --- longer reads or writes cancelled_by; the column drop is a later migration, --- shipped once no older build can run. --- --- Until then a trigger keeps canceled_by authoritative: whenever a write leaves --- canceled_by NULL but sets cancelled_by (what an older build's --- SavePurchaseExecution does), it copies cancelled_by into canceled_by. That --- makes canceled_by equal to COALESCE(canceled_by, cancelled_by), the value --- older builds read, so new code can read canceled_by alone. +-- This release still reads both spellings of the status and both actor +-- columns (COALESCE(canceled_by, cancelled_by)). It stops writing the legacy +-- actor column, but older builds that still write it keep working, so this +-- migration neither drains cancelled_by nor mirrors it. The drain, the strict +-- reads and the cancelled_by drop belong to a follow-up, shipped once this +-- migration has run everywhere and no build older than this release can be +-- rolled back to. -- -- Concurrency: both tables are locked up front, so no writer can commit a row --- between the normalize/drain UPDATEs and the narrowed CHECKs (which would fail --- the migration) or between the drain and the trigger (which would leave an --- undrained actor). Writers wait for the migration and then see the trigger and --- the narrowed CHECKs. golang-migrate runs this file in one transaction. - -LOCK TABLE purchase_executions, ri_exchange_history IN ACCESS EXCLUSIVE MODE; +-- between the normalize UPDATEs and the narrowed CHECKs (which would fail the +-- migration). golang-migrate runs this file in one transaction. +-- +-- updated_at is the plan-health and retention clock. The row triggers stamp it +-- on every UPDATE, so they are disabled around the normalization: a pure +-- spelling change must not make old rows look fresh. DISABLE TRIGGER is +-- transactional; if any step fails, the whole file rolls back with the +-- triggers still enabled. --- 1. Mirror legacy actor writes into canceled_by from now on. -CREATE OR REPLACE FUNCTION purchase_executions_mirror_legacy_cancel_actor() -RETURNS TRIGGER LANGUAGE plpgsql AS $$ -BEGIN - NEW.canceled_by := NEW.cancelled_by; - RETURN NEW; -END $$; +LOCK TABLE purchase_executions IN ACCESS EXCLUSIVE MODE; +LOCK TABLE ri_exchange_history IN ACCESS EXCLUSIVE MODE; -DROP TRIGGER IF EXISTS purchase_executions_mirror_legacy_cancel_actor ON purchase_executions; -CREATE TRIGGER purchase_executions_mirror_legacy_cancel_actor - BEFORE INSERT OR UPDATE ON purchase_executions - FOR EACH ROW - WHEN (NEW.canceled_by IS NULL AND NEW.cancelled_by IS NOT NULL) - EXECUTE FUNCTION purchase_executions_mirror_legacy_cancel_actor(); +ALTER TABLE purchase_executions DISABLE TRIGGER update_purchase_executions_updated_at; +ALTER TABLE ri_exchange_history DISABLE TRIGGER ri_exchange_updated_at; --- 2. Normalize the data BEFORE narrowing, or the narrowed CHECKs reject the --- surviving legacy rows. +-- Normalize the data BEFORE narrowing, or the narrowed CHECKs reject the +-- surviving legacy rows. UPDATE purchase_executions SET status = 'canceled' WHERE status = 'cancelled'; UPDATE ri_exchange_history SET status = 'canceled' WHERE status = 'cancelled'; -UPDATE purchase_executions -SET canceled_by = cancelled_by -WHERE canceled_by IS NULL - AND cancelled_by IS NOT NULL; --- 3. Narrow purchase_executions: the 000089 list without 'cancelled'. +ALTER TABLE purchase_executions ENABLE TRIGGER update_purchase_executions_updated_at; +ALTER TABLE ri_exchange_history ENABLE TRIGGER ri_exchange_updated_at; + +-- Narrow purchase_executions: the 000089 list without 'cancelled'. ALTER TABLE purchase_executions DROP CONSTRAINT IF EXISTS valid_status; ALTER TABLE purchase_executions DROP CONSTRAINT IF EXISTS purchase_executions_status_check; ALTER TABLE purchase_executions ADD CONSTRAINT purchase_executions_status_check @@ -64,7 +49,7 @@ ALTER TABLE purchase_executions ADD CONSTRAINT purchase_executions_status_check 'expired','paused','revocation_requested','scheduled' )); --- 4. Narrow ri_exchange_history: the 000089 list without 'cancelled'. +-- Narrow ri_exchange_history: the 000089 list without 'cancelled'. ALTER TABLE ri_exchange_history DROP CONSTRAINT IF EXISTS ri_exchange_history_status_check; ALTER TABLE ri_exchange_history ADD CONSTRAINT ri_exchange_history_status_check CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'canceled')); diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go deleted file mode 100644 index 1b18ec4b0..000000000 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go +++ /dev/null @@ -1,243 +0,0 @@ -//go:build integration - -package migrations_test - -import ( - "context" - "regexp" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" - "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// The legacy spellings are built by concatenation so the US-locale misspell -// linter does not flag them; they are the values under test. -const ( - contract158 = 158 - legacyStatus158 = "cancel" + "led" - legacyActor158 = "cancel" + "led_by" -) - -// legacyInCheck158 matches the legacy spelling's entry in a status CHECK -// definition as pg_get_constraintdef renders it (the cast follows the column -// type: text or character varying). -var legacyInCheck158 = regexp.MustCompile(`'` + legacyStatus158 + `'::[a-z ]+, `) - -const insertLegacyExecution158 = ` - INSERT INTO purchase_executions (status, scheduled_date, canceled_by, ` + legacyActor158 + `) - VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` - -func insertExecution158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, canceledBy, legacyBy *string) string { - t.Helper() - var id string - require.NoError(t, pool.QueryRow(ctx, insertLegacyExecution158, status, canceledBy, legacyBy).Scan(&id)) - return id -} - -func insertExchange158(ctx context.Context, pool *pgxpool.Pool, status string) (string, error) { - var id string - err := pool.QueryRow(ctx, ` - INSERT INTO ri_exchange_history - (account_id, region, source_ri_ids, source_instance_type, source_count, - target_offering_id, target_instance_type, target_count, status) - VALUES ('123456789012', 'us-east-1', ARRAY['ri-1'], 'm5.large', 1, - 'offer-1', 'm5.xlarge', 1, $1) - RETURNING id::text`, status).Scan(&id) - return id, err -} - -func statusCheckDef158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { - t.Helper() - var def string - require.NoError(t, pool.QueryRow(ctx, - `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1`, constraint).Scan(&def)) - return def -} - -func actors158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, canceledBy, legacyBy *string) { - t.Helper() - require.NoError(t, pool.QueryRow(ctx, - `SELECT status, canceled_by, `+legacyActor158+` FROM purchase_executions WHERE execution_id = $1`, id, - ).Scan(&status, &canceledBy, &legacyBy)) - return status, canceledBy, legacyBy -} - -func requireCheckViolation158(t *testing.T, err error, constraint string) { - t.Helper() - var pgErr *pgconn.PgError - require.ErrorAs(t, err, &pgErr) - assert.Equal(t, "23514", pgErr.Code) - assert.Equal(t, constraint, pgErr.ConstraintName) -} - -func s158(v string) *string { return &v } - -// TestMigration_000158_ContractCanceledSpelling locks down the contract half of -// the 000089 rename (issue #47): legacy status rows are normalized, legacy-only -// actors are drained into canceled_by, both status CHECKs lose exactly the -// legacy spelling, writes from an older build that still sets the legacy actor -// column keep canceled_by authoritative, and the down restores the 000089 state. -func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { - ctx := context.Background() - container := testhelpers.RequirePostgresContainer(ctx, t) - t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) - pool, path := container.DB.Pool(), getMigrationsPath() - prev := previousMigrationVersion(t, contract158) - - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - peBefore := statusCheckDef158(ctx, t, pool, "purchase_executions_status_check") - riBefore := statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check") - require.Len(t, legacyInCheck158.FindAllString(peBefore, -1), 1, peBefore) - require.Len(t, legacyInCheck158.FindAllString(riBefore, -1), 1, riBefore) - - legacyOnly := insertExecution158(ctx, t, pool, legacyStatus158, nil, s158("legacy@example.com")) - // Already canonical status, actor only in the legacy column: only the - // drain reaches it (the status UPDATE does not touch the row). - drainOnly := insertExecution158(ctx, t, pool, canceled158, nil, s158("drain@example.com")) - both := insertExecution158(ctx, t, pool, canceled158, s158("new@example.com"), s158("new@example.com")) - conflicting := insertExecution158(ctx, t, pool, legacyStatus158, s158("kept@example.com"), s158("other@example.com")) - failed := insertExecution158(ctx, t, pool, "failed", nil, nil) - exchange, err := insertExchange158(ctx, pool, legacyStatus158) - require.NoError(t, err) - - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158)) - - // Data: every legacy status normalized, every legacy-only actor drained, - // canceled_by kept where both columns were set, other rows untouched. - for id, want := range map[string]struct{ status, actor string }{ - legacyOnly: {canceled158, "legacy@example.com"}, - drainOnly: {canceled158, "drain@example.com"}, - both: {canceled158, "new@example.com"}, - conflicting: {canceled158, "kept@example.com"}, - } { - status, canceledBy, _ := actors158(ctx, t, pool, id) - assert.Equal(t, want.status, status, id) - require.NotNil(t, canceledBy, id) - assert.Equal(t, want.actor, *canceledBy, id) - } - status, canceledBy, _ := actors158(ctx, t, pool, failed) - assert.Equal(t, "failed", status) - assert.Nil(t, canceledBy) - var exchangeStatus string - require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchange).Scan(&exchangeStatus)) - assert.Equal(t, canceled158, exchangeStatus) - - // Schema: each CHECK is its 000089 definition minus the legacy spelling, - // nothing else, and rejects the legacy spelling. - assert.Equal(t, legacyInCheck158.ReplaceAllString(peBefore, ""), - statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, legacyInCheck158.ReplaceAllString(riBefore, ""), - statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) - _, err = pool.Exec(ctx, insertLegacyExecution158, legacyStatus158, nil, nil) - requireCheckViolation158(t, err, "purchase_executions_status_check") - _, err = insertExchange158(ctx, pool, legacyStatus158) - requireCheckViolation158(t, err, "ri_exchange_history_status_check") - - // An older build still writes the legacy actor column (its save path - // writes only that column). canceled_by must end up with the value that - // build reads, COALESCE(canceled_by, legacy), on insert and on update. - lateInsert := insertExecution158(ctx, t, pool, canceled158, nil, s158("late@example.com")) - _, canceledBy, _ = actors158(ctx, t, pool, lateInsert) - require.NotNil(t, canceledBy, "a legacy-only insert must be mirrored into canceled_by") - assert.Equal(t, "late@example.com", *canceledBy) - _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor158+` = 'late-update@example.com' WHERE execution_id = $1`, failed) - require.NoError(t, err) - _, canceledBy, _ = actors158(ctx, t, pool, failed) - require.NotNil(t, canceledBy, "a legacy-only update must be mirrored into canceled_by") - assert.Equal(t, "late-update@example.com", *canceledBy) - _, err = pool.Exec(ctx, `UPDATE purchase_executions SET `+legacyActor158+` = 'stale@example.com' WHERE execution_id = $1`, both) - require.NoError(t, err) - _, canceledBy, _ = actors158(ctx, t, pool, both) - assert.Equal(t, "new@example.com", *canceledBy, "a legacy write never overrides an actor already in canceled_by") - - // New code writes canceled_by only. - newOnly := insertExecution158(ctx, t, pool, canceled158, s158("current@example.com"), nil) - - // Down: back to the 000089 CHECKs, trigger gone, legacy column backfilled. - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - assert.Equal(t, peBefore, statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, riBefore, statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) - insertExecution158(ctx, t, pool, legacyStatus158, nil, nil) - _, err = insertExchange158(ctx, pool, legacyStatus158) - require.NoError(t, err, "down must accept the legacy spelling again") - _, _, legacyBy := actors158(ctx, t, pool, newOnly) - require.NotNil(t, legacyBy, "down must backfill the legacy actor column") - assert.Equal(t, "current@example.com", *legacyBy) - afterDown := insertExecution158(ctx, t, pool, canceled158, nil, s158("x@example.com")) - _, canceledBy, _ = actors158(ctx, t, pool, afterDown) - assert.Nil(t, canceledBy, "down must drop the mirror trigger") - - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158), "up must re-apply cleanly after down") -} - -// canceled158 is the canonical spelling, kept local so this frozen -// migration test does not move if the config constant ever does. -const canceled158 = "canceled" - -// TestMigration_000158_ConcurrentLegacyWritersKeepAttribution races writers -// shaped like an older build (legacy actor column only) against the migration. -// Whichever side of the migration a write lands on, canceled_by must hold its -// actor afterwards: rows committed before the migration are drained, rows -// written after it are mirrored by the trigger, and the table lock leaves no -// gap between the two. -func TestMigration_000158_ConcurrentLegacyWritersKeepAttribution(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - container := testhelpers.RequirePostgresContainer(ctx, t) - t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) - pool, path := container.DB.Pool(), getMigrationsPath() - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, previousMigrationVersion(t, contract158))) - - var migrated atomic.Bool - var afterMigration atomic.Int64 - stop := make(chan struct{}) - errs := make(chan error, 8) - var wg sync.WaitGroup - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - default: - } - done := migrated.Load() - if _, err := pool.Exec(ctx, insertLegacyExecution158, canceled158, nil, "racer@example.com"); err != nil { - errs <- err - return - } - if done { - afterMigration.Add(1) - } - } - }() - } - - time.Sleep(200 * time.Millisecond) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158)) - migrated.Store(true) - require.Eventually(t, func() bool { return afterMigration.Load() >= 20 }, time.Minute, 10*time.Millisecond) - close(stop) - wg.Wait() - close(errs) - for err := range errs { - require.NoError(t, err, "a legacy writer must not fail across the migration") - } - - var total, undrained int - require.NoError(t, pool.QueryRow(ctx, ` - SELECT count(*), count(*) FILTER (WHERE canceled_by IS NULL) - FROM purchase_executions WHERE `+legacyActor158+` IS NOT NULL`).Scan(&total, &undrained)) - require.Positive(t, total) - assert.Zero(t, undrained, "every legacy-only actor must be in canceled_by, of %d rows", total) -} From 520c27e73ad4fda1689ae7bea603ad83aec71874 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 21:25:50 +0100 Subject: [PATCH 07/19] 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 --- .../000158_contract_canceled_spelling_test.go | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go new file mode 100644 index 000000000..fa8d9f13b --- /dev/null +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go @@ -0,0 +1,275 @@ +//go:build integration + +package migrations_test + +import ( + "context" + "regexp" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The legacy spellings are built by concatenation so the US-locale misspell +// linter does not flag them; they are the values under test. +const ( + contract158 = 158 + legacyStatus158 = "cancel" + "led" + legacyActor158 = "cancel" + "led_by" + canceled158 = "canceled" +) + +// legacyInCheck158 matches the legacy spelling's entry in a status CHECK +// definition as pg_get_constraintdef renders it (the cast follows the column +// type: text or character varying). +var legacyInCheck158 = regexp.MustCompile(`'` + legacyStatus158 + `'::[a-z ]+, `) + +const insertExecution158 = ` + INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor158 + `, updated_at) + VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` + +func seedExecution158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { + t.Helper() + var id string + require.NoError(t, pool.QueryRow(ctx, insertExecution158, status, legacyBy, updatedAt).Scan(&id)) + return id +} + +func insertExchange158(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { + var id string + err := pool.QueryRow(ctx, ` + INSERT INTO ri_exchange_history + (account_id, region, source_ri_ids, source_instance_type, source_count, + target_offering_id, target_instance_type, target_count, status, updated_at) + VALUES ('123456789012', 'us-east-1', ARRAY['ri-1'], 'm5.large', 1, + 'offer-1', 'm5.xlarge', 1, $1, $2) + RETURNING id::text`, status, updatedAt).Scan(&id) + return id, err +} + +func statusCheckDef158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { + t.Helper() + var def string + require.NoError(t, pool.QueryRow(ctx, + `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1`, constraint).Scan(&def)) + return def +} + +func updatedAt158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { + t.Helper() + idCol := map[string]string{"purchase_executions": "execution_id", "ri_exchange_history": "id"}[table] + var ts time.Time + require.NoError(t, pool.QueryRow(ctx, + `SELECT updated_at FROM `+table+` WHERE `+idCol+` = $1`, id).Scan(&ts)) + return ts +} + +// readActor158 reads the actor the way every projection does. +func readActor158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { + t.Helper() + require.NoError(t, pool.QueryRow(ctx, + `SELECT status, COALESCE(canceled_by, `+legacyActor158+`) FROM purchase_executions WHERE execution_id = $1`, id, + ).Scan(&status, &actor)) + return status, actor +} + +func requireCheckViolation158(t *testing.T, err error, constraint string) { + t.Helper() + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + assert.Equal(t, "23514", pgErr.Code) + assert.Equal(t, constraint, pgErr.ConstraintName) +} + +func s158(v string) *string { return &v } + +// TestMigration_000158_ContractCanceledSpelling locks down the contract half of +// the 000089 rename (issue #47): legacy status rows are normalized without +// restamping updated_at (the plan-health and retention clock), both status +// CHECKs lose exactly the legacy spelling, the down restores the widened +// CHECKs exactly, and the write statements of the build before this release +// (which still write the legacy actor column) keep working against the +// narrowed schema. +func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { + ctx := context.Background() + container := testhelpers.RequirePostgresContainer(ctx, t) + t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) + pool, path := container.DB.Pool(), getMigrationsPath() + prev := previousMigrationVersion(t, contract158) + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) + peBefore := statusCheckDef158(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck158.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck158.FindAllString(riBefore, -1), 1, riBefore) + + old := time.Now().UTC().AddDate(0, 0, -90).Truncate(time.Microsecond) + legacyExec := seedExecution158(ctx, t, pool, legacyStatus158, s158("legacy@example.com"), old) + failedExec := seedExecution158(ctx, t, pool, "failed", nil, old) + legacyExchange, err := insertExchange158(ctx, pool, legacyStatus158, old) + require.NoError(t, err) + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158)) + + // Data: the legacy status is normalized and the legacy actor column is + // left alone (this release still reads it); other rows are untouched. + status, actor := readActor158(ctx, t, pool, legacyExec) + assert.Equal(t, canceled158, status) + require.NotNil(t, actor) + assert.Equal(t, "legacy@example.com", *actor) + status, _ = readActor158(ctx, t, pool, failedExec) + assert.Equal(t, "failed", status) + var exchangeStatus string + require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, legacyExchange).Scan(&exchangeStatus)) + assert.Equal(t, canceled158, exchangeStatus) + + // updated_at is the plan-health and retention clock: a spelling change + // must not make 90-day-old rows look fresh. Each table has its own + // updated_at trigger, so each is asserted on its own. + assert.True(t, old.Equal(updatedAt158(ctx, t, pool, "purchase_executions", legacyExec)), + "purchase_executions.updated_at must survive the normalization") + assert.True(t, old.Equal(updatedAt158(ctx, t, pool, "ri_exchange_history", legacyExchange)), + "ri_exchange_history.updated_at must survive the normalization") + var enabled int + require.NoError(t, pool.QueryRow(ctx, ` + SELECT count(*) FROM pg_trigger + WHERE tgname IN ('update_purchase_executions_updated_at', 'ri_exchange_updated_at') + AND tgenabled = 'O'`).Scan(&enabled)) + assert.Equal(t, 2, enabled, "both updated_at triggers must be enabled again") + + // Schema: each CHECK is its pre-migration definition minus the legacy + // spelling, nothing else, and rejects the legacy spelling. + assert.Equal(t, legacyInCheck158.ReplaceAllString(peBefore, ""), + statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck158.ReplaceAllString(riBefore, ""), + statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertExecution158, legacyStatus158, nil, old) + requireCheckViolation158(t, err, "purchase_executions_status_check") + _, err = insertExchange158(ctx, pool, legacyStatus158, old) + requireCheckViolation158(t, err, "ri_exchange_history_status_check") + + requireOldBuildWritesStillWork158(ctx, t, pool) + + // Down: the widened CHECKs come back byte for byte. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) + assert.Equal(t, peBefore, statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) + seedExecution158(ctx, t, pool, legacyStatus158, nil, old) + _, err = insertExchange158(ctx, pool, legacyStatus158, old) + require.NoError(t, err, "down must accept the legacy spelling again") + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158), "up must re-apply cleanly after down") +} + +// Write statements copied from origin/main (internal/config/store_postgres.go +// at the time of #1253): the build that is live when this migration lands +// writes cancelled_by, so none of them may hit the narrowed CHECKs and the +// actor must read back through COALESCE(canceled_by, cancelled_by). +const ( + oldSaveExecution158 = ` + INSERT INTO purchase_executions ( + plan_id, execution_id, status, step_number, scheduled_date, + notification_sent, approval_token_hash, recommendations, + total_upfront_cost, estimated_savings, completed_at, error, expires_at, + cloud_account_id, source, approved_by, cancelled_by, capacity_percent, + created_by_user_id, retry_execution_id, retry_attempt_n, + approval_token_expires_at, + executed_by_user_id, executed_at, pre_approval_skip_reason, + idempotency_key, scheduled_execution_at, no_submission, created_by_system + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29) + ON CONFLICT (execution_id) DO UPDATE SET + status = $3, + notification_sent = $6, + approval_token_hash = $7, + recommendations = $8, + total_upfront_cost = $9, + estimated_savings = $10, + completed_at = $11, + error = $12, + expires_at = $13, + cloud_account_id = $14, + source = $15, + approved_by = $16, + cancelled_by = $17, + capacity_percent = $18, + approval_token_expires_at = $22, + executed_by_user_id = $23, + executed_at = $24, + pre_approval_skip_reason = $25, + scheduled_execution_at = $27, + no_submission = CASE WHEN $3 = 'failed' + THEN purchase_executions.no_submission OR $28 + ELSE false END, + updated_at = NOW()` + oldSetCancelledBy158 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() + WHERE execution_id = $1` + oldTransition158 = ` + UPDATE purchase_executions + SET status = $2, updated_at = NOW(), + transitioned_by = $4, transitioned_at = NOW(), + canceled_by = COALESCE((SELECT email FROM users WHERE id = $5), canceled_by), + cancelled_by = COALESCE((SELECT email FROM users WHERE id = $5), cancelled_by) + WHERE execution_id = $1 AND status = ANY($3)` + oldCancelAllExchanges158 = ` + UPDATE ri_exchange_history + SET status = $1, updated_at = NOW() + WHERE status = 'pending'` +) + +func requireOldBuildWritesStillWork158(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { + t.Helper() + now := time.Now().UTC() + var execID string + require.NoError(t, pool.QueryRow(ctx, `SELECT gen_random_uuid()::text`).Scan(&execID)) + save := func(status, actor string) error { + _, err := pool.Exec(ctx, oldSaveExecution158, + nil, execID, status, 1, now, nil, nil, []byte(`[]`), 0, 0, nil, "", nil, + nil, "web", nil, actor, 100, nil, nil, 0, nil, nil, nil, nil, nil, nil, false, false) + return err + } + + // SavePurchaseExecutionTx: insert, then the ON CONFLICT update, both with + // the canonical status and the legacy actor column only. + require.NoError(t, save("pending", ""), "old upsert insert path") + require.NoError(t, save(canceled158, "old-save@example.com"), "old upsert update path") + status, actor := readActor158(ctx, t, pool, execID) + assert.Equal(t, canceled158, status) + require.NotNil(t, actor, "an actor written to the legacy column must read back") + assert.Equal(t, "old-save@example.com", *actor) + + // SetCancelledBy. + ct, err := pool.Exec(ctx, oldSetCancelledBy158, execID, "set-by@example.com") + require.NoError(t, err) + require.EqualValues(t, 1, ct.RowsAffected()) + _, actor = readActor158(ctx, t, pool, execID) + require.NotNil(t, actor) + assert.Equal(t, "set-by@example.com", *actor) + + // transitionExecutionStatus dual write. + var userID string + require.NoError(t, pool.QueryRow(ctx, ` + INSERT INTO users (email, password_hash, group_ids) VALUES ('transition@example.com', 'x', ARRAY[gen_random_uuid()]) RETURNING id::text`).Scan(&userID)) + transitionID := seedExecution158(ctx, t, pool, "pending", nil, now) + ct, err = pool.Exec(ctx, oldTransition158, transitionID, canceled158, []string{"pending"}, userID, userID) + require.NoError(t, err) + require.EqualValues(t, 1, ct.RowsAffected()) + status, actor = readActor158(ctx, t, pool, transitionID) + assert.Equal(t, canceled158, status) + require.NotNil(t, actor) + assert.Equal(t, "transition@example.com", *actor) + + // CancelAllPendingExchanges. + exchangeID, err := insertExchange158(ctx, pool, "pending", now) + require.NoError(t, err) + _, err = pool.Exec(ctx, oldCancelAllExchanges158, canceled158) + require.NoError(t, err) + var exchangeStatus string + require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchangeID).Scan(&exchangeStatus)) + assert.Equal(t, canceled158, exchangeStatus) +} From b4d869eb7990c2f5312753c5d566390ac0ababe6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 21:30:45 +0100 Subject: [PATCH 08/19] 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 --- internal/api/handler_history.go | 20 ++- internal/api/handler_history_test.go | 13 +- internal/api/handler_purchases.go | 18 ++- ...urchases_submit_dedupe_integration_test.go | 2 +- internal/api/handler_purchases_test.go | 61 ++++++++ internal/api/plan_health.go | 6 +- internal/api/plan_health_test.go | 9 +- internal/config/store_postgres.go | 42 +++-- internal/config/store_postgres_db_test.go | 11 ++ ...stgres_history_summary_integration_test.go | 4 +- .../config/store_postgres_ladder_resolve.go | 4 +- ...ostgres_ladder_resolve_integration_test.go | 1 + .../store_postgres_ladder_resolve_test.go | 2 +- .../config/store_postgres_pgxmock_test.go | 145 ++++++++---------- .../store_postgres_plan_schedule_clock.go | 2 +- .../config/store_postgres_ramp_baseline.go | 2 +- ...postgres_ramp_baseline_integration_test.go | 3 +- .../store_postgres_ramp_baseline_test.go | 2 +- ...ore_postgres_ramp_step_integration_test.go | 2 +- internal/config/types.go | 23 ++- internal/purchase/ladder_reconcile.go | 2 +- .../ladder_reconcile_integration_test.go | 1 + internal/purchase/ladder_reconcile_test.go | 1 + 23 files changed, 243 insertions(+), 133 deletions(-) diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index e6651ea3d..72c06674a 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -160,7 +160,12 @@ var actionableExecutionStatuses = []string{"pending", "notified", "scheduled", " // partial-failure marker and is flagged IsAuditGap so its execution-level // dollars are excluded from the dashboard totals — the committed dollars are // already counted via the per-rec purchase_history rows that succeeded. -var terminalExecutionStatuses = []string{"completed", "partially_completed", "failed", "expired", config.StatusCanceled} +// Both the US-spelling status (config.StatusCanceled) and the legacy British +// spelling (config.LegacyStatusCanceled) are listed: during the expand-contract +// rename (migration 000089) old code may still write the legacy value before +// the rolling deploy completes. The contract migration (#1278) normalizes the +// data once the deploy is verified stable; drop the legacy entry here then. +var terminalExecutionStatuses = []string{"completed", "partially_completed", "failed", "expired", config.StatusCanceled, config.LegacyStatusCanceled} // approvalExpiryWindow is how long a pending approval stays actionable // before the History view flips it to "expired". Aligns with the @@ -437,7 +442,10 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P row.StatusDescription = purchase.SanitizeStoredError(exec.Error) case "expired": row.StatusDescription = "approval link expired (not approved within 7 days)" - case config.StatusCanceled: + case config.StatusCanceled, config.LegacyStatusCanceled: + // The legacy British spelling is still matched during the + // expand-contract rename (migration 000089) until the contract + // migration (#1278) normalizes and drops it. annotateCancelled(row, exec, approver) default: // In-flight (approved/running/scheduled/paused) and audit-gap @@ -1254,10 +1262,14 @@ func addNonCompletedRowToSummary(summary *HistorySummary, status string, n int) summary.TotalFailed += n case "expired": summary.TotalExpired += n - case config.StatusCanceled: + case config.StatusCanceled, config.LegacyStatusCanceled: // A canceled purchase represents zero committed spend and zero // realized savings (issue #736). Exclude from all dollar KPIs and - // from TotalCompleted -- the money was never committed. + // from TotalCompleted -- the money was never committed. The legacy + // British spelling is matched alongside the US one during the + // expand-contract rename (migration 000089) so legacy rows can't + // inflate KPIs mid-deploy; the contract migration (#1278) drops it + // once the deploy is stable. default: return false } diff --git a/internal/api/handler_history_test.go b/internal/api/handler_history_test.go index f31d4d02c..f4778c5d4 100644 --- a/internal/api/handler_history_test.go +++ b/internal/api/handler_history_test.go @@ -1821,9 +1821,16 @@ func TestSummarizePurchaseHistory_CancelledExcludedFromKPIs(t *testing.T) { // One pending row that should be counted as pending, not completed. {Status: "pending", UpfrontCost: new(float64(999.0)), EstimatedSavings: 99.0}, // Two canceled rows — the regression case from issue #736. Neither must - // appear in the dollar KPIs or TotalCompleted. + // appear in the dollar KPIs or TotalCompleted. One uses the new US + // spelling (config.StatusCanceled) and one the legacy British spelling + // (config.LegacyStatusCanceled): during the expand-contract rename + // (migration 000089) a mixed fleet still emits the legacy spelling, so + // the dual-spelling read path must exclude BOTH. The constant carries + // the legacy value without a literal the US-locale misspell linter would + // flag (and without a nolint). Drop the legacy fixture once the contract + // migration (#1278) normalizes the data. {Status: config.StatusCanceled, UpfrontCost: new(float64(500.0)), EstimatedSavings: 50.0}, - {Status: config.StatusCanceled, UpfrontCost: new(float64(750.0)), EstimatedSavings: 75.0}, + {Status: config.LegacyStatusCanceled, UpfrontCost: new(float64(750.0)), EstimatedSavings: 75.0}, } summary := summarizePurchaseHistory(purchases) @@ -1961,7 +1968,7 @@ func TestSummarizePurchaseHistory_CancelPendingDoesNotChangeKPIs(t *testing.T) { // After: same rows plus one canceled execution (the pending that got canceled). withCancelled := append(baseline, config.PurchaseHistoryRecord{ //nolint:gocritic - Status: config.StatusCanceled, + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql UpfrontCost: new(float64(999.0)), EstimatedSavings: 99.0, }) diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 9c19eb603..0fbe3328d 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -884,11 +884,27 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri if getErr != nil { return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr) } - if existing.Status != config.StatusCanceled { + // Accept both spellings: during the expand-contract rename (migration + // 000089) a concurrent legacy cancel may have written the legacy value + // before the rolling deploy completes. The contract migration (#1278) + // normalizes the data, after which LegacyStatusCanceled can be removed. + if existing.Status != config.StatusCanceled && existing.Status != config.LegacyStatusCanceled { return nil, NewClientError(409, fmt.Sprintf( "execution %s cannot be canceled (status=%s)", executionID, existing.Status)) } + // Normalize the response Status so the idempotent recovery path returns the + // canonical US spelling even when the DB row still carries the legacy value. + // Without this, a caller that received an in-flight 200 from this branch + // would see status="canceled" while a caller that hit the happy-path + // transition above would see status="canceled" for the same execution_id + // during the rolling deploy. The legacy value is preserved in storage -- + // only the in-memory copy returned to the handler is normalized -- so the + // contract migration's authoritative status backfill still observes every + // legacy row. + if existing.Status == config.LegacyStatusCanceled { + existing.Status = config.StatusCanceled + } if delErr := h.config.DeleteSuppressionsByExecution(ctx, executionID); delErr != nil { return nil, fmt.Errorf("cancel execution %s: failed to release suppressions: %w", executionID, delErr) } diff --git a/internal/api/handler_purchases_submit_dedupe_integration_test.go b/internal/api/handler_purchases_submit_dedupe_integration_test.go index 49499b27b..04e92181b 100644 --- a/internal/api/handler_purchases_submit_dedupe_integration_test.go +++ b/internal/api/handler_purchases_submit_dedupe_integration_test.go @@ -119,7 +119,7 @@ func TestPersistExecution_RetryAfterRowLeftPending(t *testing.T) { }{ {"pending", true}, {"notified", true}, {"approved", true}, {"running", true}, {"completed", true}, {"partially_completed", true}, - {config.StatusFailed, false}, {config.StatusCanceled, false}, {config.StatusExpired, false}, + {config.StatusFailed, false}, {config.StatusCanceled, false}, {config.LegacyStatusCanceled, false}, {config.StatusExpired, false}, } { t.Run(tc.status, func(t *testing.T) { recs, key := submitFixture(t) diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 6936cf6bb..9faa713b9 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -2089,6 +2089,67 @@ func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing assert.Contains(t, ce.message, "running", "error must include actual status") } +// TestHandler_deletePlannedPurchase_ConflictRetryLegacyCanceledNormalizes is +// the regression guard for the CR outside-diff comment on PR #1277: when the +// CAS conflict recovery branch finds the row in the LEGACY British spelling +// (the row was canceled by old code during the rolling deploy window), the +// API response Status must be normalized to the canonical US spelling so a +// caller doesn't observe two different status values for the same execution +// depending on which code instance handled the request. The stored row is +// untouched -- only the in-memory copy returned to the handler is normalized. +func TestHandler_deletePlannedPurchase_ConflictRetryLegacyCanceledNormalizes(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + + adminSession := &Session{ + UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Email: "admin@example.com", + } + + planID := "12121212-1212-1212-1212-121212121212" + execID := "13131313-1313-1313-1313-131313131313" + + conflictErr := fmt.Errorf("%w: execution %s cannot transition", config.ErrExecutionNotInExpectedStatus, execID) + + // Old code already canceled this row during the deploy window: the DB + // holds the legacy British spelling. The handler must accept it (idempotent + // recovery) but normalize the response to the canonical US spelling. + existingExec := &config.PurchaseExecution{ + ExecutionID: execID, + PlanID: planID, + Status: config.LegacyStatusCanceled, + } + plan := &config.PurchasePlan{ + ID: planID, + Name: "Legacy Cancel Plan", + Enabled: true, + } + + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything).Return(nil, conflictErr) + mockStore.On("GetExecutionByID", ctx, execID).Return(existingExec, nil) + mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) + mockStore.On("UpdatePurchasePlan", ctx, mock.MatchedBy(func(p *config.PurchasePlan) bool { + return p.ID == planID && !p.Enabled + })).Return(nil) + + handler := &Handler{config: mockStore, auth: mockAuth} + + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + } + result, err := handler.deletePlannedPurchase(ctx, req, execID) + require.NoError(t, err) + assert.Equal(t, config.StatusCanceled, result.Status, + "legacy 'cancelled' must be normalized to the canonical US 'canceled' in the response") + assert.NotEqual(t, config.LegacyStatusCanceled, result.Status, + "the response must never leak the legacy spelling once the recovery branch ran") +} + // TestHandler_deletePlannedPurchase_BackendErrorReturns5xx is the regression // guard for CodeRabbit round-2 finding #1 (same class as PR #1276): a // TransitionExecutionStatus error that is NOT ErrExecutionNotInExpectedStatus diff --git a/internal/api/plan_health.go b/internal/api/plan_health.go index f37fcd1cb..0210854d5 100644 --- a/internal/api/plan_health.go +++ b/internal/api/plan_health.go @@ -222,9 +222,11 @@ func failedExecutionsFactor(counts config.ExecutionStatusCounts) (PlanHealthFact } // canceledExecutionsFactor: -5 per canceled execution in the last -// planHealthLookbackDays, capped at 4 (-20 max). +// planHealthLookbackDays, capped at 4 (-20 max). Counts both spellings of +// "canceled" (config.StatusCanceled and the legacy +// config.LegacyStatusCanceled) so pre-#1278 rows aren't invisible to scoring. func canceledExecutionsFactor(counts config.ExecutionStatusCounts) (PlanHealthFactor, bool) { - count := counts[config.StatusCanceled] + count := counts[config.StatusCanceled] + counts[config.LegacyStatusCanceled] if count == 0 { return PlanHealthFactor{}, false } diff --git a/internal/api/plan_health_test.go b/internal/api/plan_health_test.go index 47eb6c10d..7f6b5605a 100644 --- a/internal/api/plan_health_test.go +++ b/internal/api/plan_health_test.go @@ -149,7 +149,7 @@ func TestComputePlanHealth_FailedExecutionsPenaltyCapsAtFour(t *testing.T) { assert.Equal(t, 100-40, score) } -func TestComputePlanHealth_CanceledExecutionsPenaltyCapsAtFour(t *testing.T) { +func TestComputePlanHealth_CanceledExecutionsPenaltyCapsAtFourAndCountsBothSpellings(t *testing.T) { now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) plan := config.PurchasePlan{ Enabled: true, @@ -160,10 +160,11 @@ func TestComputePlanHealth_CanceledExecutionsPenaltyCapsAtFour(t *testing.T) { StartDate: now.AddDate(0, 0, -1), }, } - // 5 canceled; penalty caps at 4 * 5 = 20 but the note reports the true - // count of 5. + // 3 canonical + 2 legacy-spelled = 5 total canceled; penalty caps at + // 4 * 5 = 20 but the note reports the true count of 5. execCounts := counts(map[string]int{ - config.StatusCanceled: 5, + config.StatusCanceled: 3, + config.LegacyStatusCanceled: 2, }) score, factors := computePlanHealth(plan, now, execCounts, config.RampStepBlock{}) diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 39d3741b4..00603f7c8 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -1170,9 +1170,9 @@ func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, } // canceled_by keeps an actor already on the row: a save from a copy read - // before a concurrent cancel must not erase that cancel's attribution. - // This is the value reads saw before migration 000153, when the save wrote - // the legacy column and reads took COALESCE(canceled_by, cancelled_by). + // before a concurrent cancel must not erase that cancel's attribution. Reads + // take COALESCE(canceled_by, cancelled_by), so a legacy actor on cancelled_by + // is carried into canceled_by here. This save no longer writes cancelled_by. query := ` INSERT INTO purchase_executions ( plan_id, execution_id, status, step_number, scheduled_date, @@ -1417,7 +1417,7 @@ func (s *PostgresStore) transitionExecutionStatus(ctx context.Context, q executi RETURNING plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1470,7 +1470,8 @@ func transitionRejection(ctx context.Context, q executionQuerier, executionID st // any other column. This avoids the full-row overwrite that a // SavePurchaseExecution follow-up would perform, eliminating the lost-update // window between TransitionExecutionStatus and the attribution write -// (Finding #5). +// (Finding #5). It no longer writes the legacy cancelled_by column; reads take +// COALESCE(canceled_by, cancelled_by). func (s *PostgresStore) SetCancelledBy(ctx context.Context, executionID, cancelledBy string) error { q := `UPDATE purchase_executions SET canceled_by = $2, updated_at = NOW() WHERE execution_id = $1` @@ -1678,7 +1679,7 @@ func (s *PostgresStore) GetExecutionsByStatusesFiltered(ctx context.Context, q E SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1790,7 +1791,7 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1815,7 +1816,7 @@ func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderTha SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1857,7 +1858,7 @@ func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []stri SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1878,7 +1879,7 @@ func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExe SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1960,7 +1961,7 @@ func (s *PostgresStore) ListRecentSubmitsTx(ctx context.Context, tx pgx.Tx, crea SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -1995,7 +1996,7 @@ func getExecutionByIDOn(ctx context.Context, q executionQuerier, executionID str SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -2041,7 +2042,7 @@ func (s *PostgresStore) GetExecutionByPlanAndDate(ctx context.Context, planID st SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -2286,7 +2287,7 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, total_upfront_cost, estimated_savings, completed_at, error, expires_at, - cloud_account_id, source, approved_by, canceled_by, capacity_percent, + cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS cancelled_by, capacity_percent, created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, @@ -2317,8 +2318,10 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha // clock here because a completed row executed on (or very near) the day // it was scheduled for. // -// 2. Canceled-state cleanup: `status = 'canceled' AND updated_at < NOW() - -// retention`. +// 2. Canceled-state cleanup: `status IN ('cancelled', 'canceled') AND +// updated_at < NOW() - retention`. Both spellings are included to cover +// legacy rows ('cancelled') and new code rows ('canceled', canonical US +// spelling per StatusCanceled / migration 000089). // // This branch retains on `updated_at`, NOT `scheduled_date`, and must // stay that way: CountExecutionsByPlanAndStatus windows the plan health @@ -2403,6 +2406,11 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha // are safe from expiry-based cleanup -- which, per the reachability note // above, is every row current code writes. func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error) { + // Branch 2 lists both spellings: 'cancelled' (legacy, pre-migration + // 000089 rows) and 'canceled' (canonical, written by new code after the + // follow-up to #1277). #1278 will drop 'cancelled' once all rows are + // normalized. + // // Branch 3's allowlist and exclusion are parameterized from // TerminalExecutionStatuses and HealthScoredExecutionStatuses instead of // repeating literal lists, so this branch and the plan health score @@ -2419,7 +2427,7 @@ func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays AND scheduled_date < NOW() - INTERVAL '1 day' * $1 ) OR ( - status = 'canceled' + status IN ('cancelled', 'canceled') AND updated_at < NOW() - INTERVAL '1 day' * $1 ) OR ( diff --git a/internal/config/store_postgres_db_test.go b/internal/config/store_postgres_db_test.go index b2036d604..36f906411 100644 --- a/internal/config/store_postgres_db_test.go +++ b/internal/config/store_postgres_db_test.go @@ -1288,6 +1288,17 @@ func TestPostgresStoreDB_CleanupOldExecutions_RetainsCanceledInsideHealthWindow( shouldSurvive: true, why: "expires_at branch must not purge what the canceled branch retains", }, + { + // Deliberately the LegacyStatusCanceled constant, i.e. the + // pre-migration-000089 double-l DB value. The label avoids + // spelling it out only to keep the misspell linter quiet. + name: "legacy-spelling-canceled-today-old-scheduled-date", + status: LegacyStatusCanceled, + scheduledDays: -40, + updatedDays: 0, + shouldSurvive: true, + why: "the legacy spelling is retained on updated_at too", + }, { // The `failed` counterpart of the canceled case above, and the // reason branch 3's exclusion is sourced from diff --git a/internal/config/store_postgres_history_summary_integration_test.go b/internal/config/store_postgres_history_summary_integration_test.go index b4754760f..19cdee86d 100644 --- a/internal/config/store_postgres_history_summary_integration_test.go +++ b/internal/config/store_postgres_history_summary_integration_test.go @@ -88,7 +88,7 @@ func (f *historySummaryFixture) seed(ctx context.Context, t *testing.T) { recA := []RecommendationRecord{{Provider: "aws", CloudAccountID: &a}} mixed := []RecommendationRecord{{Provider: "aws", CloudAccountID: &a}, {Provider: "azure", CloudAccountID: &b}} statuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused", "failed", "expired", - StatusCanceled, "partially_completed"} + StatusCanceled, LegacyStatusCanceled, "partially_completed"} for i, st := range statuses { at := f.base.Add(time.Duration(i) * 36 * time.Hour) f.saveExecution(ctx, t, at, st, "", &a, nil, recA) @@ -197,7 +197,7 @@ func TestIntegration_HistorySummaryAggregate_MatchesRowFetch(t *testing.T) { } actionableStatuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused"} statuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused", - "completed", "partially_completed", "failed", "expired", StatusCanceled} + "completed", "partially_completed", "failed", "expired", StatusCanceled, LegacyStatusCanceled} for name, filter := range filters { t.Run(name, func(t *testing.T) { agg, err := f.store.HistorySummaryAggregate(ctx, HistorySummaryFilter{ diff --git a/internal/config/store_postgres_ladder_resolve.go b/internal/config/store_postgres_ladder_resolve.go index b1f5388f7..187e3878d 100644 --- a/internal/config/store_postgres_ladder_resolve.go +++ b/internal/config/store_postgres_ladder_resolve.go @@ -61,7 +61,7 @@ type LadderResolveResult struct { // still move money, so it blocks the resolve. func ladderResolvableNodeStatus(status string) bool { switch status { - case StatusFailed, StatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted: + case StatusFailed, StatusCanceled, LegacyStatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted: return true } return false @@ -71,7 +71,7 @@ func ladderResolvableNodeStatus(status string) bool { // the provider. func ladderNodeProvenUnsent(n LadderChainNode) bool { switch n.Status { - case StatusCanceled, StatusExpired: + case StatusCanceled, LegacyStatusCanceled, StatusExpired: return true case StatusFailed: return n.NoSubmission diff --git a/internal/config/store_postgres_ladder_resolve_integration_test.go b/internal/config/store_postgres_ladder_resolve_integration_test.go index 78fb16904..c1cf301bf 100644 --- a/internal/config/store_postgres_ladder_resolve_integration_test.go +++ b/internal/config/store_postgres_ladder_resolve_integration_test.go @@ -91,6 +91,7 @@ func TestResolveLadderTrancheFailed_ProvenChain(t *testing.T) { for name, nodes := range map[string][]resolveNode{ "no_submission head": {{"failed", true, time.Minute}}, "canceled after proof": {{"failed", true, time.Hour}, {"canceled", false, time.Minute}}, + "legacy spelling": {{LegacyStatusCanceled, false, time.Minute}}, "expired": {{"expired", false, time.Minute}}, } { t.Run(name, func(t *testing.T) { diff --git a/internal/config/store_postgres_ladder_resolve_test.go b/internal/config/store_postgres_ladder_resolve_test.go index 06f1d2487..78bf31bae 100644 --- a/internal/config/store_postgres_ladder_resolve_test.go +++ b/internal/config/store_postgres_ladder_resolve_test.go @@ -11,7 +11,7 @@ import ( // The chain-node allowlist fails closed: a live status or one added later is // not resolvable (the status CHECK keeps such rows out of the database tests). func TestLadderResolvableNodeStatus(t *testing.T) { - for _, s := range []string{StatusFailed, StatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted} { + for _, s := range []string{StatusFailed, StatusCanceled, LegacyStatusCanceled, StatusExpired, StatusCompleted, StatusPartiallyCompleted} { assert.True(t, ladderResolvableNodeStatus(s), s) } for _, s := range []string{"pending", "notified", "scheduled", "approved", "running", "", "future-status"} { diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index 894181c9f..ab640f0ac 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -894,29 +894,51 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// executionRowCols is the scan shape of every purchase_executions read. -var executionRowCols = []string{ - "plan_id", "execution_id", "status", "step_number", "scheduled_date", - "notification_sent", "approval_token", "recommendations", - "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "canceled_by", "capacity_percent", - "created_by_user_id", "retry_execution_id", "retry_attempt_n", - "approval_token_expires_at", - "executed_by_user_id", "executed_at", "pre_approval_skip_reason", - "idempotency_key", - "scheduled_execution_at", - "no_submission", "created_by_system", -} - -func canceledExecutionRow(t *testing.T, actor string) *pgxmock.Rows { - t.Helper() - recsJSON, err := json.Marshal([]RecommendationRecord{}) - require.NoError(t, err) - return pgxmock.NewRows(executionRowCols).AddRow( - "plan-1", "exec-1", StatusCanceled, 1, time.Now().Truncate(time.Second), +// TestPGXMock_GetExecutionByID_ProjectsCoalescedCancelledBy guards the #1277 / +// #1453 read-side follow-up: CancelExecutionAtomic and +// CancelScheduledExecutionAtomic write the canceling actor to the NEW +// canceled_by column, but migration 000089's expand-contract contract states +// every SELECT must read back COALESCE(canceled_by, cancelled_by) so a row +// written by either the old or the new column is attributed correctly. Before +// this fix GetExecutionByID selected the bare legacy cancelled_by, so an +// actor written only to canceled_by (the atomic-cancel paths) scanned back as +// NULL and the History page lost the attribution. +// +// The mock uses regexp query matching, so ExpectQuery requires the issued +// SELECT to contain the COALESCE expression; a bare `cancelled_by` projection +// (the pre-fix query) does not match, the mock returns no rows, and +// GetExecutionByID surfaces ErrNotFound instead of the expected execution -- +// that is how this test fails against the pre-fix code and passes once the +// projection is fixed. +func TestPGXMock_GetExecutionByID_ProjectsCoalescedCancelledBy(t *testing.T) { + mock := newMock(t) + store := storeWith(mock) + ctx := context.Background() + + recsJSON, _ := json.Marshal([]RecommendationRecord{}) + now := time.Now().Truncate(time.Second) + cols := []string{ + "plan_id", "execution_id", "status", "step_number", "scheduled_date", + "notification_sent", "approval_token", "recommendations", + "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", + "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", + "created_by_user_id", "retry_execution_id", "retry_attempt_n", + "approval_token_expires_at", + "executed_by_user_id", "executed_at", "pre_approval_skip_reason", + "idempotency_key", + "scheduled_execution_at", + "no_submission", "created_by_system", + } + // Simulates the exact row shape the atomic-cancel paths produce: the + // actor lives only in canceled_by (legacy cancelled_by is NULL). What + // Postgres would actually return for COALESCE(canceled_by, cancelled_by) + // is the canceled_by value, so the mocked "cancelled_by" scan slot below + // carries that same value to stand in for the server-side COALESCE result. + rows := pgxmock.NewRows(cols).AddRow( + "plan-1", "exec-1", "canceled", 1, now, sql.NullTime{}, "tok-123", recsJSON, 100.0, 200.0, sql.NullTime{}, "", sql.NullTime{}, - nil, "", nil, strPtr(actor), 100, + nil, "", nil, strPtr("cancelling-actor@example.com"), 100, nil, nil, 0, sql.NullTime{}, nil, sql.NullTime{}, nil, @@ -924,63 +946,14 @@ func canceledExecutionRow(t *testing.T, actor string) *pgxmock.Rows { sql.NullTime{}, false, false, ) -} - -// TestPGXMock_ExecutionCancelActorUsesOnlyCanceledBy pins the code half of -// the 000089 contract (issue #47): reads, cancel transitions, SetCancelledBy -// and the full-row save use canceled_by alone and never name the legacy -// double-l column, so the follow-up migration can drop that column without -// breaking a build that runs this code. The matcher rejects any statement that -// mentions the legacy column, so each call fails against the pre-#47 queries -// (COALESCE projections, dual writes, the save writing only the legacy column). -// -// The save keeps an actor already on the row (COALESCE(existing, $17)): reads -// used COALESCE(canceled_by, cancelled_by) and the save wrote only the legacy -// column, so a save from a stale copy could never erase canceled_by. Writing -// $17 straight into canceled_by would let it. -func TestPGXMock_ExecutionCancelActorUsesOnlyCanceledBy(t *testing.T) { - legacyColumn := "cancel" + "led_by" - mock, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherFunc(func(expected, actual string) error { - if strings.Contains(actual, legacyColumn) { - return errors.New("statement still references the legacy cancel actor column: " + actual) - } - return pgxmock.QueryMatcherRegexp.Match(expected, actual) - }))) - require.NoError(t, err) - store := storeWith(mock) - ctx := context.Background() + mock.ExpectQuery(`COALESCE\(canceled_by,\s*cancelled_by\)`). + WithArgs(pgxmock.AnyArg()). + WillReturnRows(rows) - mock.ExpectQuery(`approved_by, canceled_by, capacity_percent`). - WithArgs("exec-1"). - WillReturnRows(canceledExecutionRow(t, "reader@example.com")) exec, err := store.GetExecutionByID(ctx, "exec-1") require.NoError(t, err) - require.NotNil(t, exec.CancelledBy) - assert.Equal(t, "reader@example.com", *exec.CancelledBy) - - actor := "11111111-1111-1111-1111-111111111111" - mock.ExpectQuery(`(?s)SET status = \$2.*canceled_by = COALESCE\(\(SELECT email FROM users WHERE id = \$5\), canceled_by\)\s+WHERE`). - WithArgs(anyArgsCfg(7)...). - WillReturnRows(canceledExecutionRow(t, "canceler@example.com")) - returned, err := store.TransitionExecutionStatus(ctx, "exec-1", []string{"pending"}, StatusCanceled, &actor) - require.NoError(t, err) - require.NotNil(t, returned.CancelledBy) - assert.Equal(t, "canceler@example.com", *returned.CancelledBy) - - mock.ExpectExec(`SET canceled_by = \$2, updated_at = NOW\(\)`). - WithArgs("exec-1", "revoker@example.com"). - WillReturnResult(pgxmock.NewResult("UPDATE", 1)) - require.NoError(t, store.SetCancelledBy(ctx, "exec-1", "revoker@example.com")) - - mock.ExpectBegin() - mock.ExpectExec(`(?s)approved_by, canceled_by, capacity_percent.*canceled_by = COALESCE\(purchase_executions\.canceled_by, \$17\)`). - WithArgs(anyArgsCfg(29)...). - WillReturnResult(pgxmock.NewResult("INSERT", 1)) - mock.ExpectCommit() - require.NoError(t, store.SavePurchaseExecution(ctx, &PurchaseExecution{ - ExecutionID: "exec-1", PlanID: "plan-1", Status: StatusCanceled, ScheduledDate: time.Now(), - })) - + require.NotNil(t, exec.CancelledBy, "actor written to canceled_by must round-trip via the COALESCE read projection") + assert.Equal(t, "cancelling-actor@example.com", *exec.CancelledBy) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -3308,16 +3281,18 @@ func TestPGXMock_CancelPendingExchangesByOrigin_Ladder_WritesCanonicalStatus(t * } // TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus verifies that the -// cleanup DELETE includes 'canceled' in the terminal-status filter, so -// Plans-canceled rows are not missed. Regression guard for the #1277 -// follow-up Defect 2 fix. +// cleanup DELETE includes 'canceled' (canonical) in the terminal-status filter, +// so Plans-canceled rows written by new code are not missed. +// Regression guard for the #1277 follow-up Defect 2 fix. func TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus(t *testing.T) { mock := newMock(t) store := storeWith(mock) ctx := context.Background() - // The regex pins the canonical spelling after the contract migration. - mock.ExpectExec(`status = 'canceled'`). + // The regex pins on both 'cancelled' (legacy) and 'canceled' (canonical) + // appearing together in the SQL. If either is missing the mock won't + // match, causing an "unexpected query" error from ExpectationsWereMet. + mock.ExpectExec(`'cancelled'.*'canceled'`). WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("DELETE", 4)) @@ -3339,7 +3314,7 @@ func TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus(t *testing.T) { // regressed instead of just "the SQL changed". func TestPGXMock_CleanupOldExecutions_RetainsCanceledOnUpdatedAt(t *testing.T) { anchors := map[string]string{ - "canceled branch retains on updated_at": `(?s)status = 'canceled'\s+AND updated_at`, + "canceled branch retains on updated_at": `(?s)status IN \('cancelled', 'canceled'\)\s+AND updated_at`, "completed branch retains on scheduled_date": `(?s)status = 'completed'\s+AND scheduled_date`, "expires_at branch allowlists settled rows": `(?s)status = ANY\(\$2\)\s+AND status <> ALL\(\$3\)\s+AND expires_at IS NOT NULL`, } @@ -3388,7 +3363,7 @@ func TestPGXMock_CleanupOldExecutions_ExcludesEveryHealthScoredStatus(t *testing assert.NoError(t, mock.ExpectationsWereMet()) // Guards the slice's contents independently of how the query uses it. assert.ElementsMatch(t, - []string{StatusFailed, StatusCanceled}, + []string{StatusFailed, StatusCanceled, LegacyStatusCanceled}, HealthScoredExecutionStatuses) // The statuses a lapsed TTL may delete: settled and not health-scored. // None of them is one an executor, scheduler or reaper acts on. @@ -3422,13 +3397,13 @@ func TestPGXMock_CountExecutionsByPlanAndStatus_AggregatesInSQL(t *testing.T) { rows := pgxmock.NewRows([]string{"plan_id", "status", "count"}). AddRow("plan-a", "failed", 7). AddRow("plan-a", StatusCanceled, 2). - AddRow("plan-b", StatusCanceled, 1) + AddRow("plan-b", LegacyStatusCanceled, 1) mock.ExpectQuery(`(?s)SELECT plan_id, status, COUNT\(\*\).*FROM purchase_executions.*status = ANY\(\$1\).*plan_id IS NOT NULL.*updated_at >= \$2.*GROUP BY plan_id, status`). WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg()). WillReturnRows(rows) counts, err := store.CountExecutionsByPlanAndStatus(ctx, - []string{"failed", StatusCanceled}, + []string{"failed", StatusCanceled, LegacyStatusCanceled}, time.Now().AddDate(0, 0, -90)) require.NoError(t, err) @@ -3436,7 +3411,7 @@ func TestPGXMock_CountExecutionsByPlanAndStatus_AggregatesInSQL(t *testing.T) { // though DefaultListLimit-style paging would be the temptation here. assert.Equal(t, 7, counts["plan-a"]["failed"]) assert.Equal(t, 2, counts["plan-a"][StatusCanceled]) - assert.Equal(t, 1, counts["plan-b"][StatusCanceled]) + assert.Equal(t, 1, counts["plan-b"][LegacyStatusCanceled]) // Absent statuses read as a zero count rather than needing a guard. assert.Equal(t, 0, counts["plan-b"]["failed"]) assert.Equal(t, 0, counts["plan-missing"]["failed"]) diff --git a/internal/config/store_postgres_plan_schedule_clock.go b/internal/config/store_postgres_plan_schedule_clock.go index 0c0261d57..1725ee56d 100644 --- a/internal/config/store_postgres_plan_schedule_clock.go +++ b/internal/config/store_postgres_plan_schedule_clock.go @@ -129,7 +129,7 @@ func managedPlanNextRoot(plan *PurchasePlan, rows []PurchaseExecution) (*Purchas if row.StepNumber != plan.RampSchedule.CurrentStep+1 { continue } - if row.Status == StatusCanceled { + if row.Status == StatusCanceled || row.Status == LegacyStatusCanceled { blocked = blocked || managedRootPriorAction(row) continue } diff --git a/internal/config/store_postgres_ramp_baseline.go b/internal/config/store_postgres_ramp_baseline.go index 87d985b51..40e76a31a 100644 --- a/internal/config/store_postgres_ramp_baseline.go +++ b/internal/config/store_postgres_ramp_baseline.go @@ -131,7 +131,7 @@ func (s *PostgresStore) ListPlanCommittedTx(ctx context.Context, tx pgx.Tx, plan // capacity. See ListPlanCommittedTx for the rule. func holdsCapacity(e *PurchaseExecution) bool { switch e.Status { - case StatusCanceled, StatusExpired: + case StatusCanceled, LegacyStatusCanceled, StatusExpired: return false case StatusFailed: return !e.NoSubmission diff --git a/internal/config/store_postgres_ramp_baseline_integration_test.go b/internal/config/store_postgres_ramp_baseline_integration_test.go index 00747bcee..f37fcd1f9 100644 --- a/internal/config/store_postgres_ramp_baseline_integration_test.go +++ b/internal/config/store_postgres_ramp_baseline_integration_test.go @@ -71,8 +71,9 @@ func TestListPlanCommittedTx_RealRowShapes(t *testing.T) { require.NoError(t, f.store.SavePurchaseExecution(ctx, retry)) f.rowWithRecs(ctx, t, 2, a2, StatusCompleted, false, r2) - // Step 3: canceled and a failed row proven unsubmitted hold nothing. + // Step 3: canceled, legacy-canceled and a failed row proven unsubmitted hold nothing. f.rowWithRecs(ctx, t, 3, a1, StatusCanceled, false, r1) + f.rowWithRecs(ctx, t, 3, a1, LegacyStatusCanceled, false, r1) f.rowWithRecs(ctx, t, 3, a2, StatusFailed, true, r2) got := f.committed(ctx, t) diff --git a/internal/config/store_postgres_ramp_baseline_test.go b/internal/config/store_postgres_ramp_baseline_test.go index 39e6a98b7..d9cda88d9 100644 --- a/internal/config/store_postgres_ramp_baseline_test.go +++ b/internal/config/store_postgres_ramp_baseline_test.go @@ -41,7 +41,7 @@ func TestSumPlanCommitted_StatusSet(t *testing.T) { assert.Equal(t, 20, got[key].Count, "status %q holds capacity", status) } - for _, status := range []string{StatusCanceled, StatusExpired} { + for _, status := range []string{StatusCanceled, LegacyStatusCanceled, StatusExpired} { got, err := sumPlanCommitted([]PurchaseExecution{baselineExec("e", 1, status, rec)}) require.NoError(t, err) assert.Empty(t, got, "status %q holds nothing", status) diff --git a/internal/config/store_postgres_ramp_step_integration_test.go b/internal/config/store_postgres_ramp_step_integration_test.go index 00ff0b021..1e18b71c0 100644 --- a/internal/config/store_postgres_ramp_step_integration_test.go +++ b/internal/config/store_postgres_ramp_step_integration_test.go @@ -544,7 +544,7 @@ func TestRampStepStatusListsPartitionEveryWrittenStatus(t *testing.T) { written := []string{ "pending", "notified", "scheduled", "approved", "running", "paused", StatusCompleted, StatusPartiallyCompleted, StatusFailed, StatusExpired, - StatusCanceled, + StatusCanceled, LegacyStatusCanceled, } // In-flight statuses hold a step open on purpose and are deliberately not // reported as stuck; they are listed here so adding a status to the schema diff --git a/internal/config/types.go b/internal/config/types.go index c1c02439e..ad7cd6b23 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -456,12 +456,18 @@ type PurchaseExecution struct { NoSubmission bool `json:"no_submission,omitempty" dynamodbav:"no_submission,omitempty"` } -// StatusCanceled is the only canceled status value. Migration 000153 (the -// contract half of the 000089 rename) normalized every legacy British-spelled -// row to it and narrowed the status CHECK constraints so no other spelling can -// be stored. +// StatusCanceled is the canonical US-spelling status value new code writes. const StatusCanceled = "canceled" +// LegacyStatusCanceled is the British-spelling status value old code writes +// during the expand-contract rename (migration 000089). It is constructed by +// concatenation rather than a single literal so the US-locale misspell linter +// does not flag it -- this lets the dual-spelling read paths reference the +// legacy value without a //nolint:misspell directive. The CONTRACT migration +// (#1278) normalizes all rows to StatusCanceled once old code is gone, after +// which every reference to this constant can be deleted. +const LegacyStatusCanceled = "cancel" + "led" + // StatusFailed is the terminal status written when an execution's purchase // attempt errors out, and by the stuck-purchase reaper (internal/purchase/ // reaper.go) for rows left in approved/running past its threshold. @@ -498,6 +504,8 @@ const StatusExpired = "expired" // Canceled counts as settled but neither succeeded nor stuck: to cancel a // step's row is an operator deciding that unit will not buy, and that decision // is what releases a ramp an unrecoverable account would otherwise freeze. +// Both spellings are listed for the duration of the expand-contract rename +// (migration 000089, contract in #1278), same as HealthScoredExecutionStatuses. // // RampStepSucceededStatuses is the same predicate migration 000098 spells out // inline as status IN ('completed','partially_completed') when it decides which @@ -507,7 +515,7 @@ const StatusExpired = "expired" // "bought" means. var ( RampStepSucceededStatuses = []string{StatusCompleted, StatusPartiallyCompleted} - RampStepSettledStatuses = []string{StatusCompleted, StatusPartiallyCompleted, StatusCanceled} + RampStepSettledStatuses = []string{StatusCompleted, StatusPartiallyCompleted, StatusCanceled, LegacyStatusCanceled} RampStepStuckStatuses = []string{StatusFailed, StatusExpired} ) @@ -541,9 +549,13 @@ type RampStepBlock struct { // Keeping one exported slice rather than a literal list on each side is what // makes that agreement structural: adding a status to the score // automatically extends the sweep's exclusion, so the two cannot drift. +// +// Both spellings of canceled are present for the duration of the +// expand-contract rename (migration 000089, contract in #1278). var HealthScoredExecutionStatuses = []string{ StatusFailed, StatusCanceled, + LegacyStatusCanceled, } // StatusRevocationRequested records that an operator asked for a completed @@ -573,6 +585,7 @@ var TerminalExecutionStatuses = []string{ StatusPartiallyCompleted, StatusFailed, StatusCanceled, + LegacyStatusCanceled, StatusExpired, StatusRevocationRequested, } diff --git a/internal/purchase/ladder_reconcile.go b/internal/purchase/ladder_reconcile.go index abdc09145..509783241 100644 --- a/internal/purchase/ladder_reconcile.go +++ b/internal/purchase/ladder_reconcile.go @@ -80,7 +80,7 @@ func decideFiredTranche(chain []config.LadderChainNode, visible func(executionID return ladderVerdict{note: "failed without a no_submission proof (outcome unknown)"} } return failedIfPredecessorsProven(chain, ladderReasonNoSubmission) - case config.StatusCanceled: + case config.StatusCanceled, config.LegacyStatusCanceled: return failedIfPredecessorsProven(chain, ladderReasonApprovalCanceled) case config.StatusExpired: return failedIfPredecessorsProven(chain, ladderReasonApprovalExpired) diff --git a/internal/purchase/ladder_reconcile_integration_test.go b/internal/purchase/ladder_reconcile_integration_test.go index 0228e92f6..595cd2740 100644 --- a/internal/purchase/ladder_reconcile_integration_test.go +++ b/internal/purchase/ladder_reconcile_integration_test.go @@ -127,6 +127,7 @@ func TestReconcileLadderTranches_Mappings(t *testing.T) { {"failed without proof (reaper / outcome unknown) keeps netting", "failed", false, true, "fired", ""}, {"proof flag on a running row is ignored", "running", true, true, "fired", ""}, {"canceled approval", "canceled", false, true, "failed", "approval_canceled"}, + {"legacy canceled spelling", config.LegacyStatusCanceled, false, true, "failed", "approval_canceled"}, {"expired approval", "expired", false, true, "failed", "approval_expired"}, {"partially completed stays fired", "partially_completed", false, true, "fired", ""}, {"completed and visible", "completed", false, true, "completed", "commitment_visible"}, diff --git a/internal/purchase/ladder_reconcile_test.go b/internal/purchase/ladder_reconcile_test.go index 33536f543..dae1b24c8 100644 --- a/internal/purchase/ladder_reconcile_test.go +++ b/internal/purchase/ladder_reconcile_test.go @@ -33,6 +33,7 @@ func TestDecideFiredTranche(t *testing.T) { {"first attempt reaped, later attempts proven", []config.LadderChainNode{chainNode("e1", "failed", false), chainNode("e2", "failed", true), chainNode("e3", "failed", true)}, visibleAlways, "", ""}, {"every attempt proven", []config.LadderChainNode{chainNode("e1", "failed", true), chainNode("e2", "failed", true)}, visibleAlways, ladder.TrancheStatusFailed, "no_submission"}, {"canceled tail", []config.LadderChainNode{chainNode("e1", "canceled", false)}, visibleAlways, ladder.TrancheStatusFailed, "approval_canceled"}, + {"legacy spelling tail", []config.LadderChainNode{chainNode("e1", config.LegacyStatusCanceled, false)}, visibleAlways, ladder.TrancheStatusFailed, "approval_canceled"}, {"expired tail after unproven attempt", []config.LadderChainNode{chainNode("e1", "failed", false), chainNode("e2", "expired", false)}, visibleAlways, "", ""}, {"proof flag ignored on a non-failed row", []config.LadderChainNode{chainNode("e1", "running", true)}, visibleAlways, "", ""}, {"partially completed", []config.LadderChainNode{chainNode("e1", "partially_completed", false)}, visibleAlways, "", ""}, From b49c18b64cf93784ea284c540268b23961bb7af1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 21:31:05 +0100 Subject: [PATCH 09/19] 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 --- ...tore_postgres_cancel_actor_pgxmock_test.go | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 internal/config/store_postgres_cancel_actor_pgxmock_test.go diff --git a/internal/config/store_postgres_cancel_actor_pgxmock_test.go b/internal/config/store_postgres_cancel_actor_pgxmock_test.go new file mode 100644 index 000000000..b740974ad --- /dev/null +++ b/internal/config/store_postgres_cancel_actor_pgxmock_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/pashagolub/pgxmock/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// legacyCancelActorColumn is the pre-migration-000089 column, split so the +// US-locale misspell linter does not flag it; it is the value under test. +const legacyCancelActorColumn = "cancel" + "led_by" + +const coalescedActorProjection = "COALESCE(canceled_by, " + legacyCancelActorColumn + ") AS " + legacyCancelActorColumn + +var executionProjectionCols = []string{ + "plan_id", "execution_id", "status", "step_number", "scheduled_date", + "notification_sent", "approval_token", "recommendations", + "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", + "cloud_account_id", "source", "approved_by", legacyCancelActorColumn, "capacity_percent", + "created_by_user_id", "retry_execution_id", "retry_attempt_n", + "approval_token_expires_at", + "executed_by_user_id", "executed_at", "pre_approval_skip_reason", + "idempotency_key", "scheduled_execution_at", "no_submission", "created_by_system", +} + +// TestPGXMock_ExecutionProjectionsReadCoalescedActor pins the read half of the +// 000089 contract while old builds can still write the legacy column: every +// statement that projects a purchase_executions row must read +// COALESCE(canceled_by, cancelled_by), so an actor stored in either column +// scans back. A bare canceled_by projection loses the attribution of legacy +// rows (written to cancelled_by only) until the follow-up drain runs. +func TestPGXMock_ExecutionProjectionsReadCoalescedActor(t *testing.T) { + var mu sync.Mutex + var projections, bare []string + mock, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherFunc(func(_, actual string) error { + if !strings.Contains(actual, "capacity_percent") { + return nil + } + mu.Lock() + defer mu.Unlock() + projections = append(projections, actual) + if !strings.Contains(actual, coalescedActorProjection) { + bare = append(bare, actual) + } + return nil + }))) + require.NoError(t, err) + mock.MatchExpectationsInOrder(false) + store := storeWith(mock) + ctx := context.Background() + empty := func() *pgxmock.Rows { return pgxmock.NewRows(executionProjectionCols) } + // Two rows of expectations per call: a zero-row transition is followed by + // a probe SELECT. + expect := func() { + mock.ExpectQuery(".").WillReturnRows(empty()) + mock.ExpectQuery(".").WillReturnRows(empty()).Maybe() + } + + expect() + _, _ = store.GetExecutionsByStatusesFiltered(ctx, ExecutionsByStatusesQuery{Statuses: []string{"pending"}}) + expect() + _, _ = store.GetPlannedExecutions(ctx, []string{"scheduled"}, 10) + expect() + _, _ = store.GetStaleApprovedExecutions(ctx, time.Hour) + expect() + _, _ = store.ListStuckExecutions(ctx, []string{"approved"}, time.Hour) + expect() + _, _ = store.GetPendingExecutions(ctx) + expect() + _, _ = store.GetExecutionByID(ctx, "exec-1") + expect() + _, _ = store.GetExecutionByPlanAndDate(ctx, "plan-1", time.Now()) + expect() + _, _ = store.GetScheduledExecutionsDue(ctx) + actor := "11111111-1111-1111-1111-111111111111" + expect() + _, _ = store.TransitionExecutionStatus(ctx, "exec-1", []string{"pending"}, StatusCanceled, &actor) + mock.ExpectBegin() + mock.ExpectExec(".").WillReturnResult(pgxmock.NewResult("SELECT", 1)) + expect() + tx, err := mock.Begin(ctx) + require.NoError(t, err) + _, _ = store.ListRecentSubmitsTx(ctx, tx, "creator", time.Now()) + + mu.Lock() + defer mu.Unlock() + assert.GreaterOrEqual(t, len(projections), 10, "every purchase_executions projection must be exercised") + assert.Empty(t, bare, "projections that do not read COALESCE(canceled_by, legacy actor)") +} + +// TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy pins the write half: +// the save, the cancel transition and SetCancelledBy write canceled_by alone +// and never the legacy column (RETURNING projections still read it). The +// matcher rejects any INSERT or UPDATE statement whose write part names the legacy +// column, so each call fails against the dual-write queries. +// +// The save keeps an actor already on the row (COALESCE(existing, $17)): a save +// from a copy read before a concurrent cancel must not erase that actor, and a +// legacy-only actor read through COALESCE is carried into canceled_by. +func TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy(t *testing.T) { + var dualWrites []string + mock, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherFunc(func(expected, actual string) error { + write, _, _ := strings.Cut(actual, "RETURNING") + verb := strings.ToUpper(strings.Fields(write + " x")[0]) + if (verb == "INSERT" || verb == "UPDATE") && strings.Contains(write, legacyCancelActorColumn) { + dualWrites = append(dualWrites, actual) + return errors.New("write still names the legacy cancel actor column: " + actual) + } + return pgxmock.QueryMatcherRegexp.Match(expected, actual) + }))) + require.NoError(t, err) + store := storeWith(mock) + ctx := context.Background() + + actor := "11111111-1111-1111-1111-111111111111" + mock.ExpectQuery(`(?s)SET status = \$2.*canceled_by = COALESCE\(\(SELECT email FROM users WHERE id = \$5\), canceled_by\)\s+WHERE`). + WithArgs(anyArgsCfg(7)...). + WillReturnRows(pgxmock.NewRows(executionProjectionCols)) + // The empty result row makes the store probe the row afterwards; that + // probe and its error are not under test. + mock.ExpectQuery(`FROM purchase_executions`).WillReturnRows(pgxmock.NewRows(executionProjectionCols)).Maybe() + _, _ = store.TransitionExecutionStatus(ctx, "exec-1", []string{"pending"}, StatusCanceled, &actor) + require.Empty(t, dualWrites, "the cancel transition must write canceled_by only") + + mock.ExpectExec(`SET canceled_by = \$2, updated_at = NOW\(\)`). + WithArgs("exec-1", "revoker@example.com"). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + require.NoError(t, store.SetCancelledBy(ctx, "exec-1", "revoker@example.com")) + + mock.ExpectBegin() + mock.ExpectExec(`(?s)approved_by, canceled_by, capacity_percent.*canceled_by = COALESCE\(purchase_executions\.canceled_by, \$17\)`). + WithArgs(anyArgsCfg(29)...). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + require.NoError(t, store.SavePurchaseExecution(ctx, &PurchaseExecution{ + ExecutionID: "exec-1", PlanID: "plan-1", Status: StatusCanceled, ScheduledDate: time.Now(), + })) + + assert.NoError(t, mock.ExpectationsWereMet()) +} From e6348b10a88adc9abdf8e43d48ca33d4296365be Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 21:33:56 +0100 Subject: [PATCH 10/19] 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 --- ...urchases_legacy_cancel_integration_test.go | 54 ++++++ ...stgres_legacy_canceled_integration_test.go | 156 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 internal/api/handler_purchases_legacy_cancel_integration_test.go create mode 100644 internal/config/store_postgres_legacy_canceled_integration_test.go diff --git a/internal/api/handler_purchases_legacy_cancel_integration_test.go b/internal/api/handler_purchases_legacy_cancel_integration_test.go new file mode 100644 index 000000000..8b5261aa9 --- /dev/null +++ b/internal/api/handler_purchases_legacy_cancel_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package api + +import ( + "context" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" + "github.com/stretchr/testify/require" +) + +// TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration runs +// the cancel-race recovery on the schema before 000158 against a row an older +// build canceled (legacy status spelling, actor only in cancelled_by). The +// build and the migration roll out in either order, so the retry must see the +// race as already won, return the canonical status and keep the actor. +func TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + container := testhelpers.RequireEmptyDatabase(ctx, t) + pool := container.DB.Pool() + path := getMigrationsPath() + entries, err := os.ReadDir(path) + require.NoError(t, err) + var before uint + for _, e := range entries { + v, parseErr := strconv.ParseUint(strings.SplitN(e.Name(), "_", 2)[0], 10, 32) + if parseErr == nil && v < 158 && uint(v) > before { + before = uint(v) + } + } + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, before)) + store := config.NewPostgresStore(container.DB) + + var execID string + require.NoError(t, pool.QueryRow(ctx, ` + INSERT INTO purchase_executions (status, scheduled_date, `+"cancel"+`led_by) + VALUES ($1, NOW(), 'legacy@example.com') RETURNING execution_id::text`, + config.LegacyStatusCanceled).Scan(&execID)) + + h := &Handler{config: store} + got, err := h.cancelOrRecoverExecution(ctx, execID, nil) + require.NoError(t, err, "a legacy canceled row is an already-won cancel race, not a 409") + require.Equal(t, config.StatusCanceled, got.Status, "the idempotent path returns the canonical spelling") + require.NotNil(t, got.CancelledBy) + require.Equal(t, "legacy@example.com", *got.CancelledBy) +} diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go new file mode 100644 index 000000000..a66dd46af --- /dev/null +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -0,0 +1,156 @@ +//go:build integration + +package config + +import ( + "context" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" +) + +// legacyActorColumn is the pre-migration-000089 column, split so the +// US-locale misspell linter does not flag it. +const legacyActorColumn = "cancel" + "led_by" + +// versionBeforeCanceledContract is the schema a deployment is at when the +// build under test starts: the highest migration below 000158, the contract +// that narrows the status CHECKs. The new build must read and write correctly +// there, because the migration and the release roll out in either order. +func versionBeforeCanceledContract(t *testing.T) uint { + t.Helper() + entries, err := os.ReadDir(getTestMigrationsPath()) + require.NoError(t, err) + var best uint + for _, e := range entries { + v, err := strconv.ParseUint(strings.SplitN(e.Name(), "_", 2)[0], 10, 32) + if err == nil && v < 158 && uint(v) > best { + best = uint(v) + } + } + require.NotZero(t, best) + return best +} + +// TestPostgresStore_NewCodeAgainstPreContractSchema runs the new store code on +// the schema before 000158 with rows an older build wrote: the legacy status +// spelling and an actor only in cancelled_by. Every reader must treat them as +// canceled with their actor, and the new writers must not lose them. +func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + container := testhelpers.RequireEmptyDatabase(ctx, t) + pool := container.DB.Pool() + require.NoError(t, migrations.MigrateToVersion(ctx, pool, getTestMigrationsPath(), versionBeforeCanceledContract(t))) + store := NewPostgresStore(container.DB) + + acct := &CloudAccount{Name: "Legacy Cancel", Provider: "aws", ExternalID: "ext-legacy-cancel", Enabled: true} + require.NoError(t, store.CreateCloudAccount(ctx, acct)) + plan := &PurchasePlan{ + Name: "Legacy Cancel Plan", Services: map[string]ServiceConfig{}, + RampSchedule: RampSchedule{Type: "weekly", PercentPerStep: 25, StepIntervalDays: 7, CurrentStep: 2, TotalSteps: 4}, + } + require.NoError(t, store.CreatePurchasePlan(ctx, plan)) + + legacyRow := func(updatedAt time.Time, step int, actor string) string { + t.Helper() + var id string + require.NoError(t, pool.QueryRow(ctx, ` + INSERT INTO purchase_executions (plan_id, status, step_number, scheduled_date, cloud_account_id, `+legacyActorColumn+`, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING execution_id::text`, + plan.ID, LegacyStatusCanceled, step, updatedAt, acct.ID, actor, updatedAt).Scan(&id)) + return id + } + rawActors := func(id string) (canceledBy, legacyBy *string) { + t.Helper() + require.NoError(t, pool.QueryRow(ctx, + `SELECT canceled_by, `+legacyActorColumn+` FROM purchase_executions WHERE execution_id = $1`, id).Scan(&canceledBy, &legacyBy)) + return canceledBy, legacyBy + } + now := time.Now().UTC().Truncate(time.Second) + legacy := legacyRow(now, 3, "legacy@example.com") + + t.Run("get reads the actor through COALESCE", func(t *testing.T) { + got, err := store.GetExecutionByID(ctx, legacy) + require.NoError(t, err) + assert.Equal(t, LegacyStatusCanceled, got.Status) + require.NotNil(t, got.CancelledBy, "a cancelled_by-only actor must scan back") + assert.Equal(t, "legacy@example.com", *got.CancelledBy) + }) + + t.Run("history list and summary include the legacy status", func(t *testing.T) { + statuses := []string{StatusCanceled, LegacyStatusCanceled} + page, err := store.ListHistoryExecutionsPage(ctx, HistoryExecutionsPageQuery{Statuses: statuses, Limit: 10}) + require.NoError(t, err) + require.Len(t, page, 1) + require.NotNil(t, page[0].CancelledBy) + assert.Equal(t, "legacy@example.com", *page[0].CancelledBy) + + groups, err := store.HistorySummaryAggregate(ctx, HistorySummaryFilter{ExecutionStatuses: statuses}) + require.NoError(t, err) + require.Len(t, groups.Executions, 1) + assert.Equal(t, LegacyStatusCanceled, groups.Executions[0].Status) + assert.Equal(t, 1, groups.Executions[0].Count) + }) + + t.Run("a legacy row is terminal and holds no capacity or ramp step", func(t *testing.T) { + live, err := store.CountLiveExecutionsForAccount(ctx, acct.ID) + require.NoError(t, err) + assert.Zero(t, live, "a legacy canceled row must not block deleting the account") + + assert.False(t, holdsCapacity(&PurchaseExecution{Status: LegacyStatusCanceled})) + + tx, err := pool.Begin(ctx) + require.NoError(t, err) + defer func() { _ = tx.Rollback(ctx) }() + occupied, err := store.OccupiedRampStepsInRangeTx(ctx, tx, plan.ID, 3, 3) + require.NoError(t, err) + assert.Empty(t, occupied, "a step whose only unit is legacy canceled settled without buying") + }) + + t.Run("save keeps a legacy actor readable and never erases an actor", func(t *testing.T) { + got, err := store.GetExecutionByID(ctx, legacy) + require.NoError(t, err) + got.Status = StatusCanceled + require.NoError(t, store.SavePurchaseExecution(ctx, got)) + canceledBy, legacyBy := rawActors(legacy) + require.NotNil(t, canceledBy, "the save carries the legacy actor into canceled_by") + assert.Equal(t, "legacy@example.com", *canceledBy) + require.NotNil(t, legacyBy, "the legacy column is left as the older build wrote it") + assert.Equal(t, "legacy@example.com", *legacyBy) + reread, err := store.GetExecutionByID(ctx, legacy) + require.NoError(t, err) + require.NotNil(t, reread.CancelledBy) + assert.Equal(t, "legacy@example.com", *reread.CancelledBy) + + // A save from a copy read before a concurrent cancel stamped the actor. + stale := *got + stale.CancelledBy = nil + _, err = pool.Exec(ctx, `UPDATE purchase_executions SET canceled_by = 'concurrent@example.com' WHERE execution_id = $1`, legacy) + require.NoError(t, err) + require.NoError(t, store.SavePurchaseExecution(ctx, &stale)) + canceledBy, _ = rawActors(legacy) + require.NotNil(t, canceledBy) + assert.Equal(t, "concurrent@example.com", *canceledBy, "a stale save must not erase the actor") + }) + + t.Run("cleanup removes a legacy row after retention", func(t *testing.T) { + old := legacyRow(now.AddDate(0, 0, -31), 1, "old@example.com") + deleted, err := store.CleanupOldExecutions(ctx, 30) + require.NoError(t, err) + assert.EqualValues(t, 1, deleted) + _, err = store.GetExecutionByID(ctx, old) + assert.ErrorIs(t, err, ErrNotFound) + _, err = store.GetExecutionByID(ctx, legacy) + assert.NoError(t, err, "a fresh legacy row survives") + }) + +} From b201a1bb9a4fc83b8d18ab906cbeec2e34b2e83f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 22:28:32 +0100 Subject: [PATCH 11/19] 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 --- ..._purchases_legacy_cancel_integration_test.go | 2 +- ..._purchases_submit_dedupe_integration_test.go | 2 +- .../store_postgres_cancel_actor_pgxmock_test.go | 4 ++-- internal/config/store_postgres_db_test.go | 11 ----------- ..._postgres_delete_account_integration_test.go | 17 ++++++++++++++--- ...postgres_history_summary_integration_test.go | 4 ++-- ..._postgres_ladder_resolve_integration_test.go | 1 - ...postgres_legacy_canceled_integration_test.go | 5 ++--- ...e_postgres_ramp_baseline_integration_test.go | 3 +-- ...store_postgres_ramp_step_integration_test.go | 2 +- ...store_postgres_retention_integration_test.go | 3 +-- .../000158_contract_canceled_spelling_test.go | 6 ++++-- .../ladder_reconcile_integration_test.go | 1 - 13 files changed, 29 insertions(+), 32 deletions(-) diff --git a/internal/api/handler_purchases_legacy_cancel_integration_test.go b/internal/api/handler_purchases_legacy_cancel_integration_test.go index 8b5261aa9..e9e2db28f 100644 --- a/internal/api/handler_purchases_legacy_cancel_integration_test.go +++ b/internal/api/handler_purchases_legacy_cancel_integration_test.go @@ -18,7 +18,7 @@ import ( // TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration runs // the cancel-race recovery on the schema before 000158 against a row an older -// build canceled (legacy status spelling, actor only in cancelled_by). The +// build canceled (legacy status spelling, actor only in the legacy column). The // build and the migration roll out in either order, so the retry must see the // race as already won, return the canonical status and keep the actor. func TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration(t *testing.T) { diff --git a/internal/api/handler_purchases_submit_dedupe_integration_test.go b/internal/api/handler_purchases_submit_dedupe_integration_test.go index 04e92181b..49499b27b 100644 --- a/internal/api/handler_purchases_submit_dedupe_integration_test.go +++ b/internal/api/handler_purchases_submit_dedupe_integration_test.go @@ -119,7 +119,7 @@ func TestPersistExecution_RetryAfterRowLeftPending(t *testing.T) { }{ {"pending", true}, {"notified", true}, {"approved", true}, {"running", true}, {"completed", true}, {"partially_completed", true}, - {config.StatusFailed, false}, {config.StatusCanceled, false}, {config.LegacyStatusCanceled, false}, {config.StatusExpired, false}, + {config.StatusFailed, false}, {config.StatusCanceled, false}, {config.StatusExpired, false}, } { t.Run(tc.status, func(t *testing.T) { recs, key := submitFixture(t) diff --git a/internal/config/store_postgres_cancel_actor_pgxmock_test.go b/internal/config/store_postgres_cancel_actor_pgxmock_test.go index b740974ad..861428ea2 100644 --- a/internal/config/store_postgres_cancel_actor_pgxmock_test.go +++ b/internal/config/store_postgres_cancel_actor_pgxmock_test.go @@ -33,9 +33,9 @@ var executionProjectionCols = []string{ // TestPGXMock_ExecutionProjectionsReadCoalescedActor pins the read half of the // 000089 contract while old builds can still write the legacy column: every // statement that projects a purchase_executions row must read -// COALESCE(canceled_by, cancelled_by), so an actor stored in either column +// COALESCE(canceled_by, ), so an actor stored in either column // scans back. A bare canceled_by projection loses the attribution of legacy -// rows (written to cancelled_by only) until the follow-up drain runs. +// rows (written to the legacy column only) until the follow-up drain runs. func TestPGXMock_ExecutionProjectionsReadCoalescedActor(t *testing.T) { var mu sync.Mutex var projections, bare []string diff --git a/internal/config/store_postgres_db_test.go b/internal/config/store_postgres_db_test.go index 36f906411..b2036d604 100644 --- a/internal/config/store_postgres_db_test.go +++ b/internal/config/store_postgres_db_test.go @@ -1288,17 +1288,6 @@ func TestPostgresStoreDB_CleanupOldExecutions_RetainsCanceledInsideHealthWindow( shouldSurvive: true, why: "expires_at branch must not purge what the canceled branch retains", }, - { - // Deliberately the LegacyStatusCanceled constant, i.e. the - // pre-migration-000089 double-l DB value. The label avoids - // spelling it out only to keep the misspell linter quiet. - name: "legacy-spelling-canceled-today-old-scheduled-date", - status: LegacyStatusCanceled, - scheduledDays: -40, - updatedDays: 0, - shouldSurvive: true, - why: "the legacy spelling is retained on updated_at too", - }, { // The `failed` counterpart of the canceled case above, and the // reason branch 3's exclusion is sourced from diff --git a/internal/config/store_postgres_delete_account_integration_test.go b/internal/config/store_postgres_delete_account_integration_test.go index e39f74001..b05bcbebf 100644 --- a/internal/config/store_postgres_delete_account_integration_test.go +++ b/internal/config/store_postgres_delete_account_integration_test.go @@ -11,6 +11,7 @@ import ( "context" "errors" "regexp" + "slices" "sort" "testing" "time" @@ -76,13 +77,23 @@ func accountExists(ctx context.Context, t *testing.T, store *PostgresStore, id s return n == 1 } +// storableTerminalExecutionStatuses is TerminalExecutionStatuses minus the +// legacy canceled spelling, which migration 000158 forbids in the status CHECK +// on a fully migrated schema. Legacy rows are covered against the schema before +// 000158 in TestPostgresStore_NewCodeAgainstPreContractSchema. +func storableTerminalExecutionStatuses() []string { + return slices.DeleteFunc(slices.Clone(TerminalExecutionStatuses), func(s string) bool { + return s == LegacyStatusCanceled + }) +} + // An account whose executions are all terminal is deletable, and the history // rows survive with their status intact and the account reference detached. func TestDeleteCloudAccount_TerminalExecutionsAreDetached(t *testing.T) { ctx := context.Background() store := setupLadderStore(ctx, t) - for _, status := range TerminalExecutionStatuses { + for _, status := range storableTerminalExecutionStatuses() { t.Run(status, func(t *testing.T) { acct := seedDeleteAccount(ctx, t, store) execID := seedDeleteAccountExec(ctx, t, store, acct, status) @@ -158,7 +169,7 @@ func TestCountLiveExecutionsForAccount(t *testing.T) { for _, status := range liveExecutionStatuses { seedDeleteAccountExec(ctx, t, store, acct, status) } - for _, status := range TerminalExecutionStatuses { + for _, status := range storableTerminalExecutionStatuses() { seedDeleteAccountExec(ctx, t, store, acct, status) } seedDeleteAccountExec(ctx, t, store, other, "running") @@ -191,7 +202,7 @@ func TestTerminalExecutionStatuses_CoverSchema(t *testing.T) { } require.NotEmpty(t, schema, "could not parse %q", def) - classified := append(append([]string{}, TerminalExecutionStatuses...), liveExecutionStatuses...) + classified := append(append([]string{}, storableTerminalExecutionStatuses()...), liveExecutionStatuses...) sort.Strings(schema) sort.Strings(classified) assert.Equal(t, schema, classified) diff --git a/internal/config/store_postgres_history_summary_integration_test.go b/internal/config/store_postgres_history_summary_integration_test.go index 19cdee86d..b4754760f 100644 --- a/internal/config/store_postgres_history_summary_integration_test.go +++ b/internal/config/store_postgres_history_summary_integration_test.go @@ -88,7 +88,7 @@ func (f *historySummaryFixture) seed(ctx context.Context, t *testing.T) { recA := []RecommendationRecord{{Provider: "aws", CloudAccountID: &a}} mixed := []RecommendationRecord{{Provider: "aws", CloudAccountID: &a}, {Provider: "azure", CloudAccountID: &b}} statuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused", "failed", "expired", - StatusCanceled, LegacyStatusCanceled, "partially_completed"} + StatusCanceled, "partially_completed"} for i, st := range statuses { at := f.base.Add(time.Duration(i) * 36 * time.Hour) f.saveExecution(ctx, t, at, st, "", &a, nil, recA) @@ -197,7 +197,7 @@ func TestIntegration_HistorySummaryAggregate_MatchesRowFetch(t *testing.T) { } actionableStatuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused"} statuses := []string{"pending", "notified", "scheduled", "approved", "running", "paused", - "completed", "partially_completed", "failed", "expired", StatusCanceled, LegacyStatusCanceled} + "completed", "partially_completed", "failed", "expired", StatusCanceled} for name, filter := range filters { t.Run(name, func(t *testing.T) { agg, err := f.store.HistorySummaryAggregate(ctx, HistorySummaryFilter{ diff --git a/internal/config/store_postgres_ladder_resolve_integration_test.go b/internal/config/store_postgres_ladder_resolve_integration_test.go index c1cf301bf..78fb16904 100644 --- a/internal/config/store_postgres_ladder_resolve_integration_test.go +++ b/internal/config/store_postgres_ladder_resolve_integration_test.go @@ -91,7 +91,6 @@ func TestResolveLadderTrancheFailed_ProvenChain(t *testing.T) { for name, nodes := range map[string][]resolveNode{ "no_submission head": {{"failed", true, time.Minute}}, "canceled after proof": {{"failed", true, time.Hour}, {"canceled", false, time.Minute}}, - "legacy spelling": {{LegacyStatusCanceled, false, time.Minute}}, "expired": {{"expired", false, time.Minute}}, } { t.Run(name, func(t *testing.T) { diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go index a66dd46af..333f24e03 100644 --- a/internal/config/store_postgres_legacy_canceled_integration_test.go +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -42,7 +42,7 @@ func versionBeforeCanceledContract(t *testing.T) uint { // TestPostgresStore_NewCodeAgainstPreContractSchema runs the new store code on // the schema before 000158 with rows an older build wrote: the legacy status -// spelling and an actor only in cancelled_by. Every reader must treat them as +// spelling and an actor only in the legacy column. Every reader must treat them as // canceled with their actor, and the new writers must not lose them. func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) @@ -82,7 +82,7 @@ func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { got, err := store.GetExecutionByID(ctx, legacy) require.NoError(t, err) assert.Equal(t, LegacyStatusCanceled, got.Status) - require.NotNil(t, got.CancelledBy, "a cancelled_by-only actor must scan back") + require.NotNil(t, got.CancelledBy, "a legacy-column-only actor must scan back") assert.Equal(t, "legacy@example.com", *got.CancelledBy) }) @@ -152,5 +152,4 @@ func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { _, err = store.GetExecutionByID(ctx, legacy) assert.NoError(t, err, "a fresh legacy row survives") }) - } diff --git a/internal/config/store_postgres_ramp_baseline_integration_test.go b/internal/config/store_postgres_ramp_baseline_integration_test.go index f37fcd1f9..00747bcee 100644 --- a/internal/config/store_postgres_ramp_baseline_integration_test.go +++ b/internal/config/store_postgres_ramp_baseline_integration_test.go @@ -71,9 +71,8 @@ func TestListPlanCommittedTx_RealRowShapes(t *testing.T) { require.NoError(t, f.store.SavePurchaseExecution(ctx, retry)) f.rowWithRecs(ctx, t, 2, a2, StatusCompleted, false, r2) - // Step 3: canceled, legacy-canceled and a failed row proven unsubmitted hold nothing. + // Step 3: canceled and a failed row proven unsubmitted hold nothing. f.rowWithRecs(ctx, t, 3, a1, StatusCanceled, false, r1) - f.rowWithRecs(ctx, t, 3, a1, LegacyStatusCanceled, false, r1) f.rowWithRecs(ctx, t, 3, a2, StatusFailed, true, r2) got := f.committed(ctx, t) diff --git a/internal/config/store_postgres_ramp_step_integration_test.go b/internal/config/store_postgres_ramp_step_integration_test.go index 1e18b71c0..00ff0b021 100644 --- a/internal/config/store_postgres_ramp_step_integration_test.go +++ b/internal/config/store_postgres_ramp_step_integration_test.go @@ -544,7 +544,7 @@ func TestRampStepStatusListsPartitionEveryWrittenStatus(t *testing.T) { written := []string{ "pending", "notified", "scheduled", "approved", "running", "paused", StatusCompleted, StatusPartiallyCompleted, StatusFailed, StatusExpired, - StatusCanceled, LegacyStatusCanceled, + StatusCanceled, } // In-flight statuses hold a step open on purpose and are deliberately not // reported as stuck; they are listed here so adding a status to the schema diff --git a/internal/config/store_postgres_retention_integration_test.go b/internal/config/store_postgres_retention_integration_test.go index 99922d956..a6eaa5e91 100644 --- a/internal/config/store_postgres_retention_integration_test.go +++ b/internal/config/store_postgres_retention_integration_test.go @@ -30,7 +30,6 @@ func TestRampRetention_OldCleanupWritersPreserveHeldRows(t *testing.T) { {StatusFailed, false, `[]`}, {StatusFailed, true, `[]`}, {StatusCanceled, false, `[]`}, - {LegacyStatusCanceled, false, `[]`}, {StatusExpired, false, `[]`}, } for _, tc := range cases { @@ -48,7 +47,7 @@ func TestRampRetention_OldCleanupWritersPreserveHeldRows(t *testing.T) { require.Equal(t, holdsCapacity(&PurchaseExecution{Status: "future-status"}), future) deleted, err := f.store.CleanupOldExecutions(ctx, 30) require.NoError(t, err) - require.EqualValues(t, 3, deleted, "only canceled spellings and expired are eligible and unheld") + require.EqualValues(t, 2, deleted, "only canceled and expired are eligible and unheld") // A raw expires_at DELETE, the shape the deleted cmd/cleanup-lambda ran // (#357) and an operator could still run by hand: the ramp ledger trigger, // not the caller's predicate, must keep held rows. diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go index fa8d9f13b..069895480 100644 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go +++ b/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go @@ -169,8 +169,10 @@ func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { // Write statements copied from origin/main (internal/config/store_postgres.go // at the time of #1253): the build that is live when this migration lands -// writes cancelled_by, so none of them may hit the narrowed CHECKs and the -// actor must read back through COALESCE(canceled_by, cancelled_by). +// writes the legacy actor column, so none of them may hit the narrowed CHECKs +// and the actor must read back through COALESCE over both columns. +// +//nolint:misspell // the SQL deliberately names the legacy British column under test const ( oldSaveExecution158 = ` INSERT INTO purchase_executions ( diff --git a/internal/purchase/ladder_reconcile_integration_test.go b/internal/purchase/ladder_reconcile_integration_test.go index 595cd2740..0228e92f6 100644 --- a/internal/purchase/ladder_reconcile_integration_test.go +++ b/internal/purchase/ladder_reconcile_integration_test.go @@ -127,7 +127,6 @@ func TestReconcileLadderTranches_Mappings(t *testing.T) { {"failed without proof (reaper / outcome unknown) keeps netting", "failed", false, true, "fired", ""}, {"proof flag on a running row is ignored", "running", true, true, "fired", ""}, {"canceled approval", "canceled", false, true, "failed", "approval_canceled"}, - {"legacy canceled spelling", config.LegacyStatusCanceled, false, true, "failed", "approval_canceled"}, {"expired approval", "expired", false, true, "failed", "approval_expired"}, {"partially completed stays fired", "partially_completed", false, true, "fired", ""}, {"completed and visible", "completed", false, true, "completed", "commitment_visible"}, From 5c42278a5227ef3114e2e56d036bf1985d7f4092 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 22:33:40 +0100 Subject: [PATCH 12/19] 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 --- known_issues/40_cancel_actor_strict_reads.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 known_issues/40_cancel_actor_strict_reads.md diff --git a/known_issues/40_cancel_actor_strict_reads.md b/known_issues/40_cancel_actor_strict_reads.md new file mode 100644 index 000000000..b6ae91607 --- /dev/null +++ b/known_issues/40_cancel_actor_strict_reads.md @@ -0,0 +1,15 @@ +# Known Issues: execution reads that skip the legacy cancel actor (#47) + +> **Audit status (2026-11-04):** `1 needs triage` + +## LOW: two ramp reads select canceled_by without the legacy cancelled_by fallback + +**Files:** `internal/config/store_postgres_ramp_baseline.go` (`ListPlanCommittedTx`), `internal/config/store_postgres_ramp_step.go` (`ListExecutionsForPlanStepTx`) + +**Description:** Every other execution projection reads the cancel actor as `COALESCE(canceled_by, cancelled_by)`. These two select `canceled_by` alone. A legacy row whose actor was only ever written to `cancelled_by` (by builds before #1253) comes back with no actor here. Both callers decide by `status`, not by the actor, so no ramp, capacity or purchase decision changes. Only the `CancelledBy` field on the returned rows is empty. + +**Why deferred:** Pre-existing, from before #1253, and it doesn't affect any decision. The final step of the rename drains `cancelled_by` into `canceled_by` before dropping the column, and that fixes these rows at the source. + +**Issue:** #1747 (follow-up of #47 and #1253) + +**Status:** needs triage From bc919da200c8f8c36d14c095d6ec172923234f83 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 23:55:52 +0100 Subject: [PATCH 13/19] 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 --- ...urchases_legacy_cancel_integration_test.go | 4 +- ...ostgres_delete_account_integration_test.go | 4 +- ...stgres_legacy_canceled_integration_test.go | 6 +- ...00159_contract_canceled_spelling.down.sql} | 2 +- ... 000159_contract_canceled_spelling.up.sql} | 2 +- ...000159_contract_canceled_spelling_test.go} | 134 +++++++++--------- 6 files changed, 76 insertions(+), 76 deletions(-) rename internal/database/postgres/migrations/{000158_contract_canceled_spelling.down.sql => 000159_contract_canceled_spelling.down.sql} (93%) rename internal/database/postgres/migrations/{000158_contract_canceled_spelling.up.sql => 000159_contract_canceled_spelling.up.sql} (97%) rename internal/database/postgres/migrations/{000158_contract_canceled_spelling_test.go => 000159_contract_canceled_spelling_test.go} (70%) diff --git a/internal/api/handler_purchases_legacy_cancel_integration_test.go b/internal/api/handler_purchases_legacy_cancel_integration_test.go index e9e2db28f..3b3da7365 100644 --- a/internal/api/handler_purchases_legacy_cancel_integration_test.go +++ b/internal/api/handler_purchases_legacy_cancel_integration_test.go @@ -17,7 +17,7 @@ import ( ) // TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration runs -// the cancel-race recovery on the schema before 000158 against a row an older +// the cancel-race recovery on the schema before 000159 against a row an older // build canceled (legacy status spelling, actor only in the legacy column). The // build and the migration roll out in either order, so the retry must see the // race as already won, return the canonical status and keep the actor. @@ -32,7 +32,7 @@ func TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration(t *te var before uint for _, e := range entries { v, parseErr := strconv.ParseUint(strings.SplitN(e.Name(), "_", 2)[0], 10, 32) - if parseErr == nil && v < 158 && uint(v) > before { + if parseErr == nil && v < 159 && uint(v) > before { before = uint(v) } } diff --git a/internal/config/store_postgres_delete_account_integration_test.go b/internal/config/store_postgres_delete_account_integration_test.go index b05bcbebf..7a72fccb5 100644 --- a/internal/config/store_postgres_delete_account_integration_test.go +++ b/internal/config/store_postgres_delete_account_integration_test.go @@ -78,9 +78,9 @@ func accountExists(ctx context.Context, t *testing.T, store *PostgresStore, id s } // storableTerminalExecutionStatuses is TerminalExecutionStatuses minus the -// legacy canceled spelling, which migration 000158 forbids in the status CHECK +// legacy canceled spelling, which migration 000159 forbids in the status CHECK // on a fully migrated schema. Legacy rows are covered against the schema before -// 000158 in TestPostgresStore_NewCodeAgainstPreContractSchema. +// 000159 in TestPostgresStore_NewCodeAgainstPreContractSchema. func storableTerminalExecutionStatuses() []string { return slices.DeleteFunc(slices.Clone(TerminalExecutionStatuses), func(s string) bool { return s == LegacyStatusCanceled diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go index 333f24e03..ad03b6832 100644 --- a/internal/config/store_postgres_legacy_canceled_integration_test.go +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -22,7 +22,7 @@ import ( const legacyActorColumn = "cancel" + "led_by" // versionBeforeCanceledContract is the schema a deployment is at when the -// build under test starts: the highest migration below 000158, the contract +// build under test starts: the highest migration below 000159, the contract // that narrows the status CHECKs. The new build must read and write correctly // there, because the migration and the release roll out in either order. func versionBeforeCanceledContract(t *testing.T) uint { @@ -32,7 +32,7 @@ func versionBeforeCanceledContract(t *testing.T) uint { var best uint for _, e := range entries { v, err := strconv.ParseUint(strings.SplitN(e.Name(), "_", 2)[0], 10, 32) - if err == nil && v < 158 && uint(v) > best { + if err == nil && v < 159 && uint(v) > best { best = uint(v) } } @@ -41,7 +41,7 @@ func versionBeforeCanceledContract(t *testing.T) uint { } // TestPostgresStore_NewCodeAgainstPreContractSchema runs the new store code on -// the schema before 000158 with rows an older build wrote: the legacy status +// the schema before 000159 with rows an older build wrote: the legacy status // spelling and an actor only in the legacy column. Every reader must treat them as // canceled with their actor, and the new writers must not lose them. func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql b/internal/database/postgres/migrations/000159_contract_canceled_spelling.down.sql similarity index 93% rename from internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql rename to internal/database/postgres/migrations/000159_contract_canceled_spelling.down.sql index 9267664f8..9d5b977b5 100644 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling.down.sql +++ b/internal/database/postgres/migrations/000159_contract_canceled_spelling.down.sql @@ -1,4 +1,4 @@ --- Rollback 000158: re-widen both status CHECKs to the 000089 EXPAND state. +-- Rollback 000159: re-widen both status CHECKs to the 000089 EXPAND state. -- -- Rows stay 'canceled': the widened CHECKs accept both spellings and every -- build since #1277 reads both, so converting them back is unnecessary. diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql b/internal/database/postgres/migrations/000159_contract_canceled_spelling.up.sql similarity index 97% rename from internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql rename to internal/database/postgres/migrations/000159_contract_canceled_spelling.up.sql index e4f02dbdb..5ac7ef526 100644 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling.up.sql +++ b/internal/database/postgres/migrations/000159_contract_canceled_spelling.up.sql @@ -1,4 +1,4 @@ --- Migration 000158: CONTRACT half of the 000089 'cancelled' -> 'canceled' +-- Migration 000159: CONTRACT half of the 000089 'cancelled' -> 'canceled' -- rename (issue #47), part 1 of 2. -- -- 000089 (EXPAND) widened both status CHECKs to accept both spellings, added diff --git a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000159_contract_canceled_spelling_test.go similarity index 70% rename from internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go rename to internal/database/postgres/migrations/000159_contract_canceled_spelling_test.go index 069895480..acc6119f3 100644 --- a/internal/database/postgres/migrations/000158_contract_canceled_spelling_test.go +++ b/internal/database/postgres/migrations/000159_contract_canceled_spelling_test.go @@ -19,29 +19,29 @@ import ( // The legacy spellings are built by concatenation so the US-locale misspell // linter does not flag them; they are the values under test. const ( - contract158 = 158 - legacyStatus158 = "cancel" + "led" - legacyActor158 = "cancel" + "led_by" - canceled158 = "canceled" + contract159 = 159 + legacyStatus159 = "cancel" + "led" + legacyActor159 = "cancel" + "led_by" + canceled159 = "canceled" ) -// legacyInCheck158 matches the legacy spelling's entry in a status CHECK +// legacyInCheck159 matches the legacy spelling's entry in a status CHECK // definition as pg_get_constraintdef renders it (the cast follows the column // type: text or character varying). -var legacyInCheck158 = regexp.MustCompile(`'` + legacyStatus158 + `'::[a-z ]+, `) +var legacyInCheck159 = regexp.MustCompile(`'` + legacyStatus159 + `'::[a-z ]+, `) -const insertExecution158 = ` - INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor158 + `, updated_at) +const insertExecution159 = ` + INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor159 + `, updated_at) VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` -func seedExecution158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { +func seedExecution159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { t.Helper() var id string - require.NoError(t, pool.QueryRow(ctx, insertExecution158, status, legacyBy, updatedAt).Scan(&id)) + require.NoError(t, pool.QueryRow(ctx, insertExecution159, status, legacyBy, updatedAt).Scan(&id)) return id } -func insertExchange158(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { +func insertExchange159(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { var id string err := pool.QueryRow(ctx, ` INSERT INTO ri_exchange_history @@ -53,7 +53,7 @@ func insertExchange158(ctx context.Context, pool *pgxpool.Pool, status string, u return id, err } -func statusCheckDef158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { +func statusCheckDef159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { t.Helper() var def string require.NoError(t, pool.QueryRow(ctx, @@ -61,7 +61,7 @@ func statusCheckDef158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, co return def } -func updatedAt158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { +func updatedAt159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { t.Helper() idCol := map[string]string{"purchase_executions": "execution_id", "ri_exchange_history": "id"}[table] var ts time.Time @@ -70,16 +70,16 @@ func updatedAt158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, return ts } -// readActor158 reads the actor the way every projection does. -func readActor158(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { +// readActor159 reads the actor the way every projection does. +func readActor159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { t.Helper() require.NoError(t, pool.QueryRow(ctx, - `SELECT status, COALESCE(canceled_by, `+legacyActor158+`) FROM purchase_executions WHERE execution_id = $1`, id, + `SELECT status, COALESCE(canceled_by, `+legacyActor159+`) FROM purchase_executions WHERE execution_id = $1`, id, ).Scan(&status, &actor)) return status, actor } -func requireCheckViolation158(t *testing.T, err error, constraint string) { +func requireCheckViolation159(t *testing.T, err error, constraint string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) @@ -87,54 +87,54 @@ func requireCheckViolation158(t *testing.T, err error, constraint string) { assert.Equal(t, constraint, pgErr.ConstraintName) } -func s158(v string) *string { return &v } +func s159(v string) *string { return &v } -// TestMigration_000158_ContractCanceledSpelling locks down the contract half of +// TestMigration_000159_ContractCanceledSpelling locks down the contract half of // the 000089 rename (issue #47): legacy status rows are normalized without // restamping updated_at (the plan-health and retention clock), both status // CHECKs lose exactly the legacy spelling, the down restores the widened // CHECKs exactly, and the write statements of the build before this release // (which still write the legacy actor column) keep working against the // narrowed schema. -func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { +func TestMigration_000159_ContractCanceledSpelling(t *testing.T) { ctx := context.Background() container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - prev := previousMigrationVersion(t, contract158) + prev := previousMigrationVersion(t, contract159) require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - peBefore := statusCheckDef158(ctx, t, pool, "purchase_executions_status_check") - riBefore := statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check") - require.Len(t, legacyInCheck158.FindAllString(peBefore, -1), 1, peBefore) - require.Len(t, legacyInCheck158.FindAllString(riBefore, -1), 1, riBefore) + peBefore := statusCheckDef159(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef159(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck159.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck159.FindAllString(riBefore, -1), 1, riBefore) old := time.Now().UTC().AddDate(0, 0, -90).Truncate(time.Microsecond) - legacyExec := seedExecution158(ctx, t, pool, legacyStatus158, s158("legacy@example.com"), old) - failedExec := seedExecution158(ctx, t, pool, "failed", nil, old) - legacyExchange, err := insertExchange158(ctx, pool, legacyStatus158, old) + legacyExec := seedExecution159(ctx, t, pool, legacyStatus159, s159("legacy@example.com"), old) + failedExec := seedExecution159(ctx, t, pool, "failed", nil, old) + legacyExchange, err := insertExchange159(ctx, pool, legacyStatus159, old) require.NoError(t, err) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract159)) // Data: the legacy status is normalized and the legacy actor column is // left alone (this release still reads it); other rows are untouched. - status, actor := readActor158(ctx, t, pool, legacyExec) - assert.Equal(t, canceled158, status) + status, actor := readActor159(ctx, t, pool, legacyExec) + assert.Equal(t, canceled159, status) require.NotNil(t, actor) assert.Equal(t, "legacy@example.com", *actor) - status, _ = readActor158(ctx, t, pool, failedExec) + status, _ = readActor159(ctx, t, pool, failedExec) assert.Equal(t, "failed", status) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, legacyExchange).Scan(&exchangeStatus)) - assert.Equal(t, canceled158, exchangeStatus) + assert.Equal(t, canceled159, exchangeStatus) // updated_at is the plan-health and retention clock: a spelling change // must not make 90-day-old rows look fresh. Each table has its own // updated_at trigger, so each is asserted on its own. - assert.True(t, old.Equal(updatedAt158(ctx, t, pool, "purchase_executions", legacyExec)), + assert.True(t, old.Equal(updatedAt159(ctx, t, pool, "purchase_executions", legacyExec)), "purchase_executions.updated_at must survive the normalization") - assert.True(t, old.Equal(updatedAt158(ctx, t, pool, "ri_exchange_history", legacyExchange)), + assert.True(t, old.Equal(updatedAt159(ctx, t, pool, "ri_exchange_history", legacyExchange)), "ri_exchange_history.updated_at must survive the normalization") var enabled int require.NoError(t, pool.QueryRow(ctx, ` @@ -145,26 +145,26 @@ func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { // Schema: each CHECK is its pre-migration definition minus the legacy // spelling, nothing else, and rejects the legacy spelling. - assert.Equal(t, legacyInCheck158.ReplaceAllString(peBefore, ""), - statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, legacyInCheck158.ReplaceAllString(riBefore, ""), - statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) - _, err = pool.Exec(ctx, insertExecution158, legacyStatus158, nil, old) - requireCheckViolation158(t, err, "purchase_executions_status_check") - _, err = insertExchange158(ctx, pool, legacyStatus158, old) - requireCheckViolation158(t, err, "ri_exchange_history_status_check") + assert.Equal(t, legacyInCheck159.ReplaceAllString(peBefore, ""), + statusCheckDef159(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck159.ReplaceAllString(riBefore, ""), + statusCheckDef159(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertExecution159, legacyStatus159, nil, old) + requireCheckViolation159(t, err, "purchase_executions_status_check") + _, err = insertExchange159(ctx, pool, legacyStatus159, old) + requireCheckViolation159(t, err, "ri_exchange_history_status_check") - requireOldBuildWritesStillWork158(ctx, t, pool) + requireOldBuildWritesStillWork159(ctx, t, pool) // Down: the widened CHECKs come back byte for byte. require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - assert.Equal(t, peBefore, statusCheckDef158(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, riBefore, statusCheckDef158(ctx, t, pool, "ri_exchange_history_status_check")) - seedExecution158(ctx, t, pool, legacyStatus158, nil, old) - _, err = insertExchange158(ctx, pool, legacyStatus158, old) + assert.Equal(t, peBefore, statusCheckDef159(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef159(ctx, t, pool, "ri_exchange_history_status_check")) + seedExecution159(ctx, t, pool, legacyStatus159, nil, old) + _, err = insertExchange159(ctx, pool, legacyStatus159, old) require.NoError(t, err, "down must accept the legacy spelling again") - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract158), "up must re-apply cleanly after down") + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract159), "up must re-apply cleanly after down") } // Write statements copied from origin/main (internal/config/store_postgres.go @@ -174,7 +174,7 @@ func TestMigration_000158_ContractCanceledSpelling(t *testing.T) { // //nolint:misspell // the SQL deliberately names the legacy British column under test const ( - oldSaveExecution158 = ` + oldSaveExecution159 = ` INSERT INTO purchase_executions ( plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, @@ -209,28 +209,28 @@ const ( THEN purchase_executions.no_submission OR $28 ELSE false END, updated_at = NOW()` - oldSetCancelledBy158 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() + oldSetCancelledBy159 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() WHERE execution_id = $1` - oldTransition158 = ` + oldTransition159 = ` UPDATE purchase_executions SET status = $2, updated_at = NOW(), transitioned_by = $4, transitioned_at = NOW(), canceled_by = COALESCE((SELECT email FROM users WHERE id = $5), canceled_by), cancelled_by = COALESCE((SELECT email FROM users WHERE id = $5), cancelled_by) WHERE execution_id = $1 AND status = ANY($3)` - oldCancelAllExchanges158 = ` + oldCancelAllExchanges159 = ` UPDATE ri_exchange_history SET status = $1, updated_at = NOW() WHERE status = 'pending'` ) -func requireOldBuildWritesStillWork158(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { +func requireOldBuildWritesStillWork159(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { t.Helper() now := time.Now().UTC() var execID string require.NoError(t, pool.QueryRow(ctx, `SELECT gen_random_uuid()::text`).Scan(&execID)) save := func(status, actor string) error { - _, err := pool.Exec(ctx, oldSaveExecution158, + _, err := pool.Exec(ctx, oldSaveExecution159, nil, execID, status, 1, now, nil, nil, []byte(`[]`), 0, 0, nil, "", nil, nil, "web", nil, actor, 100, nil, nil, 0, nil, nil, nil, nil, nil, nil, false, false) return err @@ -239,17 +239,17 @@ func requireOldBuildWritesStillWork158(ctx context.Context, t *testing.T, pool * // SavePurchaseExecutionTx: insert, then the ON CONFLICT update, both with // the canonical status and the legacy actor column only. require.NoError(t, save("pending", ""), "old upsert insert path") - require.NoError(t, save(canceled158, "old-save@example.com"), "old upsert update path") - status, actor := readActor158(ctx, t, pool, execID) - assert.Equal(t, canceled158, status) + require.NoError(t, save(canceled159, "old-save@example.com"), "old upsert update path") + status, actor := readActor159(ctx, t, pool, execID) + assert.Equal(t, canceled159, status) require.NotNil(t, actor, "an actor written to the legacy column must read back") assert.Equal(t, "old-save@example.com", *actor) // SetCancelledBy. - ct, err := pool.Exec(ctx, oldSetCancelledBy158, execID, "set-by@example.com") + ct, err := pool.Exec(ctx, oldSetCancelledBy159, execID, "set-by@example.com") require.NoError(t, err) require.EqualValues(t, 1, ct.RowsAffected()) - _, actor = readActor158(ctx, t, pool, execID) + _, actor = readActor159(ctx, t, pool, execID) require.NotNil(t, actor) assert.Equal(t, "set-by@example.com", *actor) @@ -257,21 +257,21 @@ func requireOldBuildWritesStillWork158(ctx context.Context, t *testing.T, pool * var userID string require.NoError(t, pool.QueryRow(ctx, ` INSERT INTO users (email, password_hash, group_ids) VALUES ('transition@example.com', 'x', ARRAY[gen_random_uuid()]) RETURNING id::text`).Scan(&userID)) - transitionID := seedExecution158(ctx, t, pool, "pending", nil, now) - ct, err = pool.Exec(ctx, oldTransition158, transitionID, canceled158, []string{"pending"}, userID, userID) + transitionID := seedExecution159(ctx, t, pool, "pending", nil, now) + ct, err = pool.Exec(ctx, oldTransition159, transitionID, canceled159, []string{"pending"}, userID, userID) require.NoError(t, err) require.EqualValues(t, 1, ct.RowsAffected()) - status, actor = readActor158(ctx, t, pool, transitionID) - assert.Equal(t, canceled158, status) + status, actor = readActor159(ctx, t, pool, transitionID) + assert.Equal(t, canceled159, status) require.NotNil(t, actor) assert.Equal(t, "transition@example.com", *actor) // CancelAllPendingExchanges. - exchangeID, err := insertExchange158(ctx, pool, "pending", now) + exchangeID, err := insertExchange159(ctx, pool, "pending", now) require.NoError(t, err) - _, err = pool.Exec(ctx, oldCancelAllExchanges158, canceled158) + _, err = pool.Exec(ctx, oldCancelAllExchanges159, canceled159) require.NoError(t, err) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchangeID).Scan(&exchangeStatus)) - assert.Equal(t, canceled158, exchangeStatus) + assert.Equal(t, canceled159, exchangeStatus) } From 7bae18e5a8ea3473458de3f83c437ecf68851cc6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 5 Nov 2026 15:31:48 +0100 Subject: [PATCH 14/19] 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 --- ...urchases_legacy_cancel_integration_test.go | 2 +- ...ostgres_delete_account_integration_test.go | 4 +- ...stgres_legacy_canceled_integration_test.go | 4 +- ...00155_contract_canceled_spelling.down.sql} | 2 +- ... 000155_contract_canceled_spelling.up.sql} | 2 +- ...000155_contract_canceled_spelling_test.go} | 134 +++++++++--------- ...ads.md => 41_cancel_actor_strict_reads.md} | 0 7 files changed, 74 insertions(+), 74 deletions(-) rename internal/database/postgres/migrations/{000159_contract_canceled_spelling.down.sql => 000155_contract_canceled_spelling.down.sql} (93%) rename internal/database/postgres/migrations/{000159_contract_canceled_spelling.up.sql => 000155_contract_canceled_spelling.up.sql} (97%) rename internal/database/postgres/migrations/{000159_contract_canceled_spelling_test.go => 000155_contract_canceled_spelling_test.go} (70%) rename known_issues/{40_cancel_actor_strict_reads.md => 41_cancel_actor_strict_reads.md} (100%) diff --git a/internal/api/handler_purchases_legacy_cancel_integration_test.go b/internal/api/handler_purchases_legacy_cancel_integration_test.go index 3b3da7365..264986661 100644 --- a/internal/api/handler_purchases_legacy_cancel_integration_test.go +++ b/internal/api/handler_purchases_legacy_cancel_integration_test.go @@ -17,7 +17,7 @@ import ( ) // TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration runs -// the cancel-race recovery on the schema before 000159 against a row an older +// the cancel-race recovery on the schema before 000155 against a row an older // build canceled (legacy status spelling, actor only in the legacy column). The // build and the migration roll out in either order, so the retry must see the // race as already won, return the canonical status and keep the actor. diff --git a/internal/config/store_postgres_delete_account_integration_test.go b/internal/config/store_postgres_delete_account_integration_test.go index 7a72fccb5..8b5a1281c 100644 --- a/internal/config/store_postgres_delete_account_integration_test.go +++ b/internal/config/store_postgres_delete_account_integration_test.go @@ -78,9 +78,9 @@ func accountExists(ctx context.Context, t *testing.T, store *PostgresStore, id s } // storableTerminalExecutionStatuses is TerminalExecutionStatuses minus the -// legacy canceled spelling, which migration 000159 forbids in the status CHECK +// legacy canceled spelling, which migration 000155 forbids in the status CHECK // on a fully migrated schema. Legacy rows are covered against the schema before -// 000159 in TestPostgresStore_NewCodeAgainstPreContractSchema. +// 000155 in TestPostgresStore_NewCodeAgainstPreContractSchema. func storableTerminalExecutionStatuses() []string { return slices.DeleteFunc(slices.Clone(TerminalExecutionStatuses), func(s string) bool { return s == LegacyStatusCanceled diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go index ad03b6832..027c018dd 100644 --- a/internal/config/store_postgres_legacy_canceled_integration_test.go +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -22,7 +22,7 @@ import ( const legacyActorColumn = "cancel" + "led_by" // versionBeforeCanceledContract is the schema a deployment is at when the -// build under test starts: the highest migration below 000159, the contract +// build under test starts: the highest migration below 000155, the contract // that narrows the status CHECKs. The new build must read and write correctly // there, because the migration and the release roll out in either order. func versionBeforeCanceledContract(t *testing.T) uint { @@ -41,7 +41,7 @@ func versionBeforeCanceledContract(t *testing.T) uint { } // TestPostgresStore_NewCodeAgainstPreContractSchema runs the new store code on -// the schema before 000159 with rows an older build wrote: the legacy status +// the schema before 000155 with rows an older build wrote: the legacy status // spelling and an actor only in the legacy column. Every reader must treat them as // canceled with their actor, and the new writers must not lose them. func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { diff --git a/internal/database/postgres/migrations/000159_contract_canceled_spelling.down.sql b/internal/database/postgres/migrations/000155_contract_canceled_spelling.down.sql similarity index 93% rename from internal/database/postgres/migrations/000159_contract_canceled_spelling.down.sql rename to internal/database/postgres/migrations/000155_contract_canceled_spelling.down.sql index 9d5b977b5..0819d85c8 100644 --- a/internal/database/postgres/migrations/000159_contract_canceled_spelling.down.sql +++ b/internal/database/postgres/migrations/000155_contract_canceled_spelling.down.sql @@ -1,4 +1,4 @@ --- Rollback 000159: re-widen both status CHECKs to the 000089 EXPAND state. +-- Rollback 000155: re-widen both status CHECKs to the 000089 EXPAND state. -- -- Rows stay 'canceled': the widened CHECKs accept both spellings and every -- build since #1277 reads both, so converting them back is unnecessary. diff --git a/internal/database/postgres/migrations/000159_contract_canceled_spelling.up.sql b/internal/database/postgres/migrations/000155_contract_canceled_spelling.up.sql similarity index 97% rename from internal/database/postgres/migrations/000159_contract_canceled_spelling.up.sql rename to internal/database/postgres/migrations/000155_contract_canceled_spelling.up.sql index 5ac7ef526..7f22b752a 100644 --- a/internal/database/postgres/migrations/000159_contract_canceled_spelling.up.sql +++ b/internal/database/postgres/migrations/000155_contract_canceled_spelling.up.sql @@ -1,4 +1,4 @@ --- Migration 000159: CONTRACT half of the 000089 'cancelled' -> 'canceled' +-- Migration 000155: CONTRACT half of the 000089 'cancelled' -> 'canceled' -- rename (issue #47), part 1 of 2. -- -- 000089 (EXPAND) widened both status CHECKs to accept both spellings, added diff --git a/internal/database/postgres/migrations/000159_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000155_contract_canceled_spelling_test.go similarity index 70% rename from internal/database/postgres/migrations/000159_contract_canceled_spelling_test.go rename to internal/database/postgres/migrations/000155_contract_canceled_spelling_test.go index acc6119f3..63fa8bb47 100644 --- a/internal/database/postgres/migrations/000159_contract_canceled_spelling_test.go +++ b/internal/database/postgres/migrations/000155_contract_canceled_spelling_test.go @@ -19,29 +19,29 @@ import ( // The legacy spellings are built by concatenation so the US-locale misspell // linter does not flag them; they are the values under test. const ( - contract159 = 159 - legacyStatus159 = "cancel" + "led" - legacyActor159 = "cancel" + "led_by" - canceled159 = "canceled" + contract155 = 155 + legacyStatus155 = "cancel" + "led" + legacyActor155 = "cancel" + "led_by" + canceled155 = "canceled" ) -// legacyInCheck159 matches the legacy spelling's entry in a status CHECK +// legacyInCheck155 matches the legacy spelling's entry in a status CHECK // definition as pg_get_constraintdef renders it (the cast follows the column // type: text or character varying). -var legacyInCheck159 = regexp.MustCompile(`'` + legacyStatus159 + `'::[a-z ]+, `) +var legacyInCheck155 = regexp.MustCompile(`'` + legacyStatus155 + `'::[a-z ]+, `) -const insertExecution159 = ` - INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor159 + `, updated_at) +const insertExecution155 = ` + INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor155 + `, updated_at) VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` -func seedExecution159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { +func seedExecution155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { t.Helper() var id string - require.NoError(t, pool.QueryRow(ctx, insertExecution159, status, legacyBy, updatedAt).Scan(&id)) + require.NoError(t, pool.QueryRow(ctx, insertExecution155, status, legacyBy, updatedAt).Scan(&id)) return id } -func insertExchange159(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { +func insertExchange155(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { var id string err := pool.QueryRow(ctx, ` INSERT INTO ri_exchange_history @@ -53,7 +53,7 @@ func insertExchange159(ctx context.Context, pool *pgxpool.Pool, status string, u return id, err } -func statusCheckDef159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { +func statusCheckDef155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { t.Helper() var def string require.NoError(t, pool.QueryRow(ctx, @@ -61,7 +61,7 @@ func statusCheckDef159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, co return def } -func updatedAt159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { +func updatedAt155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { t.Helper() idCol := map[string]string{"purchase_executions": "execution_id", "ri_exchange_history": "id"}[table] var ts time.Time @@ -70,16 +70,16 @@ func updatedAt159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, return ts } -// readActor159 reads the actor the way every projection does. -func readActor159(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { +// readActor155 reads the actor the way every projection does. +func readActor155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { t.Helper() require.NoError(t, pool.QueryRow(ctx, - `SELECT status, COALESCE(canceled_by, `+legacyActor159+`) FROM purchase_executions WHERE execution_id = $1`, id, + `SELECT status, COALESCE(canceled_by, `+legacyActor155+`) FROM purchase_executions WHERE execution_id = $1`, id, ).Scan(&status, &actor)) return status, actor } -func requireCheckViolation159(t *testing.T, err error, constraint string) { +func requireCheckViolation155(t *testing.T, err error, constraint string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) @@ -87,54 +87,54 @@ func requireCheckViolation159(t *testing.T, err error, constraint string) { assert.Equal(t, constraint, pgErr.ConstraintName) } -func s159(v string) *string { return &v } +func s155(v string) *string { return &v } -// TestMigration_000159_ContractCanceledSpelling locks down the contract half of +// TestMigration_000155_ContractCanceledSpelling locks down the contract half of // the 000089 rename (issue #47): legacy status rows are normalized without // restamping updated_at (the plan-health and retention clock), both status // CHECKs lose exactly the legacy spelling, the down restores the widened // CHECKs exactly, and the write statements of the build before this release // (which still write the legacy actor column) keep working against the // narrowed schema. -func TestMigration_000159_ContractCanceledSpelling(t *testing.T) { +func TestMigration_000155_ContractCanceledSpelling(t *testing.T) { ctx := context.Background() container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - prev := previousMigrationVersion(t, contract159) + prev := previousMigrationVersion(t, contract155) require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - peBefore := statusCheckDef159(ctx, t, pool, "purchase_executions_status_check") - riBefore := statusCheckDef159(ctx, t, pool, "ri_exchange_history_status_check") - require.Len(t, legacyInCheck159.FindAllString(peBefore, -1), 1, peBefore) - require.Len(t, legacyInCheck159.FindAllString(riBefore, -1), 1, riBefore) + peBefore := statusCheckDef155(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef155(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck155.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck155.FindAllString(riBefore, -1), 1, riBefore) old := time.Now().UTC().AddDate(0, 0, -90).Truncate(time.Microsecond) - legacyExec := seedExecution159(ctx, t, pool, legacyStatus159, s159("legacy@example.com"), old) - failedExec := seedExecution159(ctx, t, pool, "failed", nil, old) - legacyExchange, err := insertExchange159(ctx, pool, legacyStatus159, old) + legacyExec := seedExecution155(ctx, t, pool, legacyStatus155, s155("legacy@example.com"), old) + failedExec := seedExecution155(ctx, t, pool, "failed", nil, old) + legacyExchange, err := insertExchange155(ctx, pool, legacyStatus155, old) require.NoError(t, err) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract159)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract155)) // Data: the legacy status is normalized and the legacy actor column is // left alone (this release still reads it); other rows are untouched. - status, actor := readActor159(ctx, t, pool, legacyExec) - assert.Equal(t, canceled159, status) + status, actor := readActor155(ctx, t, pool, legacyExec) + assert.Equal(t, canceled155, status) require.NotNil(t, actor) assert.Equal(t, "legacy@example.com", *actor) - status, _ = readActor159(ctx, t, pool, failedExec) + status, _ = readActor155(ctx, t, pool, failedExec) assert.Equal(t, "failed", status) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, legacyExchange).Scan(&exchangeStatus)) - assert.Equal(t, canceled159, exchangeStatus) + assert.Equal(t, canceled155, exchangeStatus) // updated_at is the plan-health and retention clock: a spelling change // must not make 90-day-old rows look fresh. Each table has its own // updated_at trigger, so each is asserted on its own. - assert.True(t, old.Equal(updatedAt159(ctx, t, pool, "purchase_executions", legacyExec)), + assert.True(t, old.Equal(updatedAt155(ctx, t, pool, "purchase_executions", legacyExec)), "purchase_executions.updated_at must survive the normalization") - assert.True(t, old.Equal(updatedAt159(ctx, t, pool, "ri_exchange_history", legacyExchange)), + assert.True(t, old.Equal(updatedAt155(ctx, t, pool, "ri_exchange_history", legacyExchange)), "ri_exchange_history.updated_at must survive the normalization") var enabled int require.NoError(t, pool.QueryRow(ctx, ` @@ -145,26 +145,26 @@ func TestMigration_000159_ContractCanceledSpelling(t *testing.T) { // Schema: each CHECK is its pre-migration definition minus the legacy // spelling, nothing else, and rejects the legacy spelling. - assert.Equal(t, legacyInCheck159.ReplaceAllString(peBefore, ""), - statusCheckDef159(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, legacyInCheck159.ReplaceAllString(riBefore, ""), - statusCheckDef159(ctx, t, pool, "ri_exchange_history_status_check")) - _, err = pool.Exec(ctx, insertExecution159, legacyStatus159, nil, old) - requireCheckViolation159(t, err, "purchase_executions_status_check") - _, err = insertExchange159(ctx, pool, legacyStatus159, old) - requireCheckViolation159(t, err, "ri_exchange_history_status_check") + assert.Equal(t, legacyInCheck155.ReplaceAllString(peBefore, ""), + statusCheckDef155(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck155.ReplaceAllString(riBefore, ""), + statusCheckDef155(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertExecution155, legacyStatus155, nil, old) + requireCheckViolation155(t, err, "purchase_executions_status_check") + _, err = insertExchange155(ctx, pool, legacyStatus155, old) + requireCheckViolation155(t, err, "ri_exchange_history_status_check") - requireOldBuildWritesStillWork159(ctx, t, pool) + requireOldBuildWritesStillWork155(ctx, t, pool) // Down: the widened CHECKs come back byte for byte. require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - assert.Equal(t, peBefore, statusCheckDef159(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, riBefore, statusCheckDef159(ctx, t, pool, "ri_exchange_history_status_check")) - seedExecution159(ctx, t, pool, legacyStatus159, nil, old) - _, err = insertExchange159(ctx, pool, legacyStatus159, old) + assert.Equal(t, peBefore, statusCheckDef155(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef155(ctx, t, pool, "ri_exchange_history_status_check")) + seedExecution155(ctx, t, pool, legacyStatus155, nil, old) + _, err = insertExchange155(ctx, pool, legacyStatus155, old) require.NoError(t, err, "down must accept the legacy spelling again") - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract159), "up must re-apply cleanly after down") + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract155), "up must re-apply cleanly after down") } // Write statements copied from origin/main (internal/config/store_postgres.go @@ -174,7 +174,7 @@ func TestMigration_000159_ContractCanceledSpelling(t *testing.T) { // //nolint:misspell // the SQL deliberately names the legacy British column under test const ( - oldSaveExecution159 = ` + oldSaveExecution155 = ` INSERT INTO purchase_executions ( plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, @@ -209,28 +209,28 @@ const ( THEN purchase_executions.no_submission OR $28 ELSE false END, updated_at = NOW()` - oldSetCancelledBy159 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() + oldSetCancelledBy155 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() WHERE execution_id = $1` - oldTransition159 = ` + oldTransition155 = ` UPDATE purchase_executions SET status = $2, updated_at = NOW(), transitioned_by = $4, transitioned_at = NOW(), canceled_by = COALESCE((SELECT email FROM users WHERE id = $5), canceled_by), cancelled_by = COALESCE((SELECT email FROM users WHERE id = $5), cancelled_by) WHERE execution_id = $1 AND status = ANY($3)` - oldCancelAllExchanges159 = ` + oldCancelAllExchanges155 = ` UPDATE ri_exchange_history SET status = $1, updated_at = NOW() WHERE status = 'pending'` ) -func requireOldBuildWritesStillWork159(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { +func requireOldBuildWritesStillWork155(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { t.Helper() now := time.Now().UTC() var execID string require.NoError(t, pool.QueryRow(ctx, `SELECT gen_random_uuid()::text`).Scan(&execID)) save := func(status, actor string) error { - _, err := pool.Exec(ctx, oldSaveExecution159, + _, err := pool.Exec(ctx, oldSaveExecution155, nil, execID, status, 1, now, nil, nil, []byte(`[]`), 0, 0, nil, "", nil, nil, "web", nil, actor, 100, nil, nil, 0, nil, nil, nil, nil, nil, nil, false, false) return err @@ -239,17 +239,17 @@ func requireOldBuildWritesStillWork159(ctx context.Context, t *testing.T, pool * // SavePurchaseExecutionTx: insert, then the ON CONFLICT update, both with // the canonical status and the legacy actor column only. require.NoError(t, save("pending", ""), "old upsert insert path") - require.NoError(t, save(canceled159, "old-save@example.com"), "old upsert update path") - status, actor := readActor159(ctx, t, pool, execID) - assert.Equal(t, canceled159, status) + require.NoError(t, save(canceled155, "old-save@example.com"), "old upsert update path") + status, actor := readActor155(ctx, t, pool, execID) + assert.Equal(t, canceled155, status) require.NotNil(t, actor, "an actor written to the legacy column must read back") assert.Equal(t, "old-save@example.com", *actor) // SetCancelledBy. - ct, err := pool.Exec(ctx, oldSetCancelledBy159, execID, "set-by@example.com") + ct, err := pool.Exec(ctx, oldSetCancelledBy155, execID, "set-by@example.com") require.NoError(t, err) require.EqualValues(t, 1, ct.RowsAffected()) - _, actor = readActor159(ctx, t, pool, execID) + _, actor = readActor155(ctx, t, pool, execID) require.NotNil(t, actor) assert.Equal(t, "set-by@example.com", *actor) @@ -257,21 +257,21 @@ func requireOldBuildWritesStillWork159(ctx context.Context, t *testing.T, pool * var userID string require.NoError(t, pool.QueryRow(ctx, ` INSERT INTO users (email, password_hash, group_ids) VALUES ('transition@example.com', 'x', ARRAY[gen_random_uuid()]) RETURNING id::text`).Scan(&userID)) - transitionID := seedExecution159(ctx, t, pool, "pending", nil, now) - ct, err = pool.Exec(ctx, oldTransition159, transitionID, canceled159, []string{"pending"}, userID, userID) + transitionID := seedExecution155(ctx, t, pool, "pending", nil, now) + ct, err = pool.Exec(ctx, oldTransition155, transitionID, canceled155, []string{"pending"}, userID, userID) require.NoError(t, err) require.EqualValues(t, 1, ct.RowsAffected()) - status, actor = readActor159(ctx, t, pool, transitionID) - assert.Equal(t, canceled159, status) + status, actor = readActor155(ctx, t, pool, transitionID) + assert.Equal(t, canceled155, status) require.NotNil(t, actor) assert.Equal(t, "transition@example.com", *actor) // CancelAllPendingExchanges. - exchangeID, err := insertExchange159(ctx, pool, "pending", now) + exchangeID, err := insertExchange155(ctx, pool, "pending", now) require.NoError(t, err) - _, err = pool.Exec(ctx, oldCancelAllExchanges159, canceled159) + _, err = pool.Exec(ctx, oldCancelAllExchanges155, canceled155) require.NoError(t, err) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchangeID).Scan(&exchangeStatus)) - assert.Equal(t, canceled159, exchangeStatus) + assert.Equal(t, canceled155, exchangeStatus) } diff --git a/known_issues/40_cancel_actor_strict_reads.md b/known_issues/41_cancel_actor_strict_reads.md similarity index 100% rename from known_issues/40_cancel_actor_strict_reads.md rename to known_issues/41_cancel_actor_strict_reads.md From e53c962ccdf5699c6f6aea57ceb4b63660e6ea34 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 5 Nov 2026 15:34:41 +0100 Subject: [PATCH 15/19] 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 --- ...urchases_legacy_cancel_integration_test.go | 41 +++++++++++++++---- ...stgres_legacy_canceled_integration_test.go | 33 +++++++++++---- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/internal/api/handler_purchases_legacy_cancel_integration_test.go b/internal/api/handler_purchases_legacy_cancel_integration_test.go index 264986661..8d04f66e1 100644 --- a/internal/api/handler_purchases_legacy_cancel_integration_test.go +++ b/internal/api/handler_purchases_legacy_cancel_integration_test.go @@ -16,6 +16,37 @@ import ( "github.com/stretchr/testify/require" ) +// versionBeforeCanceledContract is the schema a deployment is at when the +// build under test starts: the highest migration below the canceled contract +// (the file ending _contract_canceled_spelling.up.sql), which narrows the +// status CHECKs. The cutoff is read from that file, so a renumber needs no edit +// here. The new build must read and write correctly there, because the +// migration and the release roll out in either order. +func versionBeforeCanceledContract(t *testing.T, dir string) uint { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + prefix := func(name string) (uint, bool) { + v, err := strconv.ParseUint(strings.SplitN(name, "_", 2)[0], 10, 32) + return uint(v), err == nil + } + var contract uint + for _, e := range entries { + if v, ok := prefix(e.Name()); ok && strings.HasSuffix(e.Name(), "_contract_canceled_spelling.up.sql") { + contract = v + } + } + require.NotZero(t, contract, "no *_contract_canceled_spelling.up.sql migration in %s", dir) + var best uint + for _, e := range entries { + if v, ok := prefix(e.Name()); ok && v < contract && v > best { + best = v + } + } + require.NotZero(t, best, "no migration below %d", contract) + return best +} + // TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration runs // the cancel-race recovery on the schema before 000155 against a row an older // build canceled (legacy status spelling, actor only in the legacy column). The @@ -27,15 +58,7 @@ func TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration(t *te container := testhelpers.RequireEmptyDatabase(ctx, t) pool := container.DB.Pool() path := getMigrationsPath() - entries, err := os.ReadDir(path) - require.NoError(t, err) - var before uint - for _, e := range entries { - v, parseErr := strconv.ParseUint(strings.SplitN(e.Name(), "_", 2)[0], 10, 32) - if parseErr == nil && v < 159 && uint(v) > before { - before = uint(v) - } - } + before := versionBeforeCanceledContract(t, path) require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, before)) store := config.NewPostgresStore(container.DB) diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go index 027c018dd..35bac44f5 100644 --- a/internal/config/store_postgres_legacy_canceled_integration_test.go +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -22,21 +22,38 @@ import ( const legacyActorColumn = "cancel" + "led_by" // versionBeforeCanceledContract is the schema a deployment is at when the -// build under test starts: the highest migration below 000155, the contract -// that narrows the status CHECKs. The new build must read and write correctly -// there, because the migration and the release roll out in either order. +// build under test starts: the highest migration below the canceled contract +// (the file ending _contract_canceled_spelling.up.sql), which narrows the +// status CHECKs. The cutoff is read from that file, so a renumber needs no edit +// here. The new build must read and write correctly there, because the +// migration and the release roll out in either order. func versionBeforeCanceledContract(t *testing.T) uint { t.Helper() - entries, err := os.ReadDir(getTestMigrationsPath()) + return versionBelowCanceledContract(t, getTestMigrationsPath()) +} + +func versionBelowCanceledContract(t *testing.T, dir string) uint { + t.Helper() + entries, err := os.ReadDir(dir) require.NoError(t, err) + prefix := func(name string) (uint, bool) { + v, err := strconv.ParseUint(strings.SplitN(name, "_", 2)[0], 10, 32) + return uint(v), err == nil + } + var contract uint + for _, e := range entries { + if v, ok := prefix(e.Name()); ok && strings.HasSuffix(e.Name(), "_contract_canceled_spelling.up.sql") { + contract = v + } + } + require.NotZero(t, contract, "no *_contract_canceled_spelling.up.sql migration in %s", dir) var best uint for _, e := range entries { - v, err := strconv.ParseUint(strings.SplitN(e.Name(), "_", 2)[0], 10, 32) - if err == nil && v < 159 && uint(v) > best { - best = uint(v) + if v, ok := prefix(e.Name()); ok && v < contract && v > best { + best = v } } - require.NotZero(t, best) + require.NotZero(t, best, "no migration below %d", contract) return best } From b070505fe68bbda086ed15ddaca51e73ad831b31 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 5 Nov 2026 16:25:41 +0100 Subject: [PATCH 16/19] 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 --- internal/config/store_postgres_cancel_actor_pgxmock_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/store_postgres_cancel_actor_pgxmock_test.go b/internal/config/store_postgres_cancel_actor_pgxmock_test.go index 861428ea2..5d3725c39 100644 --- a/internal/config/store_postgres_cancel_actor_pgxmock_test.go +++ b/internal/config/store_postgres_cancel_actor_pgxmock_test.go @@ -136,7 +136,7 @@ func TestPGXMock_ExecutionCancelActorWritesOnlyCanceledBy(t *testing.T) { mock.ExpectBegin() mock.ExpectExec(`(?s)approved_by, canceled_by, capacity_percent.*canceled_by = COALESCE\(purchase_executions\.canceled_by, \$17\)`). - WithArgs(anyArgsCfg(29)...). + WithArgs(anyArgsCfg(30)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) mock.ExpectCommit() require.NoError(t, store.SavePurchaseExecution(ctx, &PurchaseExecution{ From dd11af45ce888b01bcbbf20769d51c47d5973005 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 5 Nov 2026 16:55:26 +0100 Subject: [PATCH 17/19] 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 --- .../store_postgres_legacy_canceled_integration_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go index 35bac44f5..2c5b51bd5 100644 --- a/internal/config/store_postgres_legacy_canceled_integration_test.go +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -157,6 +157,16 @@ func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { canceledBy, _ = rawActors(legacy) require.NotNil(t, canceledBy) assert.Equal(t, "concurrent@example.com", *canceledBy, "a stale save must not erase the actor") + + // A stale copy that carries a DIFFERENT actor must not replace the + // stored one either: the stored actor wins over the saved $17. + other := *got + otherActor := "stale-copy@example.com" + other.CancelledBy = &otherActor + require.NoError(t, store.SavePurchaseExecution(ctx, &other)) + canceledBy, _ = rawActors(legacy) + require.NotNil(t, canceledBy) + assert.Equal(t, "concurrent@example.com", *canceledBy, "a stale save with another actor must not replace the stored actor") }) t.Run("cleanup removes a legacy row after retention", func(t *testing.T) { From 845f596485551069813fe5af73333877adee7872 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 5 Nov 2026 16:55:45 +0100 Subject: [PATCH 18/19] 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 --- known_issues/41_cancel_actor_strict_reads.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/known_issues/41_cancel_actor_strict_reads.md b/known_issues/41_cancel_actor_strict_reads.md index b6ae91607..d7dd2fa1a 100644 --- a/known_issues/41_cancel_actor_strict_reads.md +++ b/known_issues/41_cancel_actor_strict_reads.md @@ -1,6 +1,6 @@ # Known Issues: execution reads that skip the legacy cancel actor (#47) -> **Audit status (2026-11-04):** `1 needs triage` +> **Audit status (2026-11-04):** `1 needs triage · 0 resolved` ## LOW: two ramp reads select canceled_by without the legacy cancelled_by fallback From 13befc8aecdea8263b0e771b76127a26cdc6c2bf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 5 Nov 2026 17:16:03 +0100 Subject: [PATCH 19/19] 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 --- ...urchases_legacy_cancel_integration_test.go | 2 +- ...ostgres_delete_account_integration_test.go | 4 +- ...stgres_legacy_canceled_integration_test.go | 2 +- ...00157_contract_canceled_spelling.down.sql} | 2 +- ... 000157_contract_canceled_spelling.up.sql} | 2 +- ...000157_contract_canceled_spelling_test.go} | 134 +++++++++--------- 6 files changed, 73 insertions(+), 73 deletions(-) rename internal/database/postgres/migrations/{000155_contract_canceled_spelling.down.sql => 000157_contract_canceled_spelling.down.sql} (93%) rename internal/database/postgres/migrations/{000155_contract_canceled_spelling.up.sql => 000157_contract_canceled_spelling.up.sql} (97%) rename internal/database/postgres/migrations/{000155_contract_canceled_spelling_test.go => 000157_contract_canceled_spelling_test.go} (70%) diff --git a/internal/api/handler_purchases_legacy_cancel_integration_test.go b/internal/api/handler_purchases_legacy_cancel_integration_test.go index 8d04f66e1..47ae8d90d 100644 --- a/internal/api/handler_purchases_legacy_cancel_integration_test.go +++ b/internal/api/handler_purchases_legacy_cancel_integration_test.go @@ -48,7 +48,7 @@ func versionBeforeCanceledContract(t *testing.T, dir string) uint { } // TestCancelOrRecoverExecution_LegacyCanceledRowBeforeContractMigration runs -// the cancel-race recovery on the schema before 000155 against a row an older +// the cancel-race recovery on the schema before 000157 against a row an older // build canceled (legacy status spelling, actor only in the legacy column). The // build and the migration roll out in either order, so the retry must see the // race as already won, return the canonical status and keep the actor. diff --git a/internal/config/store_postgres_delete_account_integration_test.go b/internal/config/store_postgres_delete_account_integration_test.go index 8b5a1281c..e87d3d45a 100644 --- a/internal/config/store_postgres_delete_account_integration_test.go +++ b/internal/config/store_postgres_delete_account_integration_test.go @@ -78,9 +78,9 @@ func accountExists(ctx context.Context, t *testing.T, store *PostgresStore, id s } // storableTerminalExecutionStatuses is TerminalExecutionStatuses minus the -// legacy canceled spelling, which migration 000155 forbids in the status CHECK +// legacy canceled spelling, which migration 000157 forbids in the status CHECK // on a fully migrated schema. Legacy rows are covered against the schema before -// 000155 in TestPostgresStore_NewCodeAgainstPreContractSchema. +// 000157 in TestPostgresStore_NewCodeAgainstPreContractSchema. func storableTerminalExecutionStatuses() []string { return slices.DeleteFunc(slices.Clone(TerminalExecutionStatuses), func(s string) bool { return s == LegacyStatusCanceled diff --git a/internal/config/store_postgres_legacy_canceled_integration_test.go b/internal/config/store_postgres_legacy_canceled_integration_test.go index 2c5b51bd5..59e430d1b 100644 --- a/internal/config/store_postgres_legacy_canceled_integration_test.go +++ b/internal/config/store_postgres_legacy_canceled_integration_test.go @@ -58,7 +58,7 @@ func versionBelowCanceledContract(t *testing.T, dir string) uint { } // TestPostgresStore_NewCodeAgainstPreContractSchema runs the new store code on -// the schema before 000155 with rows an older build wrote: the legacy status +// the schema before 000157 with rows an older build wrote: the legacy status // spelling and an actor only in the legacy column. Every reader must treat them as // canceled with their actor, and the new writers must not lose them. func TestPostgresStore_NewCodeAgainstPreContractSchema(t *testing.T) { diff --git a/internal/database/postgres/migrations/000155_contract_canceled_spelling.down.sql b/internal/database/postgres/migrations/000157_contract_canceled_spelling.down.sql similarity index 93% rename from internal/database/postgres/migrations/000155_contract_canceled_spelling.down.sql rename to internal/database/postgres/migrations/000157_contract_canceled_spelling.down.sql index 0819d85c8..058ce0cc8 100644 --- a/internal/database/postgres/migrations/000155_contract_canceled_spelling.down.sql +++ b/internal/database/postgres/migrations/000157_contract_canceled_spelling.down.sql @@ -1,4 +1,4 @@ --- Rollback 000155: re-widen both status CHECKs to the 000089 EXPAND state. +-- Rollback 000157: re-widen both status CHECKs to the 000089 EXPAND state. -- -- Rows stay 'canceled': the widened CHECKs accept both spellings and every -- build since #1277 reads both, so converting them back is unnecessary. diff --git a/internal/database/postgres/migrations/000155_contract_canceled_spelling.up.sql b/internal/database/postgres/migrations/000157_contract_canceled_spelling.up.sql similarity index 97% rename from internal/database/postgres/migrations/000155_contract_canceled_spelling.up.sql rename to internal/database/postgres/migrations/000157_contract_canceled_spelling.up.sql index 7f22b752a..f75960c50 100644 --- a/internal/database/postgres/migrations/000155_contract_canceled_spelling.up.sql +++ b/internal/database/postgres/migrations/000157_contract_canceled_spelling.up.sql @@ -1,4 +1,4 @@ --- Migration 000155: CONTRACT half of the 000089 'cancelled' -> 'canceled' +-- Migration 000157: CONTRACT half of the 000089 'cancelled' -> 'canceled' -- rename (issue #47), part 1 of 2. -- -- 000089 (EXPAND) widened both status CHECKs to accept both spellings, added diff --git a/internal/database/postgres/migrations/000155_contract_canceled_spelling_test.go b/internal/database/postgres/migrations/000157_contract_canceled_spelling_test.go similarity index 70% rename from internal/database/postgres/migrations/000155_contract_canceled_spelling_test.go rename to internal/database/postgres/migrations/000157_contract_canceled_spelling_test.go index 63fa8bb47..c270c273f 100644 --- a/internal/database/postgres/migrations/000155_contract_canceled_spelling_test.go +++ b/internal/database/postgres/migrations/000157_contract_canceled_spelling_test.go @@ -19,29 +19,29 @@ import ( // The legacy spellings are built by concatenation so the US-locale misspell // linter does not flag them; they are the values under test. const ( - contract155 = 155 - legacyStatus155 = "cancel" + "led" - legacyActor155 = "cancel" + "led_by" - canceled155 = "canceled" + contract157 = 157 + legacyStatus157 = "cancel" + "led" + legacyActor157 = "cancel" + "led_by" + canceled157 = "canceled" ) -// legacyInCheck155 matches the legacy spelling's entry in a status CHECK +// legacyInCheck157 matches the legacy spelling's entry in a status CHECK // definition as pg_get_constraintdef renders it (the cast follows the column // type: text or character varying). -var legacyInCheck155 = regexp.MustCompile(`'` + legacyStatus155 + `'::[a-z ]+, `) +var legacyInCheck157 = regexp.MustCompile(`'` + legacyStatus157 + `'::[a-z ]+, `) -const insertExecution155 = ` - INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor155 + `, updated_at) +const insertExecution157 = ` + INSERT INTO purchase_executions (status, scheduled_date, ` + legacyActor157 + `, updated_at) VALUES ($1, NOW(), $2, $3) RETURNING execution_id::text` -func seedExecution155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { +func seedExecution157(ctx context.Context, t *testing.T, pool *pgxpool.Pool, status string, legacyBy *string, updatedAt time.Time) string { t.Helper() var id string - require.NoError(t, pool.QueryRow(ctx, insertExecution155, status, legacyBy, updatedAt).Scan(&id)) + require.NoError(t, pool.QueryRow(ctx, insertExecution157, status, legacyBy, updatedAt).Scan(&id)) return id } -func insertExchange155(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { +func insertExchange157(ctx context.Context, pool *pgxpool.Pool, status string, updatedAt time.Time) (string, error) { var id string err := pool.QueryRow(ctx, ` INSERT INTO ri_exchange_history @@ -53,7 +53,7 @@ func insertExchange155(ctx context.Context, pool *pgxpool.Pool, status string, u return id, err } -func statusCheckDef155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { +func statusCheckDef157(ctx context.Context, t *testing.T, pool *pgxpool.Pool, constraint string) string { t.Helper() var def string require.NoError(t, pool.QueryRow(ctx, @@ -61,7 +61,7 @@ func statusCheckDef155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, co return def } -func updatedAt155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { +func updatedAt157(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, id string) time.Time { t.Helper() idCol := map[string]string{"purchase_executions": "execution_id", "ri_exchange_history": "id"}[table] var ts time.Time @@ -70,16 +70,16 @@ func updatedAt155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table, return ts } -// readActor155 reads the actor the way every projection does. -func readActor155(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { +// readActor157 reads the actor the way every projection does. +func readActor157(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id string) (status string, actor *string) { t.Helper() require.NoError(t, pool.QueryRow(ctx, - `SELECT status, COALESCE(canceled_by, `+legacyActor155+`) FROM purchase_executions WHERE execution_id = $1`, id, + `SELECT status, COALESCE(canceled_by, `+legacyActor157+`) FROM purchase_executions WHERE execution_id = $1`, id, ).Scan(&status, &actor)) return status, actor } -func requireCheckViolation155(t *testing.T, err error, constraint string) { +func requireCheckViolation157(t *testing.T, err error, constraint string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) @@ -87,54 +87,54 @@ func requireCheckViolation155(t *testing.T, err error, constraint string) { assert.Equal(t, constraint, pgErr.ConstraintName) } -func s155(v string) *string { return &v } +func s157(v string) *string { return &v } -// TestMigration_000155_ContractCanceledSpelling locks down the contract half of +// TestMigration_000157_ContractCanceledSpelling locks down the contract half of // the 000089 rename (issue #47): legacy status rows are normalized without // restamping updated_at (the plan-health and retention clock), both status // CHECKs lose exactly the legacy spelling, the down restores the widened // CHECKs exactly, and the write statements of the build before this release // (which still write the legacy actor column) keep working against the // narrowed schema. -func TestMigration_000155_ContractCanceledSpelling(t *testing.T) { +func TestMigration_000157_ContractCanceledSpelling(t *testing.T) { ctx := context.Background() container := testhelpers.RequirePostgresContainer(ctx, t) t.Cleanup(func() { require.NoError(t, container.Cleanup(context.Background())) }) pool, path := container.DB.Pool(), getMigrationsPath() - prev := previousMigrationVersion(t, contract155) + prev := previousMigrationVersion(t, contract157) require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - peBefore := statusCheckDef155(ctx, t, pool, "purchase_executions_status_check") - riBefore := statusCheckDef155(ctx, t, pool, "ri_exchange_history_status_check") - require.Len(t, legacyInCheck155.FindAllString(peBefore, -1), 1, peBefore) - require.Len(t, legacyInCheck155.FindAllString(riBefore, -1), 1, riBefore) + peBefore := statusCheckDef157(ctx, t, pool, "purchase_executions_status_check") + riBefore := statusCheckDef157(ctx, t, pool, "ri_exchange_history_status_check") + require.Len(t, legacyInCheck157.FindAllString(peBefore, -1), 1, peBefore) + require.Len(t, legacyInCheck157.FindAllString(riBefore, -1), 1, riBefore) old := time.Now().UTC().AddDate(0, 0, -90).Truncate(time.Microsecond) - legacyExec := seedExecution155(ctx, t, pool, legacyStatus155, s155("legacy@example.com"), old) - failedExec := seedExecution155(ctx, t, pool, "failed", nil, old) - legacyExchange, err := insertExchange155(ctx, pool, legacyStatus155, old) + legacyExec := seedExecution157(ctx, t, pool, legacyStatus157, s157("legacy@example.com"), old) + failedExec := seedExecution157(ctx, t, pool, "failed", nil, old) + legacyExchange, err := insertExchange157(ctx, pool, legacyStatus157, old) require.NoError(t, err) - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract155)) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract157)) // Data: the legacy status is normalized and the legacy actor column is // left alone (this release still reads it); other rows are untouched. - status, actor := readActor155(ctx, t, pool, legacyExec) - assert.Equal(t, canceled155, status) + status, actor := readActor157(ctx, t, pool, legacyExec) + assert.Equal(t, canceled157, status) require.NotNil(t, actor) assert.Equal(t, "legacy@example.com", *actor) - status, _ = readActor155(ctx, t, pool, failedExec) + status, _ = readActor157(ctx, t, pool, failedExec) assert.Equal(t, "failed", status) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, legacyExchange).Scan(&exchangeStatus)) - assert.Equal(t, canceled155, exchangeStatus) + assert.Equal(t, canceled157, exchangeStatus) // updated_at is the plan-health and retention clock: a spelling change // must not make 90-day-old rows look fresh. Each table has its own // updated_at trigger, so each is asserted on its own. - assert.True(t, old.Equal(updatedAt155(ctx, t, pool, "purchase_executions", legacyExec)), + assert.True(t, old.Equal(updatedAt157(ctx, t, pool, "purchase_executions", legacyExec)), "purchase_executions.updated_at must survive the normalization") - assert.True(t, old.Equal(updatedAt155(ctx, t, pool, "ri_exchange_history", legacyExchange)), + assert.True(t, old.Equal(updatedAt157(ctx, t, pool, "ri_exchange_history", legacyExchange)), "ri_exchange_history.updated_at must survive the normalization") var enabled int require.NoError(t, pool.QueryRow(ctx, ` @@ -145,26 +145,26 @@ func TestMigration_000155_ContractCanceledSpelling(t *testing.T) { // Schema: each CHECK is its pre-migration definition minus the legacy // spelling, nothing else, and rejects the legacy spelling. - assert.Equal(t, legacyInCheck155.ReplaceAllString(peBefore, ""), - statusCheckDef155(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, legacyInCheck155.ReplaceAllString(riBefore, ""), - statusCheckDef155(ctx, t, pool, "ri_exchange_history_status_check")) - _, err = pool.Exec(ctx, insertExecution155, legacyStatus155, nil, old) - requireCheckViolation155(t, err, "purchase_executions_status_check") - _, err = insertExchange155(ctx, pool, legacyStatus155, old) - requireCheckViolation155(t, err, "ri_exchange_history_status_check") + assert.Equal(t, legacyInCheck157.ReplaceAllString(peBefore, ""), + statusCheckDef157(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, legacyInCheck157.ReplaceAllString(riBefore, ""), + statusCheckDef157(ctx, t, pool, "ri_exchange_history_status_check")) + _, err = pool.Exec(ctx, insertExecution157, legacyStatus157, nil, old) + requireCheckViolation157(t, err, "purchase_executions_status_check") + _, err = insertExchange157(ctx, pool, legacyStatus157, old) + requireCheckViolation157(t, err, "ri_exchange_history_status_check") - requireOldBuildWritesStillWork155(ctx, t, pool) + requireOldBuildWritesStillWork157(ctx, t, pool) // Down: the widened CHECKs come back byte for byte. require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, prev)) - assert.Equal(t, peBefore, statusCheckDef155(ctx, t, pool, "purchase_executions_status_check")) - assert.Equal(t, riBefore, statusCheckDef155(ctx, t, pool, "ri_exchange_history_status_check")) - seedExecution155(ctx, t, pool, legacyStatus155, nil, old) - _, err = insertExchange155(ctx, pool, legacyStatus155, old) + assert.Equal(t, peBefore, statusCheckDef157(ctx, t, pool, "purchase_executions_status_check")) + assert.Equal(t, riBefore, statusCheckDef157(ctx, t, pool, "ri_exchange_history_status_check")) + seedExecution157(ctx, t, pool, legacyStatus157, nil, old) + _, err = insertExchange157(ctx, pool, legacyStatus157, old) require.NoError(t, err, "down must accept the legacy spelling again") - require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract155), "up must re-apply cleanly after down") + require.NoError(t, migrations.MigrateToVersion(ctx, pool, path, contract157), "up must re-apply cleanly after down") } // Write statements copied from origin/main (internal/config/store_postgres.go @@ -174,7 +174,7 @@ func TestMigration_000155_ContractCanceledSpelling(t *testing.T) { // //nolint:misspell // the SQL deliberately names the legacy British column under test const ( - oldSaveExecution155 = ` + oldSaveExecution157 = ` INSERT INTO purchase_executions ( plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token_hash, recommendations, @@ -209,28 +209,28 @@ const ( THEN purchase_executions.no_submission OR $28 ELSE false END, updated_at = NOW()` - oldSetCancelledBy155 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() + oldSetCancelledBy157 = `UPDATE purchase_executions SET canceled_by = $2, cancelled_by = $2, updated_at = NOW() WHERE execution_id = $1` - oldTransition155 = ` + oldTransition157 = ` UPDATE purchase_executions SET status = $2, updated_at = NOW(), transitioned_by = $4, transitioned_at = NOW(), canceled_by = COALESCE((SELECT email FROM users WHERE id = $5), canceled_by), cancelled_by = COALESCE((SELECT email FROM users WHERE id = $5), cancelled_by) WHERE execution_id = $1 AND status = ANY($3)` - oldCancelAllExchanges155 = ` + oldCancelAllExchanges157 = ` UPDATE ri_exchange_history SET status = $1, updated_at = NOW() WHERE status = 'pending'` ) -func requireOldBuildWritesStillWork155(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { +func requireOldBuildWritesStillWork157(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { t.Helper() now := time.Now().UTC() var execID string require.NoError(t, pool.QueryRow(ctx, `SELECT gen_random_uuid()::text`).Scan(&execID)) save := func(status, actor string) error { - _, err := pool.Exec(ctx, oldSaveExecution155, + _, err := pool.Exec(ctx, oldSaveExecution157, nil, execID, status, 1, now, nil, nil, []byte(`[]`), 0, 0, nil, "", nil, nil, "web", nil, actor, 100, nil, nil, 0, nil, nil, nil, nil, nil, nil, false, false) return err @@ -239,17 +239,17 @@ func requireOldBuildWritesStillWork155(ctx context.Context, t *testing.T, pool * // SavePurchaseExecutionTx: insert, then the ON CONFLICT update, both with // the canonical status and the legacy actor column only. require.NoError(t, save("pending", ""), "old upsert insert path") - require.NoError(t, save(canceled155, "old-save@example.com"), "old upsert update path") - status, actor := readActor155(ctx, t, pool, execID) - assert.Equal(t, canceled155, status) + require.NoError(t, save(canceled157, "old-save@example.com"), "old upsert update path") + status, actor := readActor157(ctx, t, pool, execID) + assert.Equal(t, canceled157, status) require.NotNil(t, actor, "an actor written to the legacy column must read back") assert.Equal(t, "old-save@example.com", *actor) // SetCancelledBy. - ct, err := pool.Exec(ctx, oldSetCancelledBy155, execID, "set-by@example.com") + ct, err := pool.Exec(ctx, oldSetCancelledBy157, execID, "set-by@example.com") require.NoError(t, err) require.EqualValues(t, 1, ct.RowsAffected()) - _, actor = readActor155(ctx, t, pool, execID) + _, actor = readActor157(ctx, t, pool, execID) require.NotNil(t, actor) assert.Equal(t, "set-by@example.com", *actor) @@ -257,21 +257,21 @@ func requireOldBuildWritesStillWork155(ctx context.Context, t *testing.T, pool * var userID string require.NoError(t, pool.QueryRow(ctx, ` INSERT INTO users (email, password_hash, group_ids) VALUES ('transition@example.com', 'x', ARRAY[gen_random_uuid()]) RETURNING id::text`).Scan(&userID)) - transitionID := seedExecution155(ctx, t, pool, "pending", nil, now) - ct, err = pool.Exec(ctx, oldTransition155, transitionID, canceled155, []string{"pending"}, userID, userID) + transitionID := seedExecution157(ctx, t, pool, "pending", nil, now) + ct, err = pool.Exec(ctx, oldTransition157, transitionID, canceled157, []string{"pending"}, userID, userID) require.NoError(t, err) require.EqualValues(t, 1, ct.RowsAffected()) - status, actor = readActor155(ctx, t, pool, transitionID) - assert.Equal(t, canceled155, status) + status, actor = readActor157(ctx, t, pool, transitionID) + assert.Equal(t, canceled157, status) require.NotNil(t, actor) assert.Equal(t, "transition@example.com", *actor) // CancelAllPendingExchanges. - exchangeID, err := insertExchange155(ctx, pool, "pending", now) + exchangeID, err := insertExchange157(ctx, pool, "pending", now) require.NoError(t, err) - _, err = pool.Exec(ctx, oldCancelAllExchanges155, canceled155) + _, err = pool.Exec(ctx, oldCancelAllExchanges157, canceled157) require.NoError(t, err) var exchangeStatus string require.NoError(t, pool.QueryRow(ctx, `SELECT status FROM ri_exchange_history WHERE id = $1`, exchangeID).Scan(&exchangeStatus)) - assert.Equal(t, canceled155, exchangeStatus) + assert.Equal(t, canceled157, exchangeStatus) }