Skip to content

Improve performance of queries on roms table#2811

Merged
gantoine merged 1 commit intorommapp:masterfrom
MaienM:feature/improve-roms-query-performance
Dec 29, 2025
Merged

Improve performance of queries on roms table#2811
gantoine merged 1 commit intorommapp:masterfrom
MaienM:feature/improve-roms-query-performance

Conversation

@MaienM
Copy link
Copy Markdown
Contributor

@MaienM MaienM commented Dec 29, 2025

Description

The queries on the roms become painfully slow when attempting to query later pages in large databases. As an example my database contains ~25k roms, and getting the first 500 roms (api/roms?limit=500&offset=0) is pretty quick, but getting a similar chunk towards the end (api/roms?limit=500&offset=20000) takes several minutes.

I use a PostgreSQL database, so that is what the queries below will be for, but the same problem may also occur on other database engines.

Digging into the generated queries the query that's causing the issues looks something like this:

SELECT
	roms.id,
	roms.igdb_id,
	roms.sgdb_id,
	roms.moby_id,
	roms.ss_id,
	roms.ra_id,
	roms.launchbox_id,
	roms.hasheous_id,
	roms.tgdb_id,
	roms.flashpoint_id,
	roms.hltb_id,
	roms.gamelist_id,
	roms.fs_name,
	roms.fs_name_no_tags,
	roms.fs_name_no_ext,
	roms.fs_extension,
	roms.fs_path,
	roms.fs_size_bytes,
	roms.name,
	roms.slug,
	roms.summary,
	roms.igdb_metadata,
	roms.moby_metadata,
	roms.ss_metadata,
	roms.ra_metadata,
	roms.launchbox_metadata,
	roms.hasheous_metadata,
	roms.flashpoint_metadata,
	roms.hltb_metadata,
	roms.gamelist_metadata,
	roms.path_cover_s,
	roms.path_cover_l,
	roms.url_cover,
	roms.path_manual,
	roms.url_manual,
	roms.path_screenshots,
	roms.url_screenshots,
	roms.revision,
	roms.regions,
	roms.languages,
	roms.tags,
	roms.crc_hash,
	roms.md5_hash,
	roms.sha1_hash,
	roms.ra_hash,
	roms.missing_from_fs,
	roms.platform_id,
	roms.created_at,
	roms.updated_at,
	(
		SELECT
			count(roms.id) AS count_1
		FROM
			roms
		WHERE
			roms.platform_id = platforms_1.id
	) AS anon_1,
	(
		SELECT
			coalesce(sum(roms.fs_size_bytes), $1::integer) AS coalesce_1
		FROM
			roms
		WHERE
			roms.platform_id = platforms_1.id
	) AS anon_2,
	platforms_1.id AS id_1,
	platforms_1.igdb_id AS igdb_id_1,
	platforms_1.sgdb_id AS sgdb_id_1,
	platforms_1.moby_id AS moby_id_1,
	platforms_1.ss_id AS ss_id_1,
	platforms_1.ra_id AS ra_id_1,
	platforms_1.launchbox_id AS launchbox_id_1,
	platforms_1.hasheous_id AS hasheous_id_1,
	platforms_1.tgdb_id AS tgdb_id_1,
	platforms_1.flashpoint_id AS flashpoint_id_1,
	platforms_1.igdb_slug,
	platforms_1.moby_slug,
	platforms_1.hltb_slug,
	platforms_1.slug AS slug_1,
	platforms_1.fs_slug,
	platforms_1.name AS name_1,
	platforms_1.custom_name,
	platforms_1.category,
	platforms_1.generation,
	platforms_1.family_name,
	platforms_1.family_slug,
	platforms_1.url,
	platforms_1.url_logo,
	platforms_1.aspect_ratio,
	platforms_1.temp_old_slug,
	platforms_1.missing_from_fs AS missing_from_fs_1,
	platforms_1.created_at AS created_at_1,
	platforms_1.updated_at AS updated_at_1
FROM roms
LEFT OUTER JOIN rom_user ON rom_user.rom_id = roms.id AND rom_user.user_id = $2::integer
LEFT OUTER JOIN platforms AS platforms_1 ON platforms_1.id = roms.platform_id
WHERE
	rom_user.hidden IS FALSE
	OR rom_user.hidden IS NULL
ORDER BY trim(REGEXP_REPLACE(lower(roms.name), $3::varchar, $4::varchar, 'i')) ASC
LIMIT $5::integer
OFFSET $6::integer

Running two query plans with offset 0 & 10000 shows the drastic increase in cost as the offset increases:

Cost       4,630.05 ..  3,871,521.30
Cost  7,7348,030.44 .. 81,214,986.38

The plans didn't really make it clear to me where this difference was coming from though. I spent some time removing parts of the query and running plans on it to track this down, and found that the portion of the query that causes all the pain are the subqueries for anon_1 and anon_2, which correspond to the rom_count and fs_size_bytes fields of Platform. Changing these properties to instead use a query that always returns 0 verified that these subqueries were indeed the cause, making the API request that previously took minutes take less than a second (as it should).

It makes sense that these subqueries are an expensive part of the query as they require reading the entire roms table, meaning that this has a complexity of O(n^2). I'm somewhat surprised this is worse for larger offsets though since evaluating these queries doesn't seem necessary to determine the set of roms to operate on. Either way it doesn't really make sense to calculate this for each rom since it's a property of the platform, not the rom. A much more efficient way to do this would be to join on a subquery in which these fields are calculated for each platform, like so:

SELECT
	roms.id,
	roms.igdb_id,
	roms.sgdb_id,
	roms.moby_id,
	roms.ss_id,
	roms.ra_id,
	roms.launchbox_id,
	roms.hasheous_id,
	roms.tgdb_id,
	roms.flashpoint_id,
	roms.hltb_id,
	roms.gamelist_id,
	roms.fs_name,
	roms.fs_name_no_tags,
	roms.fs_name_no_ext,
	roms.fs_extension,
	roms.fs_path,
	roms.fs_size_bytes,
	roms.name,
	roms.slug,
	roms.summary,
	roms.igdb_metadata,
	roms.moby_metadata,
	roms.ss_metadata,
	roms.ra_metadata,
	roms.launchbox_metadata,
	roms.hasheous_metadata,
	roms.flashpoint_metadata,
	roms.hltb_metadata,
	roms.gamelist_metadata,
	roms.path_cover_s,
	roms.path_cover_l,
	roms.url_cover,
	roms.path_manual,
	roms.url_manual,
	roms.path_screenshots,
	roms.url_screenshots,
	roms.revision,
	roms.regions,
	roms.languages,
	roms.tags,
	roms.crc_hash,
	roms.md5_hash,
	roms.sha1_hash,
	roms.ra_hash,
	roms.missing_from_fs,
	roms.platform_id,
	roms.created_at,
	roms.updated_at,
	platforms_1.id AS id_1,
	platforms_1.igdb_id AS igdb_id_1,
	platforms_1.sgdb_id AS sgdb_id_1,
	platforms_1.moby_id AS moby_id_1,
	platforms_1.ss_id AS ss_id_1,
	platforms_1.ra_id AS ra_id_1,
	platforms_1.launchbox_id AS launchbox_id_1,
	platforms_1.hasheous_id AS hasheous_id_1,
	platforms_1.tgdb_id AS tgdb_id_1,
	platforms_1.flashpoint_id AS flashpoint_id_1,
	platforms_1.igdb_slug,
	platforms_1.moby_slug,
	platforms_1.hltb_slug,
	platforms_1.slug AS slug_1,
	platforms_1.fs_slug,
	platforms_1.name AS name_1,
	platforms_1.custom_name,
	platforms_1.category,
	platforms_1.generation,
	platforms_1.family_name,
	platforms_1.family_slug,
	platforms_1.url,
	platforms_1.url_logo,
	platforms_1.aspect_ratio,
	platforms_1.temp_old_slug,
	platforms_1.missing_from_fs AS missing_from_fs_1,
	platforms_1.created_at AS created_at_1,
	platforms_1.updated_at AS updated_at_1,
	platforms_1.anon_1 AS anon_1,
	platforms_1.anon_2 AS anon_2
FROM roms
LEFT OUTER JOIN rom_user ON rom_user.rom_id = roms.id AND rom_user.user_id = $2::integer
LEFT OUTER JOIN
	(
		SELECT
			*,
			(
				SELECT
					count(roms.id) AS count_1
				FROM
					roms
				WHERE
					roms.platform_id = id
			) AS anon_1,
			(
				SELECT
					coalesce(sum(roms.fs_size_bytes), $1::integer) AS coalesce_1
				FROM
					roms
				WHERE
					roms.platform_id = id
			) AS anon_2
		FROM platforms
	)
	AS platforms_1
	ON platforms_1.id = roms.platform_id
WHERE
	rom_user.hidden IS FALSE
	OR rom_user.hidden IS NULL
ORDER BY trim(REGEXP_REPLACE(lower(roms.name), $3::varchar, $4::varchar, 'i')) ASC
LIMIT $5::integer
OFFSET $6::integer

Running two query plans for this altered query (using the same offsets as before) confirms that this is much more efficient & scales much better:

Cost  12,362.15 .. 12,363.40
Cost  18,137.54 .. 18,212.24

Of course, we're not going to be changing the queries directly, but this does confirm that the thing we need to change to resolve this issue is how the platforms table is joined on in this query.

My first attempt at this was to change this join from joinedload to selectinload. This didn't result in the exact same changes to the query as in the manually-improved version — in fact, the platform table no longer even appeared in any of the queries I logged — but it did result in the desired performance improvement. Perplexed by this I ran the tests and found they all passed. I also did some manual checking with the frontend, and did not encounter any issues there either.

At this point I assumed this join was only added when needed & I just hadn't logged any of those queries. To confirm this I removed it altogether and… got the same results.

That leads to this PR, which removes the offending joins. Everything I've tested suggest they are not needed, but I'm curious to hear from someone more knowledgeable about this project whether this is actually true or whether I've just missed a case where they are required.

EDIT: Switched to using selectinload instead of removing, see later comments.

Checklist
Please check all that apply.

  • 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

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @MaienM, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly improves the performance of database queries on the 'roms' table, especially for paginated results with large offsets. The core issue was identified as inefficient subqueries related to platform details being loaded for each ROM. By removing a specific 'joinedload' operation that proved to be redundant and detrimental to performance, the PR aims to resolve these bottlenecks, making API requests much faster without apparent loss of functionality.

Highlights

  • Performance Improvement: Addresses painfully slow queries on the 'roms' table, particularly when fetching later pages in large databases, reducing query times from minutes to seconds.
  • Root Cause Identified: Pinpoints 'O(n^2)' subqueries for 'rom_count' and 'fs_size_bytes' within the 'Platform' object as the primary cause of performance degradation when 'Rom.platform' was eagerly loaded.
  • Query Optimization: Removes seemingly unnecessary 'joinedload(Rom.platform)' calls from 'Rom' queries, which were causing the inefficient subqueries and performance bottlenecks.
  • Testing and Validation: The changes have been tested locally, all existing tests passed, and manual frontend checking also showed no issues, suggesting the removed join was indeed redundant.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

Excellent work on diagnosing this tricky performance issue! Your analysis is spot on. The subqueries on the Platform model's column_property fields combined with joinedload were indeed causing significant slowdowns on large datasets.

My review focuses on making the fix more robust. While removing the joinedload works in your tests, it relies on the rom.platform attribute not being accessed, which could be brittle. I've suggested changing joinedload to selectinload instead. This is a standard SQLAlchemy pattern for this exact problem. It will load the platform data efficiently in a separate query, fixing the performance issue while ensuring the data is available if needed by any part of the application.

You mentioned that when you tried selectinload, you didn't see the platform table in your query logs. This might be because selectinload issues a second, separate SELECT statement after the main query, which your logging setup might not be configured to display.

Overall, this is a great performance improvement.

I am having trouble creating individual review comments. Click here to see my feedback.

backend/handler/database/roms_handler.py (108-109)

high

You've correctly identified that joinedload(Rom.platform) is causing a performance issue due to the subqueries on the Platform model.

While removing it works in your tests, a more robust solution is to change the loading strategy to selectinload. This is the idiomatic SQLAlchemy fix for this type of N+1 subquery problem. It will load the platform data in a separate, efficient query, resolving the performance bottleneck while ensuring the rom.platform data is available if needed elsewhere.

You mentioned trying selectinload and it working well, which is great! I'd recommend committing that change instead of removing the load option entirely for better long-term maintainability.

            selectinload(Rom.platform),

backend/handler/database/roms_handler.py (140-141)

high

For the same reasons as in the with_details decorator, switching to selectinload here is the recommended approach. It fixes the performance issue while safely pre-loading the platform data.

            selectinload(Rom.platform),

The old variant added subqueries that query the entire rom table which
were evaluated for each rom, absolutely tanking the performance on
larger collections.
@MaienM MaienM force-pushed the feature/improve-roms-query-performance branch from 477ced7 to dfd8840 Compare December 29, 2025 20:33
@MaienM
Copy link
Copy Markdown
Contributor Author

MaienM commented Dec 29, 2025

I did not realize selectinload will run as a second query, though my logging should have still shown these queries if they happened, and the fact that removing the join altogether did not cause issues confirms that right now these queries are not happening.

Still, I agree that including this is probably better for long-term maintainability, so I've re-added this join with selectinload.

@gantoine gantoine merged commit bc24836 into rommapp:master Dec 29, 2025
1 of 2 checks passed
@zurdi15
Copy link
Copy Markdown
Member

zurdi15 commented Dec 29, 2025

@MaienM great analysis and solution dude, thank you so much for the contribution!

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.

3 participants