Skip to content

perf(search): rebuild PostgreSQL lookup path and stabilize live UI - #66

Merged
blurbery merged 4 commits into
mainfrom
fix/postgres-search-performance-ui-stability
Aug 29, 2026
Merged

perf(search): rebuild PostgreSQL lookup path and stabilize live UI#66
blurbery merged 4 commits into
mainfrom
fix/postgres-search-performance-ui-stability

Conversation

@blurbery

@blurbery blurbery commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Removes per-keystroke View Transitions, keeps the search control/result geometry stable while replacement data loads, and makes search chrome explicitly opaque so typing does not trigger backdrop blur/recomposition.
  • Rebuilds the PostgreSQL search hot path around stored/indexed title, alias, and episode search documents. No Meilisearch, Typesense, Bleve index, extra daemon, or duplicate persistent search store is introduced.
  • Adds a tiny Bleve-inspired relevance layer over PostgreSQL's already indexed and capped typo candidates: automatic 0/1/2 edit limits by token length, phrase/token coverage, exact/contiguous preference, and alias-aware stable reranking.
  • Bounds the complete PostgreSQL FTS + fuzzy + rerank path to 3 seconds, cancels superseded requests, disables automatic replay of interactive failures, and bounds inactive search caches.

Root causes found

  1. Live query-string replacement used a full route View Transition for every debounced keypress, snapshotting and animating the whole page.
  2. Alias ranking used correlated subqueries that could scan all 141,104 alias rows twice for every media candidate. The measured old lanterns plan visited roughly four million alias index entries.
  3. The mixed FTS query searched and ranked thousands of overview hits even when a title hit existed, then discarded all overview-only rows in the final title gate.
  4. Episode search rebuilt title and overview vectors from 385,267 source rows and performed an availability index probe per FTS candidate.
  5. One-to-three-character token prefixes expanded too broadly during typeahead.
  6. The first separated fuzzy query still correlated alias scoring to each media candidate. On the production-sized catalogue, Gane of Throns hit the new 3-second safety cutoff; its plan assigned >112k cost to repeated alias-index filters.
  7. Raw strict-word similarity overvalued one matching word: Gane of Throns ranked Justice League: Throne of Atlantis above Game of Thrones.
  8. Search-as-you-type query keys used the general cache/retry policy, retaining poster-heavy results and automatically replaying failed PostgreSQL/request-provider lookups.
  9. Clearing the prominent input did not navigate the empty value, leaving the previous result route/request mounted.

PostgreSQL lookup design

Normal FTS / prefix lookup

  • Scores aliases once in a materialized, GIN-backed CTE and reuses the aggregate for admission and ranking.
  • Uses one normalized prefix tsquery; exact-token matches are already a subset, so the duplicate title-rank calculation is removed.
  • Materializes title candidates first and gates the overview branch with a one-time NOT EXISTS; PostgreSQL shows the overview scans as never executed when title hits exist.
  • Uses exact normalized-title B-tree lookup for single tokens shorter than four characters.
  • Uses leading normalized-title B-tree lookup while the final word of a multiword typeahead query is shorter than four characters, then resumes word-prefix FTS.
  • Adds normalized title, stored title vector, and stored overview vector fields to the already-maintained episode_catalog_entries table. Existing synchronous refresh triggers feed the search-field trigger, so no queue/watcher is required.
  • Adds retry-safe concurrent GIN/B-tree indexes and collapses the 237 multi-library duplicate rows with DISTINCT.

Typo recovery / relevance

  • Fuzzy recovery is terminal and conditional: it runs only on page 0 when FTS produced fewer than five results and the query has a token of at least four characters.
  • An exact normalized title suppresses the fuzzy pass completely. This removed about 0.6-1.0 seconds of needless fuzzy work from exact Game of Thrones / Breaking Bad plans during development.
  • Canonical-title and alias typo candidates are now two independent pg_trgm GIN scans. They are UNION ALLed, aggregated by content_id, permission-filtered, and hydrated once. No correlated alias rescan remains.
  • SQL ranking blends 65% whole-title similarity with 35% strict-word similarity. This retains long-title recall without allowing one matching word to dominate a phrase.
  • PostgreSQL contributes at most 50 fuzzy rows. The in-process reranker reads at most four relevant aliases per row (50 items / 200 aliases maximum), considers 16 query tokens / 64 title tokens / 64 runes per token, checks cancellation between candidates, and allocates no persistent state.
  • The rerank edit policy mirrors Bleve auto fuzziness (0 edits for <=2 characters, 1 for 3-5, 2 for longer terms), but Silo does not import Bleve's index/storage engine or maintain a second catalogue.
  • Alias lookup and reranking run inside the same 3-second transaction. A timed-out transaction gets a separate one-second cleanup context so a canceled request does not force connection churn.

Web stability and failure behavior

  • Raises prominent live-search debounce from 100 ms to 200 ms.
  • Uses ordinary replace navigation for prominent typing/submission; compact/global navigation can retain normal route transitions.
  • Navigates the empty query on clear, aborting/removing stale search results.
  • Synchronizes the input with browser history and search-scope changes.
  • Retains page 0 as placeholder data while the replacement is pending, but prevents old remaining pages from being fetched under the new query key.
  • Interactive local and request-provider searches use retry: false and 30-second inactive gcTime; normal catalogue/request screens retain their existing policies.
  • Canceled/superseded HTTP requests do not log/write a false 500. Server deadline exhaustion returns a retryable 504 search_timeout response and the UI hides stale results behind an explicit retry state.
  • Search input, scope chips, and timeout state use the opaque page background with no backdrop-filter / -webkit-backdrop-filter.

Production-sized benchmarks

Catalogue: 15,826 media items, 141,104 aliases, 385,267 episodes, and 247,842 episode-catalog rows. Baseline pg_stat_statements for the main old query was 4,659.2 ms mean and 26,187.7 ms max across 41 calls.

Apples-to-apples old vs new plans

Measured with EXPLAIN (ANALYZE, BUFFERS, TIMING OFF), limit 61, and a hard statement timeout. Final schema/index behavior was exercised with a transaction-local TEMP copy of the production-sized episode catalogue; that transaction was rolled back. Timeout gains are conservative lower bounds.

Query Before planning Before execution After planning After execution Execution gain
l 29.794 ms 2,202.980 ms 32.558 ms 152.108 ms 14.5x
la >3,000 ms timeout 4.819 ms 116.628 ms >25.7x
lan 55.548 ms 1,179.570 ms 5.462 ms 6.160 ms 191.5x
the >3,000 ms timeout 2.740 ms 94.753 ms >31.7x
lant 46.652 ms 139.911 ms 13.424 ms 33.025 ms 4.2x
star 56.214 ms 1,453.003 ms warm / 2,814.537 ms first 7.302 ms 171.635 ms 8.5x warm / 16.4x first
love >3,000 ms timeout 7.996 ms 189.539 ms >15.8x
dune 29.491 ms 122.883 ms 6.162 ms 12.601 ms 9.8x
the m >3,000 ms timeout 12.735 ms 162.034 ms >18.5x
harry p 36.410 ms 197.137 ms 3.496 ms 20.416 ms 9.7x
lanterns 91.626 ms 5,605.681 ms 8.444 ms 3.995 ms 1,403x

Buffer evidence: lanterns fell from 156,795 shared-buffer hits to 137 shared/local pages, and broad star fell from 36,814 shared hits to 3,346 shared/local pages. The title-first plan marks both overview scans never executed for title-bearing searches.

User example searches

The before figures below are old live HTTP resolver timings captured from the authenticated browser exercise/server logs. The after figures are the final PostgreSQL database-plan lookup, so they deliberately are not presented as direct end-to-end speedup ratios.

Query Old live local resolver New DB plan
Game of Thrones 4,466 ms 11.597 ms
Breaking Bad 4,314 ms 7.467 ms
Fast 8,677 ms 17.409 ms

The exact-title guard skips fuzzy recovery for the first two. Fast returns the title family through normal indexed FTS/prefix search.

Typo query and relevance checks

Read-only real-catalogue query, same 3-second statement timeout and pinned pg_trgm threshold:

Query Before candidate SQL Final candidate SQL First result / result family
Gane of Throns >3,000 ms timeout 287.705 ms Game of Thrones
Breking Bad 384.193 ms Breaking Bad
Fast and Furios 111.047 ms Fast & Furious, then related franchise titles
Hary Poter 181.234 ms Harry Potter titles
Avengrs Endgme 117.484 ms Avengers: Endgame

The original word-only rank placed Justice League: Throne of Atlantis ahead of Game of Thrones; the blended SQL and token/edit rerank correct that ordering.

Bounded Go reranker

BenchmarkRerankFuzzyItemsCappedCandidateSet, 50 candidates × four aliases (the enforced worst case), Apple M4, five runs:

Metric Result
Mean time 0.182 ms/op
Range 0.179-0.191 ms/op
Bounded transient allocation 238,890-238,891 B/op
Allocations 4,662 allocs/op

This workload is constant with respect to total library size. There is no cache, goroutine, worker, or persistent in-process index to leak.

Browser and server checks

  • Authenticated live browser scenarios: Game of Thrones → clear → Breaking Bad → clear → Fast, plus rapid 40 ms/key bursts and intermediate geometry sampling.
  • Input geometry stayed at 456×56 / x=32 / y=196 and result-grid top stayed at y=434 throughout the sampled key sequence; no per-key geometry change was observed.
  • The browser exposed the old clear-route bug and old local resolver timings above; both are directly covered by new route/cache tests.
  • Computed search-surface styles were opaque (opacity: 1, backdrop-filter: none). This PR makes that behavior explicit instead of inheriting translucent panel chrome.
  • After read-only SQL/browser stress, pg_stat_activity reported zero active FTS/trigram searches and a maximum active age of zero.
  • No PR build was deployed to the server; browser checks against the PR behavior are backed by component/router tests and remote Web CI rather than an unapproved production deployment.

Tests and guards

  • SQL builder tests cover title/overview gating, independent fuzzy candidate arms, absence of correlated alias rescans, short-token paths, access filters, duplicate prevention, total/cursor behavior, and exact-title fuzzy suppression.
  • PostgreSQL integration coverage seeds intended/distractor titles and verifies misspelled phrase ordering plus persisted alias search.
  • Pure relevance tests cover Game of Thrones, Harry Potter, alias-backed localized titles, auto edit limits, bounded distance, and cancellation.
  • Handler tests cover silent superseded cancellation and 504 timeout semantics.
  • Web tests cover transition-free rapid typing, clear-route removal, filter preservation, placeholder-page gating, interactive retry/cache options, timeout/stale-result UI, and request-provider wiring.
  • Focused local Go tests pass. The full local catalogue package reached an unrelated httptest listener blocked by the desktop sandbox; repository CI remains the authoritative full Go/Web matrix.

Migration and rollout notes

  • Migration performs one bounded backfill of existing episode-catalog rows, then builds indexes with CREATE INDEX CONCURRENTLY.
  • Invalid remnants from an interrupted concurrent build are removed before retry.
  • Search-field triggers keep inserts and episode refreshes synchronous with PostgreSQL search.
  • No .env change, container restart, Compose down/up, deployment, or persistent production database change was performed while developing or benchmarking this PR.

@blurbery
blurbery force-pushed the fix/postgres-search-performance-ui-stability branch from bb03c20 to b0e4af5 Compare August 29, 2026 04:29
@blurbery
blurbery merged commit 4ffc65c into main Aug 29, 2026
3 checks passed
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.

1 participant