Skip to content

perf(api): drop unnecessary copy.deepcopy on pre-annotation querysets#5

Draft
d0cd wants to merge 1 commit into
previewfrom
d0cd/task-376v72
Draft

perf(api): drop unnecessary copy.deepcopy on pre-annotation querysets#5
d0cd wants to merge 1 commit into
previewfrom
d0cd/task-376v72

Conversation

@d0cd
Copy link
Copy Markdown

@d0cd d0cd commented Apr 28, 2026

Summary

  • Seven list endpoints in the Django API snapshot a filtered queryset via copy.deepcopy(issue_queryset) before calling apply_annotations(...). Since Django QuerySets are immutable through chaining (.annotate / .filter / .only all return new querysets), the deepcopy walks an unnecessary object graph on every list request.
  • Replaced each callsite with a plain alias (total_issue_queryset = issue_queryset) and dropped the now-unused import copy.
  • Microbench (/tmp/perf_repro/bench.py, 20 invocations × 5000 calls) on a queryset structurally equivalent to IssueViewSet.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.list and the project-issue list helper inside issue/base.py (2 sites)
  • IssueArchiveViewSet.list
  • CycleIssueViewSet.list
  • ModuleIssueViewSet.list
  • WorkspaceViewIssuesViewSet.list
  • WorkspaceUserProfileIssuesEndpoint.get

Test plan

  • python -m py_compile on each modified file
  • Modules import cleanly with the real Plane settings + apps loaded (DJANGO_SETTINGS_MODULE=plane.settings.test python -c "import django; django.setup(); from plane.app.views... import ...")
  • pytest plane/tests/unit -q (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 — pre-existing in the URL-length detection logic, unrelated to the views modified here.
  • HTTP-level latency on the live Plane stack (not measured — see Out of scope)

Out of scope

  • HTTP-level p50/p95 of the affected list endpoints (~16 μs is below noise floor on a single request, but universal across all 7 callsites).
  • Postgres-side cost of the issue list query (orthogonal — deepcopy is pure Python overhead before the query is compiled).
  • Other suspected hotspots noticed but not investigated: IssueListEndpoint.get uses bare correlated subqueries with Func(F('id'), function='Count') plus a final .distinct(), and several apply_annotations chains use four correlated subqueries per row that on large tables without compound indexes can dominate. Neither verified.
  • Concurrent / under-load behavior, POST/PUT paths.

🤖 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:

cd /tmp/perf_repro && /tmp/perfvenv/bin/python bench.py --mode deepcopy -n 5000
cd /tmp/perf_repro && /tmp/perfvenv/bin/python bench.py --mode reference -n 5000

Helper files in this PR:

  • /tmp/perf_repro/bench.py
  • /tmp/perf_repro/bench_app/models.py

Cause.

Cause

Six view files use the same idiom:

issue_queryset = self.filter_queryset(issue_queryset)
issue_queryset = issue_queryset.filter(**filters)

# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)   # <— unnecessary

issue_queryset = self.apply_annotations(issue_queryset)

The intent is to keep the un-annotated queryset around so the paginator can run a cheaper COUNT(*) over it (without the four correlated subqueries apply_annotations adds). But apply_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.deepcopy on a Django QuerySet does call Query.__deepcopy__ (which shortcuts to clone()), 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 to IssueViewSet.list's pre-annotation chain (multiple .filter() calls, joins through workspace/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:

  • deepcopy: 16.55 μs
  • reference: 0.019 μs (effectively free)

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.py x2, issue/archive.py, cycle/issue.py, module/issue.py, view/base.py, workspace/user.py) and drop the now-unused import copy. view/base.py was 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.

Measurement p50 p95 p99 mean
baseline (current — copy.deepcopy) 0.0 0.0 0.0 0.0
after fix (reference assignment) 0.0 0.0 0.0 0.0

Ablations.

  • Switched bench from 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.

  • HTTP-level latency on the live Plane stack — I did not boot api+web+postgres+redis+celery to measure end-to-end p50/p95 of the affected list endpoints. Per-request saving (~16 μs) is below noise floor on any single HTTP request, but is universal across all 7 callsites.
  • Postgres-side cost (EXPLAIN ANALYZE on the issue list query) — orthogonal to this fix, since the deepcopy is pure Python overhead before the query is even compiled.
  • Other potential perf concerns noticed but not investigated: (a) IssueListEndpoint.get uses bare correlated subqueries with Func(F('id'), function='Count') instead of Subquery(...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.
  • Concurrent / under-load behavior — the bench is single-threaded.
  • POST/PUT paths — only list-style GETs were inspected.
  • Whether the deepcopy was protecting against any in-place QuerySet mutation. I read each callsite and confirmed apply_annotations() is pure-chaining, but a non-obvious mutation pattern elsewhere in the request lifecycle would not be caught by my regression check.

Investigation captured by Niteshift Perf Mode. View on Niteshift

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>
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.

1 participant