* 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>
377 lines
19 KiB
Markdown
377 lines
19 KiB
Markdown
# Deterministic, Cross-Server `content_id`
|
||
|
||
**Status:** Implemented (branch `feat/deterministic-content-id`)
|
||
**Scope:** catalog / scanner / metadata
|
||
**Key code:** `internal/contentid`, `internal/metadata/service.go`,
|
||
`internal/catalog/history_source.go`,
|
||
`migrations/sql/20260612130000_deterministic_content_id.sql`,
|
||
`migrations/sql/20260614120000_content_id_online_reid.sql`
|
||
|
||
This is the dev-facing reference for *why* `content_id` is what it is and *how*
|
||
it works. It merges the original design spec, the implementation notes, and the
|
||
PR write-up into one place.
|
||
|
||
---
|
||
|
||
## 1. The problem
|
||
|
||
`content_id` is the anchor almost everything hangs off: artwork, metadata, watch
|
||
history, progress, favorites, ratings, collections, credits. Historically it was
|
||
a **Sonyflake** — a locally generated, time-ordered random number minted at scan
|
||
time.
|
||
|
||
That means the *same* movie matched on two servers (or re-imported into a fresh
|
||
instance) gets a **different** id each time. Consequence: two servers holding the
|
||
identical title share *none* of that state. Run more than one Silo, or try to
|
||
sync a profile's history between servers, and the same film shows up as a
|
||
stranger on the other side — no resume point, no watched flags, no ratings. There
|
||
was no stable identity to build any cross-server feature on.
|
||
|
||
We want: **the same logical title produces the same `content_id` on every server,
|
||
every time**, derived from a globally stable anchor (the provider IDs already in
|
||
the library), and **stable across file renames/re-encodes** so linked state
|
||
survives.
|
||
|
||
---
|
||
|
||
## 2. The decision
|
||
|
||
Make `content_id` a **deterministic, provider-derived, structured natural key**,
|
||
stored as **`text COLLATE "C"`**:
|
||
|
||
| Item | `content_id` |
|
||
| ---- | ------------ |
|
||
| Movie | `movie-tmdb-228064` (fallback `movie-imdb-tt2413338`) |
|
||
| Series | `series-tvdb-296762` |
|
||
| Season | `season-tvdb-296762-1` |
|
||
| Episode | `episode-tvdb-296762-1-5` |
|
||
| Unmatched / local | `local-<128-bit path hash, hex>` |
|
||
|
||
Why this shape:
|
||
|
||
- **Collision-free with no hashing.** Provider IDs are unique by construction, so
|
||
this is a natural key, not a surrogate — there is no "how many bits" question
|
||
and no birthday bound.
|
||
- **No column-type migration.** It stays `text`, so the wide soft-reference graph
|
||
keeps working; only the *values* are remapped, not the type.
|
||
- **Debuggable.** A row's identity is legible in logs and queries, and the
|
||
scanner's match step becomes a direct PK lookup ("do I already have
|
||
`movie-tmdb-228064`?") instead of a join through `media_item_provider_ids`.
|
||
- **Stable** across renames/re-encodes — a movie keeps its tmdb id.
|
||
|
||
### Why `content_id`, not a file-level id
|
||
|
||
Two distinct identities at different granularities, not to be conflated:
|
||
|
||
- **`content_id`** — the *logical* item (movie/series/season/episode). One id,
|
||
*many* files (1080p + 4K + cuts; a series owns thousands of episode files).
|
||
- **`media_files.id`** — one *physical* file; a per-server serial that only
|
||
answers "which file on disk."
|
||
|
||
`content_id` is the high-value target (35 FKs + ~25 unconstrained soft references
|
||
point at it). Its stability requirement is the *opposite* of a file id's: a file
|
||
id *should* change on rename; a `content_id` must *not*, or every server would
|
||
drop a title's watch history on a routine rename. That's why it's anchored on
|
||
rename-invariant provider IDs, never a path or file hash. File-level dedup is a
|
||
separate, deferred concern.
|
||
|
||
---
|
||
|
||
## 3. How the id is derived
|
||
|
||
### Format (SchemeVersion 1)
|
||
|
||
```text
|
||
movie-<provider>-<id> movie-tmdb-228064
|
||
series-<provider>-<id> series-tvdb-296762
|
||
season-<provider>-<seriesId>-<n> season-tvdb-296762-1
|
||
episode-<provider>-<seriesId>-<s>-<e> episode-tvdb-296762-1-5
|
||
local-<hex112> local-<sha256(path)[:14]>
|
||
```
|
||
|
||
The component separator is `-`, an [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-2.3)
|
||
*unreserved* character. Every component is `[a-z0-9]+` (or `tt` + digits for
|
||
IMDb), so `-` is an unambiguous delimiter, and because it never needs
|
||
percent-encoding the id doubles as its own tidy URL path segment
|
||
(`/item/series-tvdb-296762`) — identical to what is stored in the database, so
|
||
there is no encode/decode boundary and an operator can grep the value straight
|
||
out of a URL or log. (`:` would be legal in a path too, but `encodeURIComponent`
|
||
escapes it to `%3A`; an unreserved separator sidesteps that entirely.)
|
||
|
||
- The leading entity-type token domain-separates namespaces, so a movie and an
|
||
episode can never alias on a coincidental number.
|
||
- IDs are normalized: lowercase scheme tokens, whitespace trimmed, IMDb keeps its
|
||
`tt` prefix verbatim.
|
||
|
||
### Frozen provider precedence
|
||
|
||
Each item anchors on **one** canonical provider, chosen by fixed precedence so
|
||
two servers that see the same tags pick the same anchor:
|
||
|
||
- **Movies:** `tmdb → imdb → tvdb`
|
||
- **Series / Season / Episode:** `tvdb → tmdb → imdb` (TVDB is episode-canonical)
|
||
|
||
Precedence **and** exact string format are frozen behind a code constant
|
||
(`contentid.SchemeVersion = 1`), *not* a DB column — any scheme change forces a
|
||
full remap that normalizes every row at once, so there is never a mixed-version
|
||
population. Staleness is recoverable by recomputing the expected id from the
|
||
provider columns and comparing.
|
||
|
||
### The load-bearing invariant: the show falls out of the episode id
|
||
|
||
Season/episode ids compose from the **series anchor + season/episode numbers**
|
||
(both present in filenames and universally available), *not* their own provider
|
||
episode IDs (often missing). Because `episode-<provider>-<seriesId>-<s>-<e>`
|
||
embeds the series anchor, the series id is a pure **string transform** of the
|
||
episode id — no catalog lookup:
|
||
|
||
```text
|
||
episode-tvdb-296762-1-5 → series-tvdb-296762
|
||
movie-tmdb-228064 → movie-tmdb-228064 (movies/series: unchanged)
|
||
```
|
||
|
||
This is relied on by the hot watch-history query (§5). The format must never
|
||
break it. (`contentid.SeriesIDFromContentID` is the canonical transform.)
|
||
|
||
### Where the anchor comes from
|
||
|
||
For tagged libraries the IDs are in the folder/file name
|
||
(`{imdb-tt2413338} {tmdb-228064}`), so both servers derive the same id **from the
|
||
path string at scan time, with no API call**. The denormalized
|
||
`media_items.{tmdb,imdb,tvdb}_id` columns and the `media_item_provider_ids` link
|
||
table are the other sources. `content_id` is *derived from* the link table, not a
|
||
replacement — keep the full provider-ID set indexed.
|
||
|
||
---
|
||
|
||
## 4. What was built
|
||
|
||
| Area | File(s) | Summary |
|
||
| ---- | ------- | ------- |
|
||
| Derivation core | `internal/contentid/contentid.go` | `ForMovie`/`ForSeries`/`ForSeason`/`ForEpisode`/`ForLocal`, `SeriesIDFromContentID`, `IsProviderAnchored`/`IsLocal`, `SchemeVersion`, frozen precedence, normalization. |
|
||
| Generation wiring | `internal/metadata/service.go` | `deriveLogicalContentID`/`deriveSeasonContentID`/`deriveEpisodeContentID` replace `idgen.NextID()` at every mint site (skeleton, provider-match, explicit/implicit seasons, episodes, scanner fallback). |
|
||
| Re-ID on match | `internal/metadata/canonicalize.go` | Promotes a `local-` placeholder to its deterministic id at first confirmed match. |
|
||
| Hot query | `internal/catalog/history_source.go` | Watch-history `display_id` resolves the show via the string transform for anchored episodes; skips the `episodes_pkey` probe. |
|
||
| Value-remap migration | `migrations/sql/20260612130000_deterministic_content_id.sql` | Collision-safe Sonyflake → structured remap across the whole reference graph + `COLLATE "C"`, with rollback. |
|
||
| Online re-ID migration | `migrations/sql/20260614120000_content_id_online_reid.sql` | Adds `silo_rename_content_id` + `ON UPDATE CASCADE` to the content-id FK family. |
|
||
|
||
### Generation: determinism at scan time
|
||
|
||
The scanner already parses provider tags from names, so `createOrFindSkeleton`
|
||
derives `movie-tmdb-…`/`series-tvdb-…` immediately — two servers seeing the same
|
||
tags mint the same id with no provider API call. This is the primary target
|
||
(Radarr/Sonarr/Plex-tagged libraries). Untagged new items get a path-derived
|
||
`local-` id keyed on the **file path** (not the folder), so distinct pre-merge
|
||
skeletons in a shared folder stay separate while being stable across rescans.
|
||
|
||
Same-server convergence is preserved by the existing dedup
|
||
(`findExistingByProviderIDs`) plus `UpsertTx`'s
|
||
`ON CONFLICT (content_id) DO UPDATE` backstop.
|
||
|
||
Non-movie/series items (audiobook/ebook/podcast) and anchorless items **stay on
|
||
Sonyflake** — there's no stable cross-server anchor to derive from, so changing
|
||
them gains nothing.
|
||
|
||
### Re-ID on match: untagged items converge later
|
||
|
||
A tagged file is born with the right id; an *untagged* file gets a `local-` id
|
||
and only learns its provider IDs later when the match worker confirms a result. A
|
||
single gate in `mergeAndPersist` (`canonicalizeLocalContentID`) promotes the
|
||
placeholder at first confirmed match:
|
||
|
||
```text
|
||
SCAN a new file ← unchanged, still no network call
|
||
▼
|
||
createOrFindSkeleton ──► tag? YES ► movie-tmdb-123 NO ► local-<hash>
|
||
▼
|
||
(later) match worker → mergeAndPersist → canonicalizeLocalContentID
|
||
│ Is the id still local- AND do we now have provider IDs?
|
||
│ no → do nothing
|
||
│ yes → Does a row already sit at movie-tmdb-123?
|
||
│ yes → MERGE this row into it (rebindItemToExistingItem)
|
||
│ no → RENAME local-<hash> → movie-tmdb-123
|
||
▼ └─ DB auto-moves child rows (ON UPDATE CASCADE)
|
||
rest of mergeAndPersist runs with the corrected id
|
||
```
|
||
|
||
The guard is a single `IsLocal` prefix check, so tagged content and all refreshes
|
||
pay nothing; the promotion is self-healing (a partial run re-runs on the next
|
||
match). The rename primitive `silo_rename_content_id` reuses the migration's
|
||
catalog-driven column enumeration so the two stay in lockstep.
|
||
`ON UPDATE CASCADE` only fires when a `content_id` actually changes (essentially
|
||
never outside this promotion), so there is no steady-state cost.
|
||
|
||
### The migration
|
||
|
||
The column **type does not change** (`text` → `text COLLATE "C"`); the **values**
|
||
are remapped via add-then-swap, not in-place PK mutation. It dynamically
|
||
enumerates the reference graph (the three PKs, every FK child from
|
||
`pg_constraint`, and soft-reference columns by name — **65 columns** on the real
|
||
schema), drops/recreates FKs and triggers, and retains a `content_id_migration_map`
|
||
audit table for rollback.
|
||
|
||
**Collisions are never merged.** If two items derive to the same key, or a key is
|
||
already taken, those rows keep their Sonyflake id and are flagged `collision` in
|
||
the map — the migration can never violate a PK or silently drop a row. Collision
|
||
status **cascades** from a series to its seasons/episodes (otherwise a uniquely
|
||
numbered episode under a colliding series would get an `episode-…` key whose
|
||
embedded series anchor points at a non-existent row).
|
||
|
||
`COLLATE "C"` is applied to every referencing column in the same migration so
|
||
join keys match on both type and collation.
|
||
|
||
---
|
||
|
||
## 5. Why `COLLATE "C"` is mandatory
|
||
|
||
Provider-derived IDs are pure ASCII, but `content_id` inherits the DB default
|
||
`en_US.utf8` collation. The design **regresses without `COLLATE "C"`**:
|
||
|
||
- For **equality / hash joins** (the watch-history join), `en_US.utf8` is
|
||
deterministic so PostgreSQL already compares raw bytes — collation is
|
||
irrelevant.
|
||
- For **ordering** comparisons (B-tree descent on PK probes, `ORDER BY`, range
|
||
scans), `en_US.utf8` invokes libc `strcoll` while `C` is a plain `memcmp`.
|
||
- Structured keys share long prefixes (`episode-tvdb-296762-…`), which makes
|
||
`strcoll` *worse* than on 18-digit Sonyflake strings — so a structured key
|
||
under `en_US.utf8` is *slower* than the status quo. Under `C` it's ~3–5×
|
||
faster.
|
||
|
||
So `COLLATE "C"` is load-bearing, not a free upgrade.
|
||
|
||
### The hot query
|
||
|
||
The watch-history page query (`history_source.go`) resolves each history row's
|
||
episode key to its series, then inner-joins `media_items`, deduped over the
|
||
profile's *entire* history. Two facts:
|
||
|
||
- `DISTINCT ON` can't short-circuit on `LIMIT` — cost is **O(history size)** with
|
||
PK probes per row, not O(page).
|
||
- The structured id lets us **delete the `LEFT JOIN episodes`** that existed only
|
||
to fetch `series_id`: for anchored episode ids the show is a string transform.
|
||
The join is null-poisoned for anchored ids (`= NULL` is an unsatisfiable b-tree
|
||
scan key) so the planner never descends `episodes_pkey` for them. Legacy/`local-`
|
||
ids still fall through the join.
|
||
|
||
The remaining `JOIN media_items` stays — it's for access/library filtering, not
|
||
the show id. Reducing full-history scans to O(page) for the heaviest profiles is
|
||
follow-up work (a per-`(profile, display_id)` summary table), not part of this
|
||
change.
|
||
|
||
---
|
||
|
||
## 6. Performance — measured at production scale
|
||
|
||
Measured on an isolated, prod-tuned PostgreSQL 18 instance (`shared_buffers=4GB`)
|
||
loaded with an **exact-cardinality copy of production**: 184k items, 1.93M
|
||
episodes, 775k watch-history rows / 1,622 profiles. "Before" reproduces
|
||
production today (Sonyflake, `en_US.utf8`); "after" applies the structured-key
|
||
remap + `COLLATE "C"`. **Every metric improves.**
|
||
|
||
**`content_id` index-probe cost** — 300k forced point lookups over 1.9M keys:
|
||
|
||
The last three columns are *why* the faster schemes were rejected: speed alone
|
||
isn't the goal — the scheme has to carry the properties the whole feature exists
|
||
for. ✓ = has the property, ✗ = lacks it.
|
||
|
||
| Key scheme | Index size | µs / probe | vs today | Cross-server deterministic | Zero-join show transform | Human-readable |
|
||
| ---------- | ---------- | ---------- | -------- | :------------------------: | :----------------------: | :------------: |
|
||
| Sonyflake text, `en_US.utf8` *(today)* | 74 MB | 5.05 | 1.00× | ✗ | ✗ | ✗ |
|
||
| 128-bit hash `bytea(16)` | 74 MB | 1.80 | 2.81× | ✓ | ✗ | ✗ |
|
||
| `bigint` surrogate | 41 MB | 1.23 | 4.11× | ✗ | ✗ | ✗ |
|
||
| **Structured + `COLLATE "C"` (this PR)** | 84 MB | **1.86** | **2.71×** | ✓ | ✓ | ✓ |
|
||
|
||
The structured key lands within 3% of a 128-bit hash and is the **only all-✓
|
||
row**. The two faster schemes each give up something load-bearing:
|
||
|
||
- **128-bit hash** is cross-server deterministic (it hashes the same natural
|
||
key), but an opaque digest can't be string-transformed, so episode→series
|
||
needs a catalog join (no zero-join hot path), and it isn't legible in logs,
|
||
URLs, or `psql`.
|
||
- **`bigint` surrogate** is just the status quo's narrow integer: fast because
|
||
it's 8 bytes, but per-server (not reproducible elsewhere), no embedded anchor
|
||
to transform, and not meaningful to a human.
|
||
|
||
Index grows ~14% (84 vs 74 MB), immaterial.
|
||
|
||
**Real history-page query** — heaviest profile (25,384 rows):
|
||
|
||
| State | Latency | `episodes_pkey` probes | Speedup |
|
||
| ----- | ------- | ---------------------- | ------- |
|
||
| Before | 385 ms | 25,348 | 1.00× |
|
||
| **This PR** | **150 ms** | **2,775** | **2.57×** |
|
||
|
||
**Concurrency** (`pgbench`, 150 real profiles): at 100 concurrent users,
|
||
**311 ms / 321 tps → 181 ms / 551 tps** — 1.7× throughput, 42% lower tail
|
||
latency. Comparison cost is pure CPU, so cheaper `COLLATE "C"` comparisons raise
|
||
the throughput ceiling under load.
|
||
|
||
*Caveat: warmed (dataset fits in `shared_buffers`); the before/after **ratios**
|
||
are the robust result, not absolute tps.*
|
||
|
||
---
|
||
|
||
## 7. Known limitations / follow-ups
|
||
|
||
- **Untagged series children.** A series that accumulated season/episode rows
|
||
*before* it matched keeps those children on Sonyflake until a follow-up
|
||
`recomposeSeriesChildIDs` pass re-derives them. Rare at first match (children
|
||
usually don't exist yet); not yet wired.
|
||
- **Cross-provider reconciliation.** A series tagged tmdb-only on one server and
|
||
tvdb-only on another still diverges — both paths derive from the denormalized
|
||
provider columns. Proper fix: consult `media_item_provider_ids` before walking
|
||
the precedence list.
|
||
- **O(history) query shape.** The watch-history `DISTINCT ON` over full history is
|
||
the real liability at concurrency; the string transform removes a probe but not
|
||
the full scan. The O(page) summary table is the fix, deferred.
|
||
- **Migration at scale.** Runs in one transaction holding `AccessExclusive` locks
|
||
for the COLLATE rewrite + remap — run off-peak; very large installs (10–50M
|
||
rows) should use the batched procedure noted in the migration header.
|
||
- **`local-` uses the absolute path,** so a same-server remount re-IDs local
|
||
items. Acceptable for the best-effort local namespace. The hash is 112 bits
|
||
(`sha256(path)[:14]`) so a `local-` id packs losslessly into a compat UUID
|
||
(see below).
|
||
- **Client-visible id churn.** `jellycompat` encodes `content_id` into the
|
||
client-facing UUID, so item ids seen by Android/Apple/Jellyfin clients change at
|
||
migration (caches/resume keyed on item id reset once). **silo-android** and
|
||
**silo-apple** should be made aware; server-side state is fully remapped. The
|
||
encoding is *reversible* — a structured or local `content_id` is bit-packed
|
||
into the UUID (`contentid.Pack`/`Unpack`), not hashed into a lookup table — so
|
||
a compat item id decodes by pure computation and is stable across server
|
||
restarts and across instances. Only arbitrary names (genres, studios) and the
|
||
rare `content_id` too large to pack still use the in-memory hash map.
|
||
|
||
---
|
||
|
||
## 8. Prior art
|
||
|
||
- **Plex:** logical identity is a provider-namespaced GUID (`plex://movie/…`,
|
||
`com.plexapp.agents.imdb://tt…`) — exactly this shape. (Its per-server integer
|
||
`ratingKey` is the non-portable counterexample we avoid.)
|
||
- **Jellyfin / Emby:** store namespaced provider IDs and match on them; item GUIDs
|
||
are deterministic. Same principle — anchor identity on the provider, not a
|
||
per-server sequence.
|
||
|
||
---
|
||
|
||
## Appendix — alternatives rejected
|
||
|
||
Retained so the decision isn't re-litigated:
|
||
|
||
- **Keep Sonyflake.** Non-deterministic per server — the entire problem.
|
||
- **Hash provider tuple → `uuid` (128-bit).** Collision resistance is unnecessary
|
||
(input is already unique), the id becomes opaque, and it forces a `text → uuid`
|
||
migration across 35 FKs + ~25 soft refs whose failure mode is a seq-scan cliff
|
||
(miss one column → `uuid = text` per-row cast → index disabled). Pays a large
|
||
migration cost for an ~8-byte width win that only matters at a 50M-row ceiling.
|
||
- **Hash provider tuple → `text` hex.** Opaque like the uuid, large like the
|
||
natural key — no reason to pick it over the legible structured key.
|
||
- **Path or content hash.** A title spans many files at many paths (no single path
|
||
to hash), and the hash changes on rename — the opposite of the stability
|
||
requirement. Only appropriate for *file-level* identity.
|
||
- **Pack provider + numeric id into `bigint`.** Variable-width provider numbers
|
||
and S/E packing make the encoding brittle; not worth it over a legible key.
|
||
- **File-level deterministic id (`media_files`).** Deferred, not rejected — lower
|
||
value (no metadata/watch state hangs off it), with open problems (server-local
|
||
mount anchors, same-file-in-two-libraries). A complement, out of scope here.
|