Repository navigation
perf: N+1 queries on thing table causing 3.8M single-row DB lookupsΒ #12432
Description
Feature Request
Database performance: N+1 queries on thing table causing 3.8M single-row lookups
Summary
pg_stat_statements profiling on production reveals SELECT * FROM thing WHERE key=$1 is the highest call-volume query on the database with 3.8 million calls in the observation window, totalling ~2.5M ms of DB time. The batched equivalent (SELECT ... WHERE key IN (...)) exists and is used in some paths but only accounts for 134k calls β a 28:1 ratio of unbatched to batched lookups.
Evidence
From pg_stat_statements on production (ol-db0):
query | calls | mean_exec_time | total_exec_time
----------------------------------------+---------+----------------+----------------
SELECT * FROM thing WHERE key=$1 | 3830328 | 0.66ms | 2,537,916ms
SELECT thing.key FROM thing ... IN ($1) | 134057 | 11.44ms | 1,533,958ms
The individual lookups are fast (0.66ms mean) but the volume is the problem. The server has 15GB RAM against a 892GB database, so cache pressure is real β stddev of 17ms on a 0.66ms mean indicates frequent cache misses costing 20-30ms per cold lookup.
Expected behaviour
Things should be loaded in batches using the existing WHERE key IN (...) path wherever possible, reducing round-trips and I/O pressure.
Steps to reproduce
Any page that loads a list of works/editions/authors and then resolves each one individually β likely in openlibrary/plugins/worksearch, openlibrary/plugins/books, or any template that iterates and calls web.ctx.site.get() in a loop.
Suggested fix
Audit call sites of web.ctx.site.get(key) (single-key) and replace loops with web.ctx.site.get_many(keys) (batched) where the keys are known upfront. The infobase layer already supports bulk fetching.
Impact
- Directly reduces DB query volume and I/O pressure
- No infrastructure changes required
- Likely the highest ROI performance fix available given current hardware constraints
Environment
- PostgreSQL 18.3
- Database size: 892GB
- Server RAM: 15GB
shared_buffers: 4GB
Breakdown
Implementation Details (for maintainers)
Related files
Refer to this map of common Endpoints:
*
Requirements Checklist
Checklist of requirements that need to be satisfied in order for this issue to be closed:
- [ ]
Stakeholders
Instructions for Contributors
- Before creating a new branch or pushing up changes to a PR, please first run these commands to ensure your repository is up to date, as the pre-commit bot may add commits to your PRs upstream.
Activity
mekarpeles commentedon Apr 22, 2026
Priority: *andLead: @*to replaceNeeds: TriageandNeeds: LeadContext
From codebase research:
thingtable), making these complementary rather than duplicates.openlibrary/plugins/upstream/mybooks.py:77contains an explicit TODO:# TODO: should do in one web.ctx.get_many fetchβ the loan loop at lines 74β82 callsweb.ctx.site.get(loan["book"])per loan.openlibrary/plugins/openlibrary/code.py:386contains[web.ctx.site.get(key) for key in things]β a list comprehension that issues one query per item.web.ctx.site.get(calls and 22 files already usingweb.ctx.site.get_many(. The batched API is fully implemented in the infobase layer (vendor/infogami/infogami/infobase/dbstore.pyusesWHERE key IN (...)).Issue Assessment
Open questions
pg_stat_statementssnapshot from a representative traffic window (weekday peak vs. off-hours)?Action items
pg_stat_statementstotal_exec_timeor call ratio), and identify stakeholders.Type: Buggiven that the pattern produces measurable production degradation.Suggested breakdown
Given the codebase research, a concrete task checklist could be:
web.ctx.site.get()call sites to classify each as single-key (acceptable) vs. loop (N+1 candidate) β a grep scan across the repo is sufficient. Output: annotated list of loop call sites with estimated impact.mybooks.py:74β82β replace per-loansite.get()with a singlesite.get_many([loan["book"] for loan in myloans])call. Has a confirming TODO comment. β Good First Issue candidate: self-contained, existing TODO, β€ 2 hours, no deep codebase knowledge required.code.py:386β replace list comprehension withsite.get_many(things).pg_stat_statementsto verify the unbatched-to-batched ratio improves.Full triage checklist (maintainers / Pam)
Needs: Breakdown,Needs: Triage,Needs: Leadpresent;Needs: Investigationnot applied (root cause clear from evidence)pg_stat_statementsdata provided: 3.8M calls, 2.5M ms DB time, 28:1 unbatched:batched ratio*not replaced)revisionorlazyparameters differ across items in a loop)mybooks.py:77TODO,code.py:386loop,dbstore.pybatched implementation, issue N+1 query problem in dynlinks.py (query_keys, ol_get_many_as_dict, get_isbn_editiondict_map)Β #12350 all reviewedNote
This comment was automatically generated by Pam, Open Library's Project AI Manager, on behalf of @mekarpeles. Pam is designed to provide status visibility, perform basic project management functions and relevant codebase research, and provide actionable feedback so contributors aren't left waiting.
WeiGuang-2099 commentedon Apr 22, 2026
Hi, I'd like to work on this issue. Could you assign it to me?
6 remaining items
RayBB commentedon Sep 4, 2026
@CipherZ3r0 could you look into this one?
CipherZ3r0 commentedon Sep 4, 2026
@RayBB Yes
CipherZ3r0 commentedon Sep 5, 2026
@RayBB
N+1 candidates
I found potential 11 loop-shaped site.get() calls
These are the 11 places where we can potentially convert N individual get() calls into a single get_many() call, either directly or with a small restructuring:
openlibrary/plugins/openlibrary/api.py:684
openlibrary/plugins/openlibrary/bulk_tag.py:39
openlibrary/views/loanstats.py:205
openlibrary/plugins/upstream/edits.py:250 and openlibrary/plugins/upstream/edits.py:255
openlibrary/plugins/upstream/mybooks.py:84 and openlibrary/plugins/upstream/mybooks.py:88
openlibrary/plugins/upstream/mybooks.py:621-622 and 624
openlibrary/catalog/add_book/init.py:233
openlibrary/catalog/add_book/init.py:568
β Only partially batchable. Initial keys can potentially be batched, but redirect resolution is dynamic/sequential. Needs more care before changing.
Proposal
migrating one at a time And leave add_book/init.py:568 for last because of the redirect-chain behavior.
lazy=True and revision=xx
I also found several other legitimate uses of:
site.get(key, lazy=True)
These won't be treated as normal N+1 replacements because lazy=True intentionally changes when the document is loaded.
Confirmed uses include:
These involve things such as version/author references and previous revisions.
Proposal: no touching any related to lazy or revision once for this round will look into them in next phases
So for the current task, I'd scope the work to the 11 N+1 candidates above, with lazy=True or revision=xx as a separate investigation.
RayBB commentedon Sep 6, 2026
@CipherZ3r0 this is great work! Really in depth analysis.
I propose you open separate PRs for each of the first three and then we can go from there.
They seem the most fixable and we can get into a good pattern.
In your PRs please include a short screen recording using https://recordly.dev/ or similar showing the before/after that it still works.
Also, it would be awesome if you could do some small analysis on the performance for each one if possible (I know it can be hard with local data but see what you can do). Some of these may have smaller benefits but it's good to have the hygene in our codebase so AI doesn't replicate these negative patterns elsewhere.
CipherZ3r0 commentedon Sep 10, 2026
@RayBB first three are done!!
for the 4th
potential approach to this is
behavior comparison:
before:
After:
This approach will reduce the number of request hitting database but it will take temp ram storage more.
so my question is should we change it or keep it as it is?
RayBB commentedon Sep 10, 2026
That's a good question. Do you know how many olids are likely to be passed into this? I imagine almost every time the first entry of author or work has a title. As such, we probably don't need to change this one since it'll create more database work on average. Do you agree?
I think 5 definitely has some room for improvement though! Wanna try that?
CipherZ3r0 commentedon Sep 10, 2026
I am not sure about how many olids are likely to be passed into this and also not sure about will it get title on very first try but i do agree that if it will get it on first try then there's no meaning to change it to .get_many() it will create unnecessary work for DB.
so i will skip 4th for now and will work on 5th
@RayBB
CipherZ3r0 commentedon Sep 14, 2026
@RayBB Hey, I looked in find_threshold_match() for the next part of #12432. The repeated site.get().get(edition_key) calls can be batched with get_many(), but redirects make this a bit more complicated since an edition can resolve through multiple redirect hops.
I see a possible approach where we batch the lookups hop-by-hop and then evaluate the resolved editions in the original edition_pool order. However, preserving the existing seen behavior across redirect chains makes the implementation considerably more complex.
What approach would you prefer here? Should we go with the hop-by-hop batching approach, look for a simpler optimization, or just leave this path as it is given the added complexity?
fix: part 4 #12432 β batch mybooks book lookups (#13597)
fix: part 5 #12432 β batch mybooks notes lookups (#13609)
Merge pull request #13625 from CipherZ3r0/12432/batch-find-matching-wβ¦