Files
silo-server/migrations/sql/20260612130000_deterministic_content_id.sql
13c5e0ba2f feat(catalog): deterministic cross-server content_id (#155)
* feat(catalog): deterministic cross-server content_id

Replace per-server Sonyflake content_id with a structured natural key
derived from provider IDs (movie:tmdb:…, series:tvdb:…, episode:…,
local:… fallback), so two servers holding the same title share one
anchor for artwork, watch history, progress, favorites and ratings.

- internal/contentid: derivation core, SeriesIDFromContentID transform,
  frozen precedence, SchemeVersion=1, embedded-series-anchor invariant.
- internal/metadata/service.go: deterministic id at every mint site.
- internal/catalog/history_source.go: resolve show via string transform
  for anchored episode ids; skip the episodes_pkey probe.
- migrations/sql/20260612130000: collision-safe value remap across the
  65-column reference graph + COLLATE "C", FK/trigger handling, audit
  map, working down.

Benchmarked against an exact-cardinality copy of cprod-postgres
(1.93M episodes, 775k history rows): 2.57x faster history page, 1.7x
throughput at 100 concurrent users, 2.7x cheaper per content_id probe.

* feat(catalog): re-ID untagged items to deterministic content_id at first match

Untagged libraries get a path-derived local: content_id at scan time and
only learn their provider IDs later, when the match worker confirms a
result. Previously that id was never folded back in, so untagged-then-matched
items kept a per-server local: placeholder forever and never converged across
servers (re-ID was deferred to a migration rerun).

mergeAndPersist now promotes a local: skeleton to its deterministic
provider-anchored id at the moment of first confirmed match, via a single
new gate (canonicalizeLocalContentID):

  - target id already taken  -> merge onto it (existing rebind machinery)
  - target id free           -> rename in place

The rename is a single SQL function (silo_rename_content_id); FK children
follow via ON UPDATE CASCADE added to the content_id family, so a fresh
skeleton moves a handful of rows rather than the full-table remap the bulk
migration does. The guard is one IsLocal prefix check, so tagged content and
all refreshes pay nothing, and the move is self-healing under retry.

Verified: gofmt/vet/build clean; migrate-validate passes; migration applies
on the real schema (up/down/up), FKs gain ON UPDATE CASCADE while keeping
ON DELETE; functional test confirms series PK move + series_id cascade +
provider-id sweep, and movie rename.

Follow-ups (noted in docs): recomposeSeriesChildIDs for a series that
accumulated episodes before matching; a lockstep test for the soft-ref list.

* fix(catalog): harden content_id parsing and merge per review

Address review feedback on the deterministic content_id work:

- history_source.go: gate the anchored-episode display-id transform on the
  full five-part episode shape (split_part parts 2-5 non-empty), not just the
  'episode:' prefix, so a malformed id can't transform to 'series:broken:' and
  vanish at the media_items join. Shared anchoredEpisodePredicate drives both
  the null-poisoned join key and the series-recovery expression.
- contentid.go: unexport the provider-precedence slices so no package can
  mutate the frozen SchemeVersion ordering at runtime.
- contentid.go: add parseAnchored to validate the exact per-kind arity and
  numeric season/episode suffixes; SeriesIDFromContentID and IsProviderAnchored
  now fail closed on truncated/malformed ids (e.g. "episode:tvdb:296762").
- canonicalize.go: distinguish catalog.ErrItemNotFound from transient lookup
  errors (a real error no longer masquerades as "target free"), and allow a
  matched local source to be consolidated onto the canonical row instead of
  orphaning a duplicate.

* refactor(contentid): URL-safe "-" separator in content_id

Use "-" instead of ":" to join content_id components
(movie-tmdb-228064, episode-tvdb-296762-1-5, local-<hex>). "-" is an RFC 3986
unreserved character, so a content_id is URL-safe verbatim: encodeURIComponent
is a no-op and the id is its own tidy path segment (/item/series-tvdb-296762)
with no %3A escaping. The stored value equals the URL value, so there is no
encode/decode boundary and an operator can grep the id straight out of a URL or
log. Every component is [a-z0-9]+ (or "tt"+digits), so "-" is unambiguous.

Pre-release format finalization: this branch is unmerged, so no deployed data
carries ":" ids — the migration mints the "-" form fresh and no re-migration is
needed. Still SchemeVersion 1.

- contentid.go: single `sep` constant drives construction and parsing so the two
  can never drift; all constructors/parsers and doc examples updated.
- history_source.go: split_part transform and the anchored-episode predicate use
  '-'; kept in lockstep with the package via a code comment.
- 20260612130000_deterministic_content_id.sql: derivation and season/episode
  composition emit '-'; LIKE filters match 'series-%'.
- docs/architecture/deterministic-content-id.md: format spec + rationale for the
  separator choice; this is the design doc the change is derived from.

Client-side: the web frontend treats content_id as an opaque string (no
splitting/regex), so no client changes are required; existing
encodeURIComponent call sites simply stop emitting %3A.

* docs(contentid): show why hash/bigint rejected in probe-cost table

Add Cross-server deterministic / Zero-join show transform / Human-readable
columns to the index-probe-cost comparison so the trade-off is legible at a
glance: the 128-bit hash and bigint surrogate are faster but each give up a
load-bearing property, and the structured key is the only all-checkmark row.

* docs(contentid): order probe-cost table to end on the structured key

* docs(contentid): label fenced blocks and drop stray EOF tags

Per CodeRabbit review: add 'text' language to three fenced code blocks
(MD040) and remove accidental </content></invoke> artifacts at EOF.

* fix(catalog): remap array-valued content_id soft references in deterministic id migration

The value-remap migration (20260612130000) enumerates the reference graph by FK
plus a scalar name+type sweep (text/varchar/bpchar). That misses
trending_discover_snapshots.content_ids: it is text[] (excluded by the type
filter), named content_ids not content_id (excluded by the name list), and
cannot carry an FK — so the bulk remap left those arrays holding stale Sonyflake
ids that resolve to nothing until the snapshot regenerates. A counterexample to
the migration's "self-protecting, cannot orphan" invariant.

Remap the array element-wise in both directions (Up old->new, Down new->old),
preserving order and leaving collision/unmatched elements untouched; a WHERE
EXISTS guard skips empty/unaffected arrays so array_agg never collapses the NOT
NULL column to NULL. Mirror the gap in silo_rename_content_id (20260614120000)
with array_replace for the single-value runtime rename so the two stay in
lockstep.

Verified on PG18: mixed/collision/empty arrays remap correctly and round-trip
clean; runtime array_replace preserves order.

Surfaced reviewing #155. The jellycompat restart-decode regression and the
atomicity-wording nit are posted as review comments, not addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(jellycompat): pack content_id into compat UUID reversibly so item ids survive restarts

Addresses the restart-decode regression raised in review of #155. With
content_id now a structured string instead of a numeric Sonyflake,
EncodeStringID sent every item/season id down the one-way SHA1 path, making
decode depend on an in-memory reverse map. That map is cold after a process
restart (the codec is a process-lifetime singleton), so a client presenting a
previously-issued item UUID — resume-from-home, deep link, detail page, image,
userdata — got "unknown compat id" until the item was re-listed.

Make the encoding reversible instead of stateful:

- internal/contentid: add Pack/Unpack, a bit-packed, fixed-budget (<=15 byte)
  binary form of a structured or local content_id. digitCount preserves
  provider-id leading zeros (e.g. imdb tt0944947); structured forms are
  self-delimiting; the local form fills the budget exactly. Provider ids that
  overflow uint64 return ok=false.
- Shrink ForLocal to a 112-bit (sha256(path)[:14]) hash so a local id packs
  losslessly into the 15-byte UUID payload. 112 bits is far beyond any single
  server's local-item count. No other code assumed the old width.
- internal/jellycompat: EncodeStringID packs item/season content_ids into the
  UUID (byte 0 = kind, bytes 1..15 = packed, non-zero tag distinguishes it from
  the numeric encoding); DecodeStringID unpacks first and re-packs to confirm,
  so an opaque id whose bytes merely parse is rejected and falls through to the
  map. Numeric ids and arbitrary names (genres, studios) are unchanged.

Net: item/season ids decode by pure computation — stable across restarts and
across instances — with no lookup table. Only the rare unpackable content_id and
non-content names still use the in-memory map.

TDD: round-trip property tests in contentid (all kinds, leading zeros, reject
cases) and a cross-instance decode test in jellycompat that fails on the old
hash+map path. Full contentid + jellycompat suites green; production code
golangci-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migrate): make the migration run timeout configurable (SILO_MIGRATE_TIMEOUT)

The boot-path migration runner hardcoded a 5-minute context timeout. The
deterministic-content-id value-remap (20260612130000) does a full-table COLLATE
rewrite + 65-column remap that needs ~20 min on a real dataset (615k items /
2M episodes), so it was cancelled at 5 min. Worse, Postgres keeps the orphaned
backend running (holding AccessExclusive locks) until it notices the dead client
at a statement boundary, while the goose session advisory lock releases on
disconnect — so each 5-min boot retry piled a new attempt behind the previous
one's locks. The migration never applied; the server boot-looped.

Make the timeout configurable via SILO_MIGRATE_TIMEOUT (a Go duration like
"60m"); 0 or negative disables the deadline for a one-off heavy migration. Default
stays 5m. All three entry points (migrate-status, --migrate-only, boot) honor it.

Required for the deterministic-content-id migration to apply on any real-sized
database, not just dev — the 5m cap made the PR undeployable at scale.

Follow-up (not here): on cancellation the runner should actively terminate its
backend so a future timeout cannot orphan a lock-holding statement.

TDD: MigrationTimeout parsing (default/override/zero/invalid) + MigrationContext
deadline behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(contentid): require exact length for local ids in Unpack

Tighten the tagLocal branch of Unpack from `len(body) < localHashLen` to an
equality check. The local form fills the compat-UUID payload exactly (no
padding), so a body of any other length is non-canonical; matching it exactly
keeps Unpack a strict fail-closed inverse of Pack for the fixed-length branch,
which decodes client-supplied UUIDs.

Not applied to the structured branch (a review suggestion proposed the same
change there): structured ids are self-delimiting and the compat layer pads them
with trailing zeros to fill the 15-byte UUID payload, so ignoring trailing bytes
is intentional and documented. Rejecting them would make every structured id
fail to decode — the jellycompat cross-instance test guards against that.

Not a live bug today (the only caller passes u[1:] from a 16-byte UUID, so body
is always exactly localHashLen, and idcodec re-packs to verify), but it is the
correct contract and zero-risk. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:34:13 -04:00

445 lines
19 KiB
SQL

-- Deterministic, cross-server content_id (see
-- docs/architecture/deterministic-content-id.md).
--
-- This migration remaps the *values* of content_id (and every column that
-- references it) from per-server Sonyflake ids to provider-derived structured
-- keys, and changes the storage collation of those columns to "C". The column
-- *type* does not change (text -> text COLLATE "C"), so the soft-reference graph
-- keeps working; only the values and collation change.
--
-- Mapping rules (kept in lockstep with internal/contentid; SchemeVersion = 1):
-- movies anchor precedence tmdb -> imdb -> tvdb => movie-<provider>-<id>
-- series anchor precedence tvdb -> tmdb -> imdb => series-<provider>-<id>
-- seasons compose from the series anchor => season-<provider>-<sid>-<n>
-- episodes compose from the series anchor => episode-<provider>-<sid>-<s>-<e>
-- The "-" component separator is an RFC 3986 unreserved char, so the id needs no
-- URL encoding (see internal/contentid). Items with no usable provider anchor
-- (and all item types other than movie/series, e.g. audiobook/ebook/podcast)
-- keep their existing Sonyflake id: there is no stable cross-server anchor to
-- derive from, so changing them gains nothing. New unmatched movies/series
-- instead get a path-derived local- id at
-- scan time (handled in Go, not here).
--
-- Safety: collisions (two ids deriving to the same key, or a key already taken
-- by an un-mapped row) are detected and left on Sonyflake — the migration never
-- merges or drops a row. The old->new map is retained in
-- content_id_migration_map for audit and rollback.
--
-- OPERATIONAL NOTE: on a large production dataset (10-50M rows) the value
-- UPDATEs and the COLLATE rewrite hold AccessExclusive locks for the duration.
-- Run off-peak. For very large installs, replace the in-transaction UPDATE loop
-- below with the batched/online procedure in the design doc (§7.2); the mapping
-- and collision logic here are reusable as-is.
-- +goose Up
-- Step 1: persistent audit/rollback map.
CREATE TABLE content_id_migration_map (
old_id text PRIMARY KEY,
new_id text NOT NULL,
entity text NOT NULL, -- media_item | season | episode
status text NOT NULL DEFAULT 'mapped' -- mapped | collision
);
-- Step 2a: derive movie/series keys from the denormalized provider columns,
-- applying the frozen precedence. Rows that already hold a non-Sonyflake key
-- (new_id = old_id) or have no usable anchor are skipped.
INSERT INTO content_id_migration_map (old_id, new_id, entity)
SELECT old_id, new_id, 'media_item'
FROM (
SELECT
mi.content_id AS old_id,
CASE
WHEN lower(mi.type) IN ('movie', 'movies') THEN
CASE
WHEN nullif(btrim(mi.tmdb_id, E' \t\n\r\f'), '') ~ '^[0-9]+$' THEN 'movie-tmdb-' || btrim(mi.tmdb_id, E' \t\n\r\f')
WHEN lower(btrim(mi.imdb_id, E' \t\n\r\f')) ~ '^tt[0-9]+$' THEN 'movie-imdb-' || lower(btrim(mi.imdb_id, E' \t\n\r\f'))
WHEN nullif(btrim(mi.tvdb_id, E' \t\n\r\f'), '') ~ '^[0-9]+$' THEN 'movie-tvdb-' || btrim(mi.tvdb_id, E' \t\n\r\f')
END
WHEN lower(mi.type) IN ('series', 'show', 'tv') THEN
CASE
WHEN nullif(btrim(mi.tvdb_id, E' \t\n\r\f'), '') ~ '^[0-9]+$' THEN 'series-tvdb-' || btrim(mi.tvdb_id, E' \t\n\r\f')
WHEN nullif(btrim(mi.tmdb_id, E' \t\n\r\f'), '') ~ '^[0-9]+$' THEN 'series-tmdb-' || btrim(mi.tmdb_id, E' \t\n\r\f')
WHEN lower(btrim(mi.imdb_id, E' \t\n\r\f')) ~ '^tt[0-9]+$' THEN 'series-imdb-' || lower(btrim(mi.imdb_id, E' \t\n\r\f'))
END
END AS new_id
FROM media_items mi
) d
WHERE d.new_id IS NOT NULL
AND d.new_id <> d.old_id;
-- Step 2b: seasons compose from their series' new key. Joining on the map means
-- only seasons of an already-mapped (legacy) series are remapped; seasons of a
-- post-cutover series (series_id already structured) naturally fall out.
INSERT INTO content_id_migration_map (old_id, new_id, entity)
SELECT
s.content_id,
'season-' || split_part(m.new_id, '-', 2) || '-' || split_part(m.new_id, '-', 3)
|| '-' || s.season_number,
'season'
FROM seasons s
JOIN content_id_migration_map m
ON m.old_id = s.series_id AND m.entity = 'media_item'
WHERE m.new_id LIKE 'series-%';
-- Step 2c: episodes compose from the series anchor + season/episode numbers.
INSERT INTO content_id_migration_map (old_id, new_id, entity)
SELECT
e.content_id,
'episode-' || split_part(m.new_id, '-', 2) || '-' || split_part(m.new_id, '-', 3)
|| '-' || e.season_number || '-' || e.episode_number,
'episode'
FROM episodes e
JOIN content_id_migration_map m
ON m.old_id = e.series_id AND m.entity = 'media_item'
WHERE m.new_id LIKE 'series-%';
-- Step 3: collision detection. Never remap into a key that is claimed twice, or
-- one already occupied by a row that is NOT being remapped (a pre-existing
-- deterministic row). Such rows stay on Sonyflake; operators reconcile dupes
-- separately. This guarantees the value remap can never violate a PK.
CREATE INDEX content_id_migration_map_new_id_idx ON content_id_migration_map (new_id);
UPDATE content_id_migration_map m
SET status = 'collision'
WHERE m.new_id IN (
SELECT new_id FROM content_id_migration_map GROUP BY new_id HAVING count(*) > 1
);
UPDATE content_id_migration_map m
SET status = 'collision'
WHERE m.status = 'mapped'
AND (
EXISTS (SELECT 1 FROM media_items x WHERE x.content_id = m.new_id)
OR EXISTS (SELECT 1 FROM seasons x WHERE x.content_id = m.new_id)
OR EXISTS (SELECT 1 FROM episodes x WHERE x.content_id = m.new_id)
);
-- Cascade collisions from a series to its children. A season/episode key embeds
-- the series anchor (season-<p>-<sid>-..., episode-<p>-<sid>-...), so if the
-- series itself was left on Sonyflake (collision), remapping a child would point
-- the embedded anchor at a series row that no longer exists under that key —
-- orphaning the child and dropping its rows from the anchor-derived history
-- query. Keep such children on Sonyflake too. (A collision on the season alone
-- does not force the episode, whose anchor is the series, not the season.)
UPDATE content_id_migration_map m
SET status = 'collision'
FROM seasons s
JOIN content_id_migration_map ms ON ms.old_id = s.series_id AND ms.status = 'collision'
WHERE m.entity = 'season' AND m.status = 'mapped' AND m.old_id = s.content_id;
UPDATE content_id_migration_map m
SET status = 'collision'
FROM episodes e
JOIN content_id_migration_map ms ON ms.old_id = e.series_id AND ms.status = 'collision'
WHERE m.entity = 'episode' AND m.status = 'mapped' AND m.old_id = e.content_id;
-- Step 4: enumerate every column in the content_id reference graph — the three
-- PKs, every FK child column referencing them, and a name sweep for the
-- unconstrained soft references — so the remap and collation change cover them
-- all without trusting a hand-list (per the design doc).
CREATE TEMP TABLE _cid_cols (table_name regclass, column_name name) ON COMMIT DROP;
-- The three primary keys.
INSERT INTO _cid_cols VALUES
('media_items'::regclass, 'content_id'),
('seasons'::regclass, 'content_id'),
('episodes'::regclass, 'content_id');
-- Every FK child column that references the family (reliable, from the catalog).
INSERT INTO _cid_cols
SELECT con.conrelid::regclass, att.attname
FROM pg_constraint con
JOIN unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = k.attnum
WHERE con.contype = 'f'
AND con.confrelid IN ('media_items'::regclass, 'seasons'::regclass, 'episodes'::regclass);
-- Soft references (no FK): text columns whose name is a known content-id holder
-- in this schema. The value remap is self-protecting — only values that match a
-- mapped Sonyflake id change — so an over-broad name match cannot corrupt
-- unrelated data; it only needs the names to genuinely hold content ids.
INSERT INTO _cid_cols
SELECT c.oid::regclass, a.attname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
JOIN pg_type t ON t.oid = a.atttypid
WHERE c.relkind IN ('r', 'p')
AND n.nspname = 'public'
AND t.typname IN ('text', 'varchar', 'bpchar')
AND a.attname IN (
'media_item_id', 'series_id', 'season_id', 'episode_id', 'content_id',
'season_content_id', 'episode_content_id', 'library_item_id', 'cover_item',
'item_id', 'similar_item_id', 'source_item_id'
)
AND c.relname NOT LIKE 'content_id_migration%'
AND NOT EXISTS (
SELECT 1 FROM _cid_cols ex WHERE ex.table_name = c.oid::regclass AND ex.column_name = a.attname
);
-- Step 5: drop the family's FK constraints (so PK and child values can be
-- remapped and recollated), saving their definitions to recreate afterward.
CREATE TEMP TABLE content_id_migration_fk (conname name, rel regclass, condef text) ON COMMIT DROP;
-- +goose StatementBegin
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT con.conname, con.conrelid::regclass AS rel, pg_get_constraintdef(con.oid) AS condef
FROM pg_constraint con
WHERE con.contype = 'f'
AND con.confrelid IN ('media_items'::regclass, 'seasons'::regclass, 'episodes'::regclass)
LOOP
INSERT INTO content_id_migration_fk (conname, rel, condef) VALUES (r.conname, r.rel, r.condef);
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.rel, r.conname);
END LOOP;
END $$;
-- +goose StatementEnd
-- Step 5b: drop every user trigger on a swept table for the duration. Two
-- reasons: (1) a trigger that names a family column in an UPDATE OF list or WHEN
-- clause blocks ALTER COLUMN TYPE; (2) ANY row trigger on a swept table would
-- fire per-row during the remap (e.g. the episode_catalog_entries
-- denormalization triggers — including plain AFTER INSERT/UPDATE/DELETE ones a
-- column-name match would miss), causing per-row plpgsql work and order-
-- dependent corruption of the denormalized tables. Those tables are remapped
-- directly by the column sweep, so dropping all of their triggers is both
-- correct and faster. Recreated verbatim in Step 8.
CREATE TEMP TABLE content_id_migration_trg (tgname name, rel regclass, def text) ON COMMIT DROP;
-- +goose StatementBegin
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT t.tgname, t.tgrelid::regclass AS rel, pg_get_triggerdef(t.oid) AS def
FROM pg_trigger t
WHERE NOT t.tgisinternal
AND t.tgrelid IN (SELECT table_name FROM _cid_cols)
LOOP
INSERT INTO content_id_migration_trg (tgname, rel, def) VALUES (r.tgname, r.rel, r.def);
EXECUTE format('DROP TRIGGER %I ON %s', r.tgname, r.rel);
END LOOP;
END $$;
-- +goose StatementEnd
-- Step 6: remap values across every family column. After a row's value becomes
-- the (non-Sonyflake) new id it no longer matches any old_id, so a single pass
-- per column suffices and is idempotent.
-- +goose StatementBegin
DO $$
DECLARE
c RECORD;
BEGIN
FOR c IN SELECT table_name, column_name FROM _cid_cols LOOP
EXECUTE format(
'UPDATE %s t SET %I = m.new_id FROM content_id_migration_map m '
|| 'WHERE m.status = ''mapped'' AND m.old_id = t.%I',
c.table_name, c.column_name, c.column_name
);
END LOOP;
END $$;
-- +goose StatementEnd
-- Step 6b: remap array-valued soft references. A text[] column that holds
-- resolved content_ids is excluded from the scalar sweep above on BOTH axes —
-- the type filter is scalar (text/varchar/bpchar) and the name list has
-- 'content_id', not the plural 'content_ids' — and an array cannot carry an FK,
-- so nothing else would touch it. Without this it keeps stale Sonyflake ids that
-- resolve to nothing. trending_discover_snapshots.content_ids is the only such
-- column in this schema; remap element-wise, preserving order, leaving unmapped
-- elements (collisions / unmatched) untouched. The WHERE EXISTS guard skips
-- empty and unaffected arrays so array_agg can never collapse the NOT NULL
-- column to NULL.
-- +goose StatementBegin
DO $$
BEGIN
IF to_regclass('public.trending_discover_snapshots') IS NOT NULL THEN
UPDATE trending_discover_snapshots t
SET content_ids = (
SELECT array_agg(COALESCE(m.new_id, u.elem) ORDER BY u.ord)
FROM unnest(t.content_ids) WITH ORDINALITY AS u(elem, ord)
LEFT JOIN content_id_migration_map m
ON m.status = 'mapped' AND m.old_id = u.elem
)
WHERE EXISTS (
SELECT 1
FROM unnest(t.content_ids) AS e(elem)
JOIN content_id_migration_map m ON m.status = 'mapped' AND m.old_id = e.elem
);
END IF;
END $$;
-- +goose StatementEnd
-- Step 7: change collation of every family column to "C". This rewrites the
-- columns' indexes; structured keys share long prefixes, so "C" (memcmp) is
-- load-bearing — without it ordered/probe paths regress below the Sonyflake
-- status quo (design doc §5.2).
-- +goose StatementBegin
DO $$
DECLARE
c RECORD;
BEGIN
FOR c IN SELECT table_name, column_name FROM _cid_cols LOOP
EXECUTE format(
'ALTER TABLE %s ALTER COLUMN %I TYPE text COLLATE "C"',
c.table_name, c.column_name
);
END LOOP;
END $$;
-- +goose StatementEnd
-- Step 8: recreate triggers, then the FK constraints (both sides now remapped
-- and "C"-collated).
-- +goose StatementBegin
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN SELECT def FROM content_id_migration_trg LOOP
EXECUTE r.def;
END LOOP;
FOR r IN SELECT conname, rel, condef FROM content_id_migration_fk LOOP
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I %s', r.rel, r.conname, r.condef);
END LOOP;
END $$;
-- +goose StatementEnd
-- NOTE: the design doc (§9.2.2) also proposes an expression index on
-- user_watch_history backing a "by show" display_id. It is intentionally NOT
-- created here: the current hot query (full-history DISTINCT ON) cannot use it
-- (its display_id references the joined episodes table and it lacks watched_at),
-- and it would index the wrong value for legacy/local episode rows (§9.2.5).
-- It belongs with the O(page) summary-table work (§9.2.3) that actually reads
-- it, using a resolved display_id that handles those rows correctly. Adding it
-- now would be pure write-amplification on every watch event with no reader.
-- +goose Down
-- Rebuild the column set for the reverse remap and collation reset.
CREATE TEMP TABLE _cid_cols (table_name regclass, column_name name) ON COMMIT DROP;
INSERT INTO _cid_cols VALUES
('media_items'::regclass, 'content_id'),
('seasons'::regclass, 'content_id'),
('episodes'::regclass, 'content_id');
INSERT INTO _cid_cols
SELECT con.conrelid::regclass, att.attname
FROM pg_constraint con
JOIN unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = k.attnum
WHERE con.contype = 'f'
AND con.confrelid IN ('media_items'::regclass, 'seasons'::regclass, 'episodes'::regclass);
INSERT INTO _cid_cols
SELECT c.oid::regclass, a.attname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
JOIN pg_type t ON t.oid = a.atttypid
WHERE c.relkind IN ('r', 'p')
AND n.nspname = 'public'
AND t.typname IN ('text', 'varchar', 'bpchar')
AND a.attname IN (
'media_item_id', 'series_id', 'season_id', 'episode_id', 'content_id',
'season_content_id', 'episode_content_id', 'library_item_id', 'cover_item',
'item_id', 'similar_item_id', 'source_item_id'
)
AND c.relname NOT LIKE 'content_id_migration%'
AND NOT EXISTS (
SELECT 1 FROM _cid_cols ex WHERE ex.table_name = c.oid::regclass AND ex.column_name = a.attname
);
-- Drop FKs and family triggers, revert values via the map, reset collation to
-- the database default, recreate triggers + FKs. NOTE: rollback is only
-- consistent for rows minted before the generation cutover; rows created with
-- structured ids after cutover have no map entry and remain structured
-- (design doc §7.2).
CREATE TEMP TABLE content_id_migration_fk (conname name, rel regclass, condef text) ON COMMIT DROP;
CREATE TEMP TABLE content_id_migration_trg (tgname name, rel regclass, def text) ON COMMIT DROP;
-- +goose StatementBegin
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT con.conname, con.conrelid::regclass AS rel, pg_get_constraintdef(con.oid) AS condef
FROM pg_constraint con
WHERE con.contype = 'f'
AND con.confrelid IN ('media_items'::regclass, 'seasons'::regclass, 'episodes'::regclass)
LOOP
INSERT INTO content_id_migration_fk (conname, rel, condef) VALUES (r.conname, r.rel, r.condef);
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.rel, r.conname);
END LOOP;
FOR r IN
SELECT t.tgname, t.tgrelid::regclass AS rel, pg_get_triggerdef(t.oid) AS def
FROM pg_trigger t
WHERE NOT t.tgisinternal
AND t.tgrelid IN (SELECT table_name FROM _cid_cols)
LOOP
INSERT INTO content_id_migration_trg (tgname, rel, def) VALUES (r.tgname, r.rel, r.def);
EXECUTE format('DROP TRIGGER %I ON %s', r.tgname, r.rel);
END LOOP;
END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$
DECLARE
c RECORD;
BEGIN
FOR c IN SELECT table_name, column_name FROM _cid_cols LOOP
EXECUTE format(
'UPDATE %s t SET %I = m.old_id FROM content_id_migration_map m '
|| 'WHERE m.status = ''mapped'' AND m.new_id = t.%I',
c.table_name, c.column_name, c.column_name
);
EXECUTE format(
'ALTER TABLE %s ALTER COLUMN %I TYPE text COLLATE pg_catalog."default"',
c.table_name, c.column_name
);
END LOOP;
END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN SELECT def FROM content_id_migration_trg LOOP
EXECUTE r.def;
END LOOP;
FOR r IN SELECT conname, rel, condef FROM content_id_migration_fk LOOP
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I %s', r.rel, r.conname, r.condef);
END LOOP;
END $$;
-- +goose StatementEnd
-- Reverse the array-valued soft-reference remap (mirror of Step 6b). Runs while
-- content_id_migration_map still exists, i.e. before the DROP TABLE below.
-- +goose StatementBegin
DO $$
BEGIN
IF to_regclass('public.trending_discover_snapshots') IS NOT NULL THEN
UPDATE trending_discover_snapshots t
SET content_ids = (
SELECT array_agg(COALESCE(m.old_id, u.elem) ORDER BY u.ord)
FROM unnest(t.content_ids) WITH ORDINALITY AS u(elem, ord)
LEFT JOIN content_id_migration_map m
ON m.status = 'mapped' AND m.new_id = u.elem
)
WHERE EXISTS (
SELECT 1
FROM unnest(t.content_ids) AS e(elem)
JOIN content_id_migration_map m ON m.status = 'mapped' AND m.new_id = e.elem
);
END IF;
END $$;
-- +goose StatementEnd
DROP TABLE IF EXISTS content_id_migration_map;