Skip to content

perf(backend): mirror ROM filter values into a narrow trigger-maintained table#3885

Open
gantoine wants to merge 1 commit into
masterfrom
perf/roms-facets-table
Open

perf(backend): mirror ROM filter values into a narrow trigger-maintained table#3885
gantoine wants to merge 1 commit into
masterfrom
perf/roms-facets-table

Conversation

@gantoine

Copy link
Copy Markdown
Member

Description

Fixes #3882.

Building the search/gallery filter dropdowns aggregated the facet columns straight off roms. Those columns are cheap to compute since #3826, but they live inline with the raw provider-metadata blobs, so the aggregation still had to read every row of a multi-gigabyte table. On the reporter's ~92k-game library that is ~5.3s, identical cold and warm, i.e. purely disk-read bound: the stock innodb_buffer_pool_size defaults (128 MB on the official mariadb image, 256 MB on LinuxServer.io) are far below the ~1 GB roms table, and it recurs after every scan because the cached values are invalidated.

This adds roms_facets, a narrow table holding only the per-ROM filter values (a few MB for the same library):

  • Migration 0100 creates the table (rom_id PK → roms.id ON DELETE CASCADE, platform_id + the 10 facet columns, index on platform_id), backfills it with a single INSERT ... SELECT, and installs the sync triggers: roms_facets_after_insert / roms_facets_after_update on MySQL/MariaDB (upsert via ON DUPLICATE KEY UPDATE) and romm_sync_rom_facets() + roms_facets_sync on PostgreSQL (ON CONFLICT DO UPDATE). Deletes ride the FK cascade. downgrade() drops the triggers, function, and table.
  • Sync lives in the database rather than in application code, so every write path (ORM, bulk update(), scan) stays consistent without a hook to remember. Stored generated columns are confirmed readable from NEW.* in an AFTER trigger on both engines.
  • with_filter_values / get_rom_filters now share one select over RomFacets; roms no longer appears in the facet query at all, only in the id subquery.

Verified on MariaDB 11.3 and PostgreSQL 16: insert/update/delete sync, and a downgrade → re-upgrade round trip backfills pre-existing rows. EXPLAIN on the new query reaches roms only through a covering secondary index (Using index) for the id list, then eq_ref PK lookups into the narrow table, so the wide rows are never scanned.

Two caveats: I could not reproduce the reporter's ~90k-game timing locally, so the win is argued from the query plan and table width rather than a measured 5.3s → sub-second on real data. And the separate full ordered rom-id scan that virtual scrolling needs (~5.1s in the report) is untouched, as the issue scopes it out.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Screenshots (if applicable)

n/a - backend only, no user-visible UI change.


AI assistance disclosure: this change was written with AI assistance (Claude Code). The approach was chosen by a human; the model wrote the migration, model, handler change, and tests, and ran the verification described above. All of it has been reviewed by a human before submission.

…ned table

Building the filter dropdowns aggregated the facet columns straight off
`roms`. Those columns are cheap to compute since #3826, but they sit inline
with the raw provider-metadata blobs, so the aggregation read every row of a
multi-gigabyte table: seconds of pure disk reading on any database whose
buffer pool is smaller than the table (the stock 128-256 MB defaults always
are on a large library).

`roms_facets` holds only the filter values and is kept in sync by AFTER
INSERT/UPDATE triggers on `roms`, so every write path stays consistent
without an application hook. Deletes ride the ON DELETE CASCADE foreign key.

Fixes #3882

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 22, 2026 14:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves ROM filter aggregation to a narrow database-maintained mirror table. The main changes are:

  • A migration creates, backfills, and indexes roms_facets.
  • MySQL, MariaDB, and PostgreSQL triggers synchronize facet values.
  • Filter queries read from the new table instead of wide ROM rows.
  • Tests cover steady-state insert, update, delete, and filter behavior.

Confidence Score: 4/5

The migration-time synchronization gap needs to be fixed before merging.

  • Steady-state trigger and query behavior are consistent.
  • A concurrent ROM write can escape both the backfill and trigger synchronization.
  • Escaped rows cause incomplete filter values without an error.

backend/alembic/versions/0100_roms_facets_table.py

Important Files Changed

Filename Overview
backend/alembic/versions/0100_roms_facets_table.py Creates and backfills the facet mirror, but permits writes to escape synchronization before trigger installation.
backend/handler/database/roms_handler.py Switches filter aggregation to the facet mirror while preserving projection order and ROM-ID scoping.
backend/models/rom.py Adds the ORM mapping for the narrow facet table.
backend/alembic/env.py Registers the new model with Alembic metadata.
backend/tests/handler/database/test_rom_facets.py Covers steady-state trigger synchronization but not writes during the migration.

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
backend/alembic/versions/0100_roms_facets_table.py:124
**Writes Can Escape Initial Sync**

A ROM inserted or updated after this backfill but before the triggers are installed is not captured by either operation. Its facet row stays missing or stale until another update occurs, so the new filter queries silently omit its values; trigger installation and the backfill need an ordering that cannot lose concurrent writes.

Reviews (1): Last reviewed commit: "perf(backend): mirror ROM filter values ..." | Re-trigger Greptile

)
op.create_index("idx_roms_facets_platform_id", "roms_facets", ["platform_id"])

op.execute(_BACKFILL_SQL)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Writes Can Escape Initial Sync

A ROM inserted or updated after this backfill but before the triggers are installed is not captured by either operation. Its facet row stays missing or stale until another update occurs, so the new filter queries silently omit its values; trigger installation and the backfill need an ordering that cannot lose concurrent writes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/alembic/versions/0100_roms_facets_table.py
Line: 124

Comment:
**Writes Can Escape Initial Sync**

A ROM inserted or updated after this backfill but before the triggers are installed is not captured by either operation. Its facet row stays missing or stale until another update occurs, so the new filter queries silently omit its values; trigger installation and the backfill need an ordering that cannot lose concurrent writes.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

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.

[Bug] Search filter dropdowns still take ~5 seconds on the first open after a scan (large libraries)

2 participants