Repository navigation
Speed up the Taxa Lists page & add a reusable way to look up permissions once per page - #1428
Conversation
✅ Deploy Preview for antenna-ssec canceled.
|
✅ Deploy Preview for antenna-preview canceled.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe change reduces repeated project and permission queries during taxa list serialization. It adds prefetched project membership checks, cached project permissions, sorted prefetched project IDs, and regression tests for query counts and permission scoping. ChangesTaxa list permissions and query behavior
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix Suggested reviewers: Merge Risk: ⚪ Minimal · up to The optimization preserves the described permission and response contracts, with regression coverage for query behavior and project scoping. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🟡 Changes recommended
The new permission scoping tests don’t currently grant taxa-list write perms, so they can pass without exercising the intended cross-project permission-leak scenarios.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR reduces N+1 query behavior on the Taxa Lists list endpoint by batching related lookups (projects, taxa counts, and permissions) so list performance stays flat as row count grows.
Changes:
- Prefetch
TaxaList.projectsand use that prefetch in serializer/project-membership checks to avoid per-row queries. - Fix
taxa_countto only executeobj.taxa.count()when the annotation is actually missing. - Cache active project and project permissions per request (serializer instance) and add targeted query-count/permission-scoping tests.
File summaries
| File | Description |
|---|---|
| ami/main/api/views.py | Prefetches projects in TaxaListViewSet.get_queryset() to prevent per-row DB hits. |
| ami/main/api/serializers.py | Avoids eager default evaluation for taxa counts; caches active project/perms per request; reads prefetched projects and returns sorted IDs. |
| ami/base/permissions.py | Extends add_m2m_object_permissions() to reuse prefetched projects and accept optional precomputed project perms. |
| ami/main/tests.py | Adds query-count regression test and permission scoping tests for the new prefetch/perms fast paths. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tests Two Copilot findings on PR #1428: - add_m2m_object_permissions annotates project_perms as set[str], but guardian.get_perms() returns a list; wrap it in set() at the call site. - TaxaListPermissionScopingTestCase's member only held the BasicMember role (from plain project.members.add()), which never grants update_taxalist/delete_taxalist (only ProjectManager does — see ami/users/roles.py). Verified empirically: forcing the membership check in add_m2m_object_permissions to always pass left both guard tests green, because the permission set was empty regardless of scoping. Assigning ProjectManager to the member, adding a positive counterpart test, and asserting the positive case in the cross-project test closes the gap — the prefetch guard test now fails when the membership check is bypassed (confirmed with the same temporary override, then reverted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TngW6AshkUEhNp9D6z3U9Z
TaxaListSerializer resolves the active project and the requesting member's permissions once per row instead of once per request, and get_taxa_count() runs a COUNT query per row even when annotated (getattr's default argument is evaluated eagerly). Adds a query-count test that fails on unfixed code (measured 16 queries for 3 rows vs 37 for 10) and two permission-scoping tests for the prefetch-based fix that follows: a non-member list must report no write permissions when its `projects` relation was prefetched rather than queried, and a member of one project must not see that project's permissions on a row shared with a project they are not a member of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TngW6AshkUEhNp9D6z3U9Z
TaxaListSerializer resolved the active project and the requesting member's project permissions on every row instead of once per request, add_m2m_object_permissions ran a membership query per row, and get_taxa_count() ran a COUNT query per row even when the annotation was present. Query count no longer scales with the number of taxa lists returned. - get_permissions() caches the resolved project and permission set on `self`: a ListSerializer reuses one child instance across every row, and the viewset builds a fresh serializer per request, so this is exactly request-scoped without touching the standalone get_active_project() other serializers also call per row. - TaxaListViewSet.get_queryset() prefetches `projects`; get_projects() and add_m2m_object_permissions()'s membership check both read the prefetch cache instead of querying, with a query fallback when the cache isn't populated. - get_taxa_count() no longer calls obj.taxa.count() as getattr's default argument, which evaluated unconditionally regardless of whether the annotation was present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TngW6AshkUEhNp9D6z3U9Z
…tests Two Copilot findings on PR #1428: - add_m2m_object_permissions annotates project_perms as set[str], but guardian.get_perms() returns a list; wrap it in set() at the call site. - TaxaListPermissionScopingTestCase's member only held the BasicMember role (from plain project.members.add()), which never grants update_taxalist/delete_taxalist (only ProjectManager does — see ami/users/roles.py). Verified empirically: forcing the membership check in add_m2m_object_permissions to always pass left both guard tests green, because the permission set was empty regardless of scoping. Assigning ProjectManager to the member, adding a positive counterpart test, and asserting the positive case in the cross-project test closes the gap — the prefetch guard test now fails when the membership check is bypassed (confirmed with the same temporary override, then reverted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TngW6AshkUEhNp9D6z3U9Z
637d619 to
96e2671
Compare
Summary
The Taxa Lists page gets slower with every list a project has, because the API that feeds it asks the database several extra questions for each row: which projects the list belongs to, how many taxa it holds, which project is active, and what the current user may do. This PR makes that page cost the same number of database queries however many lists are shown.
It also puts a reusable way of doing that in the shared code, because the cause is not specific to taxa lists. Every list response in Antenna resolves the caller's permissions once per row:
DefaultSerializer.get_permissionscallsBaseModel._get_object_perms, which looks up the row's project and then asks django-guardian what the user may do with it — even though the answer is the same for every row in one response. Six serializers override that method; the rest inherit it. The helper here now accepts an already-resolved permission set, so a serializer can compute it once per request and hand it in, and the same three techniques apply to any list endpoint that needs them. Where to use them next is listed below.List of Changes
TaxaListViewSetprefetchesprojects;get_projects()reads the prefetched rowsgetattr(obj, "annotated_taxa_count", obj.taxa.count())evaluated its default on every call; the fallback now only runs when the annotation is missingadd_m2m_object_permissionsreads the prefetch cache when present and falls back to a query otherwise; it accepts the already-resolved permissions through a new optional argumentWhere this should be used next
Tracked as #1430, which also weighs whether the remaining endpoints should thread the resolved permissions through each serializer as this PR does, or cache them inside the shared helper so no call site has to change.
The same shape appears elsewhere. These are counted from reading the code, not measured, and none of them is fixed here:
OccurrenceListSerializer.get_permissions)select_related, so reading it is a query, and guardian is then asked the same question again for every row. An earlier investigation put this endpoint at roughly 5 + 12 queries per row.OccurrenceIdentificationSerializer.get_permissions), nested inside the aboveSourceImageCollectionSerializer.get_permissions)MinimalNestedModelSerializer)DefaultSerializer.to_representation(), so each nested object appendsuser_permissionsand pays the same lookup. Which parents embed it in a list response was not tracedNone of those endpoints has a query-count test, so a regression in them would not be noticed today. The lasting fix is to let the object-level helper take a resolved permission set the way the many-to-many one now does, and to give each list endpoint a query-count test with a multi-row fixture; that is follow-up work rather than something to add here.
Two serializers were checked and are not affected: the nested capture-taxon and classification serializers return an empty permission list without touching the database.
Related Issues
Relates to #1424. Independent of #1425, which touches the same permission helper; whichever merges second needs a small rebase.
Detailed Description
Measured with a test fixture (a project, N taxa lists with one taxon each, requested by a project member who is not a superuser, with query caching out of the way):
A superuser skips the per-row permission lookup, so the tests use an ordinary member; measuring as a superuser hides most of the cost.
Also measured end to end against a copy of the production database, running this branch and a clean
maincheckout in the same container image against the same data: the project with the most occurrences (about 180,000), requested by an ordinary member, with 40 temporary taxa lists created inside a transaction and rolled back afterwards, after a warm-up request and with the query cache invalidated before each request.On main the time grows with the number of rows; here it stays flat.
The permission cache lives on the serializer instance, not on the module, the user model or the shared
get_active_project()helper. DRF creates a serializer per request and reuses one child instance for every row of a list response, so the cached values cannot outlive a request or reach another user. Keeping the change insideTaxaListSerializeralso keeps this PR from changing behaviour for other endpoints.Two tests pin that the speed-up does not loosen permissions: a list that is not in the active project still reports no
updateordeletewhen its projects are prefetched, and a member of project A gets no write permissions on a shared list while requesting with project B, where they are not a member.The three techniques, for reuse. Resolve the caller's permissions once per request rather than once per row, and pass them in. Resolve the active project once per request the same way. Read a prefetched relation instead of querying it, and keep the fallback for callers that did not prefetch. A fourth thing worth knowing is the
getattr(obj, "annotation", obj.thing.count())trap fixed here: the default argument is evaluated on every call, so the fallback query runs even when the annotation is present. That was the only instance of it in the serializers.Not fixed here, from reading the code and not measured: the taxa endpoint (
/taxa/) resolves the active project per row throughTaxonSerializer.get_summary_data(). Its existing query-count test compares two page sizes against a fixture that only ever holds six taxa, so both requests return the same rows and the test cannot see growth per row.How to Test the Changes
TestTaxaListListQueryCountasserts the query count is equal for 3 and 10 rows and pins the absolute number. It fails on main (16 and 37) and passes here (8 and 8).TaxaListPermissionScopingTestCasecovers the two permission guards above.ami.mainmodule (386 tests),makemigrations --check --dry-run, black, isort and flake8 passed during development. Backend CI is currently failing on every branch before tests start because an image pull is refused (fix in Update docker image source for minio and minio-init from docker hub to quay.io #1419), so CI here will not be informative until that merges.Deployment Notes
None. No migration, no settings, no change to response shape apart from project ids being sorted.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TngW6AshkUEhNp9D6z3U9Z
Summary by CodeRabbit
Performance
Bug Fixes
Tests