mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-13 21:28:02 +00:00
Performance: skip effective_content annotation on document list unless required (#13789)
* perf: skip effective_content annotation on document list unless filtered on DocumentViewSet.get_queryset() always attached a correlated subquery resolving each document's latest version content, even though it's only needed for the deprecated search/title_content/content__* filter params. Evaluated for every candidate row before pagination's LIMIT, this is pathological on MariaDB: its default cardinality estimate for the mostly- NULL root_document_id self-join drives it to a near-full-table scan per row instead of using the FK index, turning a normal filtered list request into a multi-second query (root cause of paperless-ngx#13778's report). Only attach the annotation when a request actually filters on it. The common case now relies on Document.get_effective_content()'s existing prefetch-based fallback instead (extended the "versions" prefetch to include content), which DocumentSerializer.to_representation() now calls directly instead of checking for the annotation via hasattr(). * fix: address review feedback on effective_content annotation skip - _needs_effective_content_annotation() now checks for a non-blank, stripped param value rather than mere key presence, matching how SearchFilter/TitleContentFilter/EffectiveContentFilter themselves no-op on a blank value. An empty ?search= or a saved view with a cleared text filter no longer re-triggers the annotation. - The "versions" prefetch on DocumentViewSet no longer carries content for every historical version of every document -- that's unused bloat for version-heavy documents. Added latest_version_content_prefetch() (versioning.py), a separate, windowed prefetch scoped to just the newest version's content per root, and taught Document.get_effective_content() to check it first. - DocumentSerializer.to_representation() no longer unconditionally calls get_effective_content(). Added has_prefetched_effective_content() (versioning.py) as a cheap upfront check: only resolve version-aware content when an SQL annotation or a versions prefetch is already on the instance. TrashView and GlobalSearchView build their own querysets independently of DocumentViewSet and never display document content at all (checked both frontend components), so they now keep showing the document's own, unresolved content with zero extra queries -- the same behavior as before effective_content resolution existed, just generalized past the narrow hasattr() check it replaced. * Perf: derive _CONTENT_FILTER_PARAMS from DocumentFilterSet and search_fields instead of hand-maintaining it * Fixes the new test failure and restricts doing the annotation even further, so content must have been requested to annotate even * CLean up the new test with the docstrings, handle the fields in one place * Fun with contenttype and caching. Compare only the queries spent on the documents themselves or else
This commit is contained in:
@@ -7,9 +7,12 @@ from typing import Any
|
||||
|
||||
from django.db.models import F
|
||||
from django.db.models import OuterRef
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Subquery
|
||||
from django.db.models import Window
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
@@ -46,6 +49,68 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
||||
)
|
||||
|
||||
|
||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
||||
|
||||
|
||||
def latest_version_content_prefetch() -> Prefetch:
|
||||
"""
|
||||
A Prefetch for Document.versions scoped to just the newest version's
|
||||
content, for get_effective_content()'s fallback when no SQL annotation
|
||||
is present.
|
||||
|
||||
Deliberately not merged into a metadata-only "versions" prefetch (the one
|
||||
used for the serialized versions list): that one fetches every historical
|
||||
version of every document, and pulling full OCR content for versions
|
||||
nobody will read wastes DB transfer/memory at scale. This one is windowed
|
||||
down to a single row per root, then bounded by Prefetch's own IN-list to
|
||||
whatever page/result set it's attached to -- one cheap bulk query total,
|
||||
not one per document and not one per version.
|
||||
"""
|
||||
return Prefetch(
|
||||
"versions",
|
||||
queryset=(
|
||||
Document.objects.filter(
|
||||
root_document_id__isnull=False,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.annotate(
|
||||
rn=Window(
|
||||
RowNumber(),
|
||||
partition_by=F("root_document_id"),
|
||||
order_by=[
|
||||
F("version_index").desc(nulls_last=True),
|
||||
F("id").desc(),
|
||||
],
|
||||
),
|
||||
)
|
||||
.filter(rn=1)
|
||||
.only("id", "root_document_id", "content")
|
||||
),
|
||||
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||
)
|
||||
|
||||
|
||||
def has_prefetched_effective_content(document: Document) -> bool:
|
||||
"""
|
||||
True if document.get_effective_content() can answer without an extra
|
||||
per-instance query -- an SQL ``effective_content`` annotation, the lean
|
||||
latest_version_content_prefetch(), or the metadata-only "versions"
|
||||
prefetch is already present on the instance.
|
||||
|
||||
Callers that haven't set any of those up (e.g. views that build their
|
||||
own querysets independently of DocumentViewSet.get_queryset(), like
|
||||
TrashView or GlobalSearchView) intentionally don't pay for version-aware
|
||||
content resolution -- see DocumentSerializer.to_representation(), which
|
||||
uses this to decide whether to call get_effective_content() at all.
|
||||
"""
|
||||
if hasattr(document, "effective_content"):
|
||||
return True
|
||||
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
|
||||
return True
|
||||
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
|
||||
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
|
||||
|
||||
|
||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||
"""
|
||||
Same sorting as versions_newest_first()
|
||||
|
||||
Reference in New Issue
Block a user