Skip to content

Index library.alternate_artist_name in search_doc at weight C (BS#3733) - #3734

Merged
jakebromberg merged 1 commit into
mainfrom
fix/3733-alt-name-search-doc
Oct 29, 2026
Merged

jakebromberg merged 1 commit into
mainfrom
fix/3733-alt-name-search-doc

Conversation

@jakebromberg

@jakebromberg jakebromberg commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Closes #3733

Summary

Classic catalog search could not find a release by one word of its alternate artist name. The station librarian filed a CD under Kath Bloom (BL 239) with the alternate artist name kath bloom & david Shapiro, and searching shapiro returned the other Shapiros but not this release.

This PR adds library.alternate_artist_name to the generated column library.search_doc at weight C, behind its own position gap. Any word of a release's alternate artist name now finds that release through stage 1 of Both-mode search. Because the column is generated, this works as soon as the release is saved or edited, with no job run. This is what /wxycdb's Lucene index did. The artist_search_alias path is unchanged and still adds the owning artist's card.

  • shared/database/src/migrations/0208_3733-library-search-doc-alternate-artist-name.sql: drop and re-add search_doc, following 0178 exactly: SET LOCAL lock and statement timeouts, DROP COLUMN IF EXISTS, ADD COLUMN ... GENERATED ALWAYS AS (...) STORED, CREATE INDEX IF NOT EXISTS library_search_doc_idx, and the trailing ANALYZE. The expression is the one the issue suggested. The header records the measurements below.
  • shared/database/src/schema.ts: the same expression and an updated comment. After the change, drizzle-kit generate reports No schema changes, nothing to migrate.
  • Comments in library.service.ts and docs/catalog-search/README.md / docs/playlist-search/README.md that said search_doc holds only artist and title.
  • The journal when is drizzle-kit's own stamp (2026-10-28 16:46 PDT). It is strictly above the tail (0207, 2026-10-23 15:39 PDT), so I did not bump it by hand. meta/0208_snapshot.json (10,130 lines) is drizzle-kit output, which every migration carries (validator Check 7). The production source is the migration (about 20 lines of DDL under its header) and the schema expression.

Effect on each search_doc reader

  1. Both-mode stage 1, searchLibraryByTsvector (GET /library/ with artist_name === album_title: classic catalog search on the DJ site and the DJ apps). This reader gets more hits: every word of an alternate name. The ranking form does not change: match_tier, then exact_score, album_score, plays. An alternate-name-only hit (C, 0.2 in ts_rank) sorts below an artist (A, 1.0) or title (B, 0.4) hit on the same word in the same tier. As with title hits today, an exact alternate-name word (tier 2) still sorts above a prefix-only artist hit (tier 1). Two side effects:

    • Within one artist's releases in the same tier, a release whose alternate name repeats the query words now scores slightly higher. This is the same thing that already lifts a release whose title repeats the word. On the clone, 1,961 of 4,844 alternate names contain the filing artist's name. For example, dj krush now lists Cold Krush Cuts (alternate DJ Krush & Coldcut) first among DJ Krush's releases (0.9982 against 0.9910).
    • A stage-1 hit still skips stage 2 and the track-search cascade (BS#1885). On the clone, alternate names add 1,755 words that appear in no artist name or title. A query whose only stage-1 hits are on such words now stops at stage 1, and does not reach the trigram tier and the CTA/LML cascade.

    The documented regression set in docs/catalog-search/README.md (stereolab transient, the velvet, love) has the same top 3 before and after. On the clone, coldcut now returns the ten Coldcut releases (A), then the three Various Artists Solid Steel titles that name Coldcut (B), then Cold Krush Cuts (C), which stage 1 could not return before.

  2. Alias-only probe after stage 1, searchLibraryAliasHitsAfterTsvector (BS#3552, Classic search drops alias matches when direct matches fill the page: reserve 10% of the page for them #3578). It excludes every row where search_doc @@ tsquery. A release whose own alternate name matches is now a stage-1 row: untagged, and ranked by weight. Before, it came back as an alias row. The artist's other releases still come back as alias rows. No release shows twice, and the id dedupe is still in place.

  3. V/A shortcut in fuzzySearchLibrary. The code does not change. The same-string case goes through Both-mode, so on the clone various artists now also matches 78 releases whose alternate name holds both words (5,213 in total). They rank after the 5,135 direct rows. The different-strings case still returns [] for a compilation artist.

  4. Request line and GET /library/search?query=, library.service.ts:searchLibrary. This is the reader near line 8300 that the issue points to. It calls searchLibraryBothMode without alias rows, so it gets the same stage-1 change. The request-line strategies keep at most 5 results (MAX_SEARCH_RESULTS), then keep only rows whose artist starts with the parsed artist (filterResultsByArtist). So an alternate-name-only hit filed under another artist is dropped there. In one narrow case behaviour changes: when such hits are the only stage-1 rows, the strategy now gets nothing back, where before stage 2 could have answered. Stage-2 rows go through the same artist-prefix filter.

  5. GET /library/query (library-search.service.ts). This route does not read search_doc. It uses trigram ILIKE over artist, title and label, plus the alias branch. No change.

  6. Flowsheet search (search.service.ts). It reads flowsheet.search_doc only. search.service.gapped-vector-schema-drift.test.ts pins its gap sentinel against library's expression, and that test still passes: same sentinel, same width.

  7. Other code that touches the column. Catalog export, metadata broadcast and the BMI list already leave search_doc out. captureCatalogDeleteSnapshot skips generated columns, so restore is not affected (see below). The cascade_library_artist_name trigger and PATCH /library/:id recompute the column as before. A PATCH of alternate_artist_name now recomputes it too, which is the fix.

Stage-1 latency on a prod-shaped clone

Method: I captured the stage-1 statement verbatim from the running backend (searchLibraryByTsvector, n=50). Then I ran it as EXPLAIN (ANALYZE) (planning plus execution time) against the 64,193-row prod clone (LOAD_CLONE_FIXTURE=true, seed-clone.sql), in a private PG 18.6 cluster. Each round has 200 runs per query after 10 warm-ups (300 runs in round 3). The two expressions were alternated old, new, old, new, old, new, and each state was rebuilt fresh (rewrite, index build, VACUUM ANALYZE) before it was measured.

Query Rows matched, before → after p95 before (3 rounds) p95 after (3 rounds) p50 before p50 after
shapiro 2 → 2 5.5 / 6.3 / 6.4 ms 4.7 / 6.3 / 6.7 ms 3.9 / 4.1 / 4.1 ms 3.8 / 4.0 / 4.7 ms
love 1,234 → 1,250 11.9 / 14.8 / 14.0 ms 13.5 / 13.8 / 20.9 ms 10.8 / 11.3 / 11.2 ms 11.3 / 11.3 / 15.0 ms
the 15,653 → 16,119 60.2 / 58.9 / 90.0 ms 71.3 / 67.9 / 87.9 ms 52.3 / 51.4 / 62.8 ms 57.0 / 58.0 / 68.0 ms
and 3,970 → 4,508 22.2 / 22.6 / 33.2 ms 30.8 / 48.4 / 30.5 ms 19.7 / 19.9 / 24.9 ms 23.1 / 23.6 / 24.6 ms

Rare words do not change. The most common words cost a few milliseconds more, because they match more rows: the +3%, and +14%. Caveats:

  • The machine's load average was about 30 from other sessions, so a single round can be off by tens of percent. Round 2's and p95 is one example.
  • The clone predates the librarian's filing, so it has no Kath Bloom release. shapiro matches the same two Sally Shapiro rows in both states, which makes it the rare-word case.
  • Prod is PG 14 on RDS, on different hardware. Compare these numbers with each other, not with prod.
  • A first pass compared the clone as loaded against the migrated clone, and showed shapiro p95 14.7 → 4.6 ms. That gain came from the COPY-loaded index (6,320 kB, bloated) and its stats, not from this change, so I discarded it in favour of the fresh-rebuild comparison above.

Deploy

  • This must deploy alone. migrate() runs every pending migration in one transaction, and the ACCESS EXCLUSIVE lock that DROP COLUMN takes is held until that transaction commits. As of 2026-10-28 17:07 PDT, the last auto-deploy, at main HEAD 31b0dfdc (2026-10-26 16:21 PDT), succeeded, so 0207 is applied. None of the 10 open PRs touches shared/database/src/migrations/ or schema.ts. If another migration merges first, let it deploy before this one merges.
  • Lock budget, measured on the clone (PG 18.6, three runs, each in a rolled-back transaction): DROP COLUMN about 1 ms; ADD COLUMN rewrite 1,061–1,213 ms (0178's expression: 994–1,103 ms on the same clone); CREATE INDEX 115–148 ms; ANALYZE 450–509 ms. That is about 1.6–1.9 s of statements under the lock, ANALYZE included. Through dev_env/init-db.mjs with SKIP_SEED=true (the runner the migrate image uses), the whole apply took 2.32 s of wall time, node start-up included. The timeouts (lock_timeout 30 s, statement_timeout 10 min) bound the wait and the rewrite. DJs are on air around the clock, so merge at a quiet hour.
  • PG 14 check on this PR: the migrate-dryrun lane restored the automated prod RDS snapshot of 2026-10-27 22:04 PDT (engine 14.22) to an ephemeral sandbox and applied the pending chain, which was 0208 alone because 0207 deployed before that snapshot. It reported dryrun-migrate: ok. The whole step took 11.5 s of wall time, node start-up and a cold restored instance included. That is an upper bound for the prod apply, not a measurement of the lock window.
  • On a fresh build the GIN index goes from 3,944 kB to 4,096 kB. On the clone, search_doc moves from attnum 31 to 32 and gets its pg_stats row back from the trailing ANALYZE.

Safety re-checks on current main

  • No view depends on library.search_doc. pg_depend/pg_rewrite returns 0 rows on the schema at 0207. library_artist_view and rotation_library_view read library, but not this column. The only dependents are the column's own generation expression and library_search_doc_idx.
  • restoreCatalogDeleteSnapshot still omits generated columns. Capture filters !column.generated (shared/database/src/catalog-delete-snapshot.ts:155), and replay inserts only the captured keys. So the attnum move has no effect on restore, and a restored row gets the new vector on insert.
  • Sentinel collisions: 0 of the 64,193 clone rows contain wxycsearchdocgap in artist_name, album_title or alternate_artist_name.
  • No contract change. search_doc is server-only. It is not in api.yaml or the catalog export.

Tests

  • tests/integration/library-search-alternate-artist-name.spec.js: one parameterized table over Both-mode GET /library/. Each row files the release, sets the alternate name with PATCH /library/:id, and searches at once with no job run:

    • shapiro finds Kath Bloom / Kath Bloom & David Shapiro and ranks it after Sally Shapiro;
    • coldcut finds DJ Krush / DJ Krush & Coldcut and ranks it after Coldcut;
    • the full credit still finds the release;
    • a name cleared with PATCH no longer finds it.

    Before the migration the first two rows failed with Received array: [9734] (Sally Shapiro only), which is the reported bug.

  • tests/integration/library-search-doc-position-gap.spec.js, extended with the real Cold Krush Cuts row:

    • a phrase across the title/alternate-name seam ('cuts' <-> 'dj', and the prefix form) matches nothing;
    • a phrase inside the alternate name ('krush' <-> 'coldcut') still matches;
    • the alternate name is weight C and sits behind its own gap.

    The phrase case is here rather than in the HTTP table for one reason. Over HTTP, a stage-1 miss falls through to the trigram tier, which matches the same release on its title, so the response cannot show the seam. 0178's spec tests its own seam at this level for the same reason.

  • tests/unit/database/schema.search-doc-alternate-artist-name.test.ts:

    • schema.ts has the three segments in order, each with its weight, and one gap before each later segment;
    • the latest migration that defines the column has the same expression as schema.ts (whitespace normalized);
    • the latest snapshot has the schema.ts expression byte for byte;
    • 0208's DDL has the 0178 shape.

    The comparisons find "the latest" through the journal, so if BS#871 redefines the column later, the test keeps working instead of breaking.

Local verification

  • Docker is not running on this machine, so I did not use npm run ci:testmock. Instead, the integration specs ran the way test.yml runs them. The database was a private PG 18.6 cluster built by dev_env/init-db.mjs with real migrations: a fresh database, so every migration ran in one transaction. The mock API, auth and backend ran on the host with the integration job's service env.
  • 48 integration suites pass: every library* suite, requestLine, both request-line-* suites, search-exact-case-insensitive, artist-prefix-fold-search and specialty-shows-catalog. On the first run, library-catalog-export and request-line-spotlight failed only because my local env lacked DIGITAL_ARCHIVE_STREAMING_ENABLED and FLOWSHEET_SPOTLIGHT_ENABLED. With CI's values they pass.
  • Unit tests: 760 suites, 16,507 tests pass.
  • These pass as well: typecheck, typecheck:tests (baseline matches), the jobs typecheck (14 jobs), format:check, lint:migrations, check-bulk-update-analyze.mjs --strict, precondition guards, cross-cache-identity flags, legacy entry-id writes, LML caller classification, check:db-mock-sync, check:better-auth-mock-sync, check-auth-tables-doc, check-sql-claim-docs, check:audit-coverage:ci and build.
  • ESLint passes on the changed files (0 errors). I did not run the full-repo npm run lint locally, because of its memory cost; CI runs it.
  • PG 14 syntax and the apply on prod-shaped data were checked by the migrate-dryrun lane on this PR (see Deploy).

After deploy

Classic search for shapiro on dj.wxyc.org should return the Kath Bloom release. A read-only check on prod is also enough:

SELECT id, album_title, alternate_artist_name
FROM wxyc_schema.library
WHERE search_doc @@ to_tsquery('simple', 'shapiro:*')
  AND alternate_artist_name ILIKE '%shapiro%';

Classic catalog search could not find a release by one word of its alternate artist name. The station librarian filed a CD under Kath Bloom with the alternate artist name `kath bloom & david Shapiro`, and searching `shapiro` did not return it. Stage 1 of Both-mode search reads library.search_doc, which held only artist_name (A) and album_title (B). The artist_search_alias path scores the whole credit (0.32 against its 0.40 floor) and is filled by a daily job.

Migration 0208 drops and re-adds search_doc with a third segment, alternate_artist_name at weight C, behind its own sentinel gap. It follows 0178: lock and statement timeouts, IF EXISTS guards, the index rebuild and the trailing ANALYZE. Because the column is generated, a release is findable by any word of its alternate name as soon as it is saved or edited. Weight C keeps an alternate-name-only hit below an artist or title hit on the same word.

schema.ts carries the same expression, and drizzle-kit reports no drift. The new integration spec covers the librarian's case, a one-word name inside a credit, the full credit, a name set and a name cleared with PATCH /library/:id, and the ranking against an artist-name hit. The position-gap spec now also covers the title/alternate-name phrase seam and the C band. A unit test pins schema.ts, the migration and the latest snapshot to one expression.
@github-actions

Copy link
Copy Markdown

Schema constraint shape report

no new constraints detected in this diff (uniqueIndex, .unique(), SET NOT NULL, CHECK, FK)

@jakebromberg
jakebromberg merged commit 93f3ce2 into main Oct 29, 2026
7 checks passed
@jakebromberg
jakebromberg deleted the fix/3733-alt-name-search-doc branch October 29, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Catalog search can't find a release by one word of its alternate artist name: add alternate_artist_name to library.search_doc

1 participant