Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- Reverse 000095: narrow purchase_history.account_id back to VARCHAR(20).
--
-- THIS ROLLBACK IS LOSSY BY NATURE and deliberately FAILS LOUDLY rather than
-- truncating. Any row written after the up migration applied may carry an
-- Azure subscription GUID (36 chars) or a long GCP project ID; narrowing the
-- column would either error mid-rewrite or, worse, be "fixed" by a
-- well-meaning operator with a truncating USING clause that silently corrupts
-- the audit trail this table exists to provide.
--
-- So: refuse the rollback while any over-long value is present, naming the
-- count, and let the operator decide. There is no safe automatic remedy --
-- unlike 000063's down migration, which could coerce NULL monthly_cost to 0.0
-- losslessly, there is no lossless 20-character encoding of a 36-character
-- subscription ID.
--
-- 000067's down migration made the same call for savings_snapshots.account_id
-- and simply left the column at VARCHAR(255). This one goes further and does
-- narrow, but only when it is provably safe.
--
-- ------------------------------------------------------------------
-- Reverse exactly what the up migration did, and nothing more
-- ------------------------------------------------------------------
-- The up lands the column on exactly VARCHAR(255) (atttypmod 259) and leaves
-- anything already >= 255, or unbounded `varchar` (atttypmod -1), untouched.
-- So this narrows ONLY from 259. Guarding on "anything wider than 20" would
-- narrow a VARCHAR(300) or an unbounded varchar down to VARCHAR(20) -- a
-- state 000095 never created, and a rollback must restore the pre-up state
-- rather than impose a new one.
--
-- Unlike the up, this direction is NOT binary-coercible: narrowing a varchar
-- forces a full table rewrite and rebuilds every dependent index. On a large
-- purchase_history that is a long ACCESS EXCLUSIVE lock, not the near-instant
-- catalog-only change the up migration is.
--
-- ------------------------------------------------------------------
-- Recovering from a refused rollback
-- ------------------------------------------------------------------
-- golang-migrate stamps (version=95, dirty=true) BEFORE running this file, so
-- a refusal leaves schema_migrations dirty at 95 and maybeAutoHealDirty
-- (migrate.go) deliberately will NOT clear it -- the next deploy hard-fails
-- until an operator intervenes. Because this file raises before touching the
-- schema, the database still matches version 95 exactly. Recover by setting
-- CUDLY_FORCE_MIGRATION_VERSION=95 (the CURRENT version, never lower: the
-- 000095 up migration's effects ARE present) and redeploying, then removing
-- the env var. Only then archive or reconcile the over-long rows if the
-- rollback is still wanted.

DO $$
DECLARE
typmod INTEGER;
over_long BIGINT;
BEGIN
SELECT atttypmod INTO typmod
FROM pg_attribute
WHERE attrelid = 'purchase_history'::regclass
AND attname = 'account_id'
AND NOT attisdropped;

-- 259 == VARCHAR(255) (atttypmod is n + 4). Anything else was not
-- produced by 000095's up migration, so leave it alone.
IF typmod IS DISTINCT FROM 259 THEN
RETURN;
END IF;

SELECT COUNT(*) INTO over_long
FROM purchase_history
WHERE CHAR_LENGTH(account_id) > 20;

IF over_long > 0 THEN
RAISE EXCEPTION
'refusing to narrow purchase_history.account_id to VARCHAR(20): '
'% row(s) hold an account ID longer than 20 characters '
'(Azure subscription GUIDs are 36 chars, GCP project IDs up to 30). '
'Narrowing would truncate the audit trail. Archive or reconcile '
'those rows before rolling back migration 000095.',
over_long;
END IF;

ALTER TABLE purchase_history
ALTER COLUMN account_id TYPE VARCHAR(20);
END $$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
-- Migration 000095: widen purchase_history.account_id to VARCHAR(255).
--
-- account_id has been VARCHAR(20) since 000001. That fits an AWS account ID
-- (12 digits) and nothing else:
--
-- * an Azure subscription ID is a 36-character GUID
-- * a GCP project ID is 6 to 30 characters
--
-- The value written into this column is cloud_accounts.external_id, which is
-- VARCHAR(255) at its source (000011), so nothing upstream constrains it.
-- SavePurchaseHistory therefore issues an INSERT that Postgres rejects with
-- SQLSTATE 22001 (`value too long for type character varying(20)`) for every
-- Azure purchase and every GCP purchase whose project ID exceeds 20 chars.
--
-- The purchase itself has already succeeded and been billed by the time this
-- INSERT runs, so the failure loses the audit row for real money spent. The
-- error is not swallowed -- savePurchaseHistory returns it and the caller
-- stamps a `history_write_failed` audit-gap marker on the execution
-- (issue #621) -- but the purchase_history row is gone: invisible in the
-- History view, absent from GetActivePurchaseHistory (so analytics undercount
-- committed spend), and unseen by the grace-period and suppression logic.
--
-- The identical widening was already applied to the sibling column:
-- migration 000067 widened savings_snapshots.account_id to VARCHAR(255) for
-- exactly this reason, and 000074 repaired it on partially-migrated
-- databases. purchase_history was missed. This closes that gap and lands on
-- VARCHAR(255), matching both cloud_accounts.external_id and
-- savings_snapshots.account_id.
--
-- Operator note: rows already lost to 22001 cannot be recovered by this
-- migration -- they were never inserted. Affected executions are identifiable
-- via `SELECT execution_id, error FROM purchase_executions WHERE error LIKE
-- '%history_write_failed%'`, whose marker carries the provider-side commitment
-- ID; reconciliation has to come from there or from the provider console. The
-- wildcard on both sides is deliberate: recordHistoryAuditGap appends the
-- marker via appendErrNote, so it is not necessarily at the start of the
-- field when the execution already carried an error.
--
-- ------------------------------------------------------------------
-- Why the probe, and why pg_attribute rather than information_schema
-- ------------------------------------------------------------------
-- Guarded so it is correct on a fresh database, on an already-deployed one,
-- and on re-run under the auto-heal path (project rule
-- feedback_migration_full_restore: IF NOT EXISTS does not repair a wrong
-- column type, so the ALTER has to be conditioned on the observed type
-- rather than skipped).
--
-- 000067 probed information_schema.columns without a schema predicate. This
-- one resolves the table through 'purchase_history'::regclass instead, which
-- follows the connection's search_path in EXACTLY the way the bare
-- `ALTER TABLE purchase_history` below does. buildMigrateDSN deliberately
-- appends no connection options (RDS Proxy does not support them), so
-- search_path is whatever the role default is; a probe that resolved the
-- table differently from the ALTER could silently match nothing, no-op, and
-- let golang-migrate record 000095 as applied while the p0 data loss
-- persisted. On a money path a migration must not be able to report success
-- without having done the work -- hence also the unconditional post-check
-- and the hard failure when the column is absent entirely.
--
-- atttypmod encodes VARCHAR(n) as n + 4. A value of -1 means unbounded
-- `varchar`, which is WIDER than VARCHAR(255) and must be left alone --
-- 000067's `character_maximum_length IS NULL` branch would have narrowed it.
--
-- Nothing else depends on this column's type: no view, materialized view,
-- foreign key or partition references purchase_history, so no drop/recreate
-- dance is needed (contrast 000067, which had to drop three views first).
-- Widening a varchar is binary-coercible, so Postgres skips both the table
-- rewrite and any index rebuild -- idx_purchase_history_account_timestamp
-- (000002) is left in place and the ALTER is near-instant even on a large
-- table. That reasoning does NOT hold in reverse; see the down migration.

DO $$
DECLARE
typmod INTEGER;
BEGIN
SELECT atttypmod INTO typmod
FROM pg_attribute
WHERE attrelid = 'purchase_history'::regclass
AND attname = 'account_id'
AND NOT attisdropped;

IF typmod IS NULL THEN
RAISE EXCEPTION
'migration 000095: column purchase_history.account_id does not exist';
END IF;

IF typmod <> -1 AND typmod - 4 < 255 THEN
ALTER TABLE purchase_history
ALTER COLUMN account_id TYPE VARCHAR(255);

-- Post-check: re-read the catalog and fail loudly if the widening
-- did not take effect, so this migration can never be recorded as
-- applied while account_id is still too narrow for Azure/GCP.
SELECT atttypmod INTO typmod
FROM pg_attribute
WHERE attrelid = 'purchase_history'::regclass
AND attname = 'account_id'
AND NOT attisdropped;

IF typmod <> -1 AND typmod - 4 < 255 THEN
RAISE EXCEPTION
'migration 000095 failed to widen purchase_history.account_id: still VARCHAR(%)',
typmod - 4;
END IF;
END IF;
END $$;
Loading
Loading