perf(api): drop unnecessary copy.deepcopy on pre-annotation querysets#5
Draft
d0cd wants to merge 1 commit into
Draft
perf(api): drop unnecessary copy.deepcopy on pre-annotation querysets#5d0cd wants to merge 1 commit into
d0cd wants to merge 1 commit into
Conversation
Seven list endpoints snapshotted the filtered queryset via copy.deepcopy(issue_queryset) before calling apply_annotations(...). Django QuerySets are immutable through chaining (.annotate/.filter/.only all return new querysets), so the deepcopy walked an unnecessary object graph on every list request. A plain alias gives identical semantics. Microbenchmark on a queryset structurally equivalent to IssueViewSet.list's pre-annotation chain (multiple .filter, joined Q objects, .exclude, .order_by) shows 16.55us per call dropping to ~0.02us (20x5000 samples). Per-request saving is small in absolute terms, but universal across all 7 callsites and trivially safe. Affected viewsets: IssueViewSet (x2), IssueArchiveViewSet, CycleIssueViewSet, ModuleIssueViewSet, WorkspaceViewIssuesViewSet, and WorkspaceUserProfileIssuesEndpoint. Run-on: Niteshift Local Dev Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
copy.deepcopy(issue_queryset)before callingapply_annotations(...). Since Django QuerySets are immutable through chaining (.annotate/.filter/.onlyall return new querysets), the deepcopy walks an unnecessary object graph on every list request.total_issue_queryset = issue_queryset) and dropped the now-unusedimport copy./tmp/perf_repro/bench.py, 20 invocations × 5000 calls) on a queryset structurally equivalent toIssueViewSet.list's pre-annotation chain: 16.55 μs → 0.019 μs per call (~99.9% of the operation cost is the deepcopy itself). Per-request saving is small in absolute terms, but universal across all 7 callsites and trivially safe.Affected viewsets
IssueViewSet.listand the project-issue list helper insideissue/base.py(2 sites)IssueArchiveViewSet.listCycleIssueViewSet.listModuleIssueViewSet.listWorkspaceViewIssuesViewSet.listWorkspaceUserProfileIssuesEndpoint.getTest plan
python -m py_compileon each modified fileDJANGO_SETTINGS_MODULE=plane.settings.test python -c "import django; django.setup(); from plane.app.views... import ...")pytest plane/tests/unit -q(skippingbg_taskswhich need Redis): 85 passed, 3 failed. The 3 failures are inplane/tests/unit/utils/test_url.py::TestContainsURL.*and reproduce identically on the unmodified baseline — pre-existing in the URL-length detection logic, unrelated to the views modified here.Out of scope
IssueListEndpoint.getuses bare correlated subqueries withFunc(F('id'), function='Count')plus a final.distinct(), and severalapply_annotationschains use four correlated subqueries per row that on large tables without compound indexes can dominate. Neither verified.🤖 Generated with Claude Code
🌒 Run on Niteshift: View Task
Performance Investigation
Scope.
Seven list endpoints in Plane's Django API (issue list, archived issues, cycle issues, module issues, workspace view issues, workspace user-profile issues) snapshot a pre-annotation QuerySet via
copy.deepcopy(queryset)so it can be reused for the paginator's COUNT. Django QuerySets are immutable through chaining, so the deepcopy walks an unnecessary object graph on every paginated list request hitting these viewsets.Reproducer. Copy and run:
Helper files in this PR:
/tmp/perf_repro/bench.py/tmp/perf_repro/bench_app/models.pyCause.
Cause
Six view files use the same idiom:
The intent is to keep the un-annotated queryset around so the paginator can run a cheaper
COUNT(*)over it (without the four correlated subqueriesapply_annotationsadds). Butapply_annotations(issue_queryset)doesn't mutate its argument — it returns a new chained queryset. A plain alias (total_issue_queryset = issue_queryset) gives the same snapshot semantics without copying anything.copy.deepcopyon a Django QuerySet does callQuery.__deepcopy__(which shortcuts toclone()), but the surrounding wrapper still does dict-copy + recursive descent through_known_related_objects,_iterable_class, etc. Cost is per-call, not per-row.Evidence
A microbench (
/tmp/perf_repro/bench.py) builds a queryset structurally similar toIssueViewSet.list's pre-annotation chain (multiple.filter()calls, joins throughworkspace/state/project,Q(...)disjunctions,.exclude(),.order_by()) and times one of:--mode deepcopy:filtered = copy.deepcopy(issue_queryset)(current)--mode reference:filtered = issue_queryset(proposed)20 invocations × 5000 calls each, p50 per call:
Ablation: removing the deepcopy in the bench eliminates ~99.9% of that operation's cost, confirming the deepcopy itself is the entire overhead.
Fix
Replace
copy.deepcopy(issue_queryset)with a plain alias in all 7 callsites (issue/base.pyx2,issue/archive.py,cycle/issue.py,module/issue.py,view/base.py,workspace/user.py) and drop the now-unusedimport copy.view/base.pywas simplified further by chaining.only("id")directly onto the alias.Honest framing
Per-request the saving is small (~16 μs), below noise floor on any HTTP-latency measurement. This is a clear, universal, trivially-safe code-quality + micro-perf cleanup, not a hotspot fix. I did not boot the full Plane stack to measure HTTP-level impact — see Out of scope.
Outcome — Fix.
Files:
apps/api/plane/app/views/issue/base.py,apps/api/plane/app/views/issue/archive.py,apps/api/plane/app/views/workspace/user.py,apps/api/plane/app/views/module/issue.py,apps/api/plane/app/views/view/base.py,apps/api/plane/app/views/cycle/issue.py.Regression check: ✅ passed — pytest plane/tests/unit (skipping bg_tasks which need Redis): 85 passed, 3 failed. The 3 failures are in plane/tests/unit/utils/test_url.py (TestContainsURL.*) and reproduce identically on the unmodified baseline — they're pre-existing in URL-length detection logic, unrelated to the views modified here. All 6 modified modules also import cleanly with the real Plane settings + apps loaded.
Measurements.
Ablations.
copy.deepcopy(issue_queryset)to plain reference assignment on the same filter/Q-object/joined queryset. Same command shape, same warm-up. → attributable cost: 99.9%.Out of test scope.
IssueListEndpoint.getuses bare correlated subqueries withFunc(F('id'), function='Count')instead ofSubquery(...Count('id'))plus a final.distinct(); (b) several apply_annotations chains use four correlated subqueries per row, which on large tables without compound indexes can dominate. Neither verified.