Compare commits

..
Author SHA1 Message Date
Trenton HandClaude Sonnet 5.5 34be974edc Performance: use guardian's identity indexes for page permission lookups
The per-page grant lookups filtered only on content type and object_pk. No guardian index leads with object_pk, so MariaDB read every grant of the model to find the 50 documents on a page, three times per list request.

Also restrict the grants to existing users and groups, which changes no result but lets the (identity, content_type, object_pk) indexes be used.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 08:41:44 -07:00
5 changed files with 97 additions and 188 deletions

No files matched your search

+17 -7
View File
@@ -291,12 +291,19 @@ def get_permission_grantees(
"permission__codename__in": list(action_by_codename), "permission__codename__in": list(action_by_codename),
"object_pk__in": [str(pk) for pk in pks], "object_pk__in": [str(pk) for pk in pks],
} }
for perm_model, identity_field, key in ( for perm_model, identity_model, identity_field, key in (
(UserObjectPermission, "user_id", "users"), (UserObjectPermission, User, "user_id", "users"),
(GroupObjectPermission, "group_id", "groups"), (GroupObjectPermission, Group, "group_id", "groups"),
): ):
# Every grant belongs to an existing user or group, so restricting to
# them changes no result. It lets MariaDB use guardian's
# (identity, content_type, object_pk) index; the content type alone
# matches every grant of the model and is scanned in full.
rows = ( rows = (
perm_model.objects.filter(**grant_filter) perm_model.objects.filter(
**{f"{identity_field}__in": identity_model.objects.values("pk")},
**grant_filter,
)
.values_list("object_pk", "permission__codename", identity_field) .values_list("object_pk", "permission__codename", identity_field)
.order_by(identity_field) .order_by(identity_field)
) )
@@ -576,9 +583,12 @@ def annotate_document_count_by_ids(
or CustomFieldInstance) or CustomFieldInstance)
related_object_field: field on the relation pointing back to queryset pk related_object_field: field on the relation pointing back to queryset pk
document_ids: the document ids to count against -- a concrete list/set, document_ids: the document ids to count against -- a concrete list/set,
or a simple (already resolved) queryset of ids. Keep or a simple (already resolved) queryset of ids. Callers
the list small: it is embedded in the statement as that need this filtered by a complex condition (e.g. a
literal parameters. permission check) should resolve it to a concrete list
first if the same ids will be reused across multiple
calls, rather than passing the complex queryset itself
into each -- see ``_get_selection_data_for_queryset``.
target_field: field on the relation pointing to Document id target_field: field on the relation pointing to Document id
""" """
+15 -3
View File
@@ -397,10 +397,14 @@ class OwnedObjectSerializer(
object_pks = list(obj.pk for obj in objects) object_pks = list(obj.pk for obj in objects)
pk_type = type(first_obj.pk) pk_type = type(first_obj.pk)
def get_pks_for_permission_type(model): def get_pks_for_permission_type(model, identity_field, identity_model):
# Restricting to existing users/groups changes no result but lets
# MariaDB use guardian's (identity, content_type, object_pk) index
# instead of scanning every grant of the model.
return map( return map(
pk_type, # coerce the pk to be the same type of the provided objects pk_type, # coerce the pk to be the same type of the provided objects
model.objects.filter( model.objects.filter(
**{f"{identity_field}__in": identity_model.objects.values("pk")},
content_type=ctype, content_type=ctype,
object_pk__in=object_pks, object_pk__in=object_pks,
) )
@@ -410,8 +414,16 @@ class OwnedObjectSerializer(
UserObjectPermission = get_user_obj_perms_model() UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model() GroupObjectPermission = get_group_obj_perms_model()
user_permission_pks = get_pks_for_permission_type(UserObjectPermission) user_permission_pks = get_pks_for_permission_type(
group_permission_pks = get_pks_for_permission_type(GroupObjectPermission) UserObjectPermission,
"user_id",
User,
)
group_permission_pks = get_pks_for_permission_type(
GroupObjectPermission,
"group_id",
Group,
)
return set(user_permission_pks) | set(group_permission_pks) return set(user_permission_pks) | set(group_permission_pks)
+2 -2
View File
@@ -1141,8 +1141,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertCountEqual( self.assertCountEqual(
response.data["selected_document_types"], response.data["selected_document_types"],
[ [
{"id": self.dt1.id, "document_count": 1}, {"id": self.c1.id, "document_count": 1},
{"id": self.dt2.id, "document_count": 0}, {"id": self.c2.id, "document_count": 0},
], ],
) )
-101
View File
@@ -49,7 +49,6 @@ from documents.models import WorkflowTrigger
from documents.search import get_backend from documents.search import get_backend
from documents.signals.handlers import run_workflows from documents.signals.handlers import run_workflows
from documents.tests.utils import ConsumeTaskMixin from documents.tests.utils import ConsumeTaskMixin
from documents.views import DocumentViewSet
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import TagFactory from paperless_testing.factories import TagFactory
@@ -1756,106 +1755,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
} }
self.assertEqual(counts_by_field[custom_field.id], 1) self.assertEqual(counts_by_field[custom_field.id], 1)
def test_selection_data_counts_with_shared_by_filter(self) -> None:
"""
GIVEN:
- Several documents owned by one user with the same correspondent
and document type, only one of which is shared
WHEN:
- Requesting the document list filtered by shared_by__id with
include_selection_data=true
THEN:
- Counts reflect only the shared document, not every document the
owner has with that correspondent or document type, however the
link-table lookups are batched
"""
owner = User.objects.create_user(username="owner")
other = User.objects.create_user(username="other")
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
tag = Tag.objects.create(name="tag")
docs = [
Document.objects.create(
checksum=str(i),
owner=owner,
correspondent=correspondent,
document_type=doc_type,
)
for i in range(3)
]
for doc in docs:
doc.tags.add(tag)
grant_object(other, docs[0], "view_document")
for batch_size in (10_000, 1):
with (
self.subTest(link_batch_size=batch_size),
mock.patch.object(
DocumentViewSet,
"selection_data_link_batch_size",
batch_size,
),
):
response = self.client.get(
f"/api/documents/?shared_by__id={owner.id}"
"&include_selection_data=true",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)
selection_data = response.data["selection_data"]
for key, obj in (
("selected_correspondents", correspondent),
("selected_document_types", doc_type),
("selected_tags", tag),
):
counts = {
item["id"]: item["document_count"]
for item in selection_data[key]
}
self.assertEqual(counts[obj.id], 1, key)
def test_selection_data_counts_documents_once_when_filter_joins(self) -> None:
"""
GIVEN:
- A document matching a multi-valued filter through two tags
WHEN:
- Requesting the document list with include_selection_data=true
THEN:
- The document is counted once per correspondent, not once per
matching tag, however the link-table lookups are batched
"""
correspondent = Correspondent.objects.create(name="c1")
tag_a = Tag.objects.create(name="a")
tag_b = Tag.objects.create(name="b")
both = Document.objects.create(checksum="1", correspondent=correspondent)
both.tags.add(tag_a, tag_b)
only_a = Document.objects.create(checksum="2", correspondent=correspondent)
only_a.tags.add(tag_a)
for batch_size in (10_000, 1):
with (
self.subTest(link_batch_size=batch_size),
mock.patch.object(
DocumentViewSet,
"selection_data_link_batch_size",
batch_size,
),
):
response = self.client.get(
f"/api/documents/?tags__id__in={tag_a.id},{tag_b.id}"
"&include_selection_data=true",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
counts = {
item["id"]: item["document_count"]
for item in response.data["selection_data"][
"selected_correspondents"
]
}
self.assertEqual(counts[correspondent.id], 2)
def test_statistics(self) -> None: def test_statistics(self) -> None:
doc1 = Document.objects.create( doc1 = Document.objects.create(
title="none1", title="none1",
+63 -75
View File
@@ -5,7 +5,6 @@ import platform
import re import re
import tempfile import tempfile
import zipfile import zipfile
from collections import Counter
from collections import deque from collections import deque
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime from datetime import datetime
@@ -169,6 +168,7 @@ from documents.permissions import PaperlessNotePermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.permissions import TrashPermissions from documents.permissions import TrashPermissions
from documents.permissions import ViewDocumentsPermissions from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_by_ids
from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_permission_grantees from documents.permissions import get_permission_grantees
@@ -1022,83 +1022,71 @@ class DocumentViewSet(
"custom_field_", "custom_field_",
) )
# Matching document ids are looked up in the link tables in batches of this
# size: one literal IN (...) over every match is a multi-MB statement per
# relation and, on SQLite, exceeds the bound-parameter limit.
selection_data_link_batch_size = 10_000
def _get_selection_data_for_queryset(self, queryset): def _get_selection_data_for_queryset(self, queryset):
# The filtered queryset is evaluated once, as the page's own filter is, # Resolve once instead of once per model below. `queryset` can carry an
# and never wrapped in a subquery: re-deriving a join or a HAVING # arbitrarily expensive WHERE clause (user filters plus the permission
# (shared_by__id) per statement is very slow on some databases, and # filter); re-embedding it as a subquery inside 5 separate Count(...)
# regrouping the queryset by another column would miscount. # calls forces the database to re-evaluate that whole thing 5 times, and
# order_by() drops the default/user ordering, which only costs a sort. # -- for FK relations especially -- can defeat semi-join planning
correspondents: Counter = Counter() # entirely at scale. A concrete id list is cheap to reuse.
document_types: Counter = Counter() # order_by() drops the default/user ordering -- irrelevant for a plain
storage_paths: Counter = Counter() # id list, but left in place it forces a sort over the full filtered
document_ids: list[int] = [] # set before the ids can even be collected.
seen: set[int] = set() document_ids = list(queryset.order_by().values_list("pk", flat=True))
rows = queryset.order_by().values_list(
"pk", correspondents = Correspondent.objects.annotate(
"correspondent_id", document_count=Count(
"document_type_id", "documents",
"storage_path_id", filter=Q(documents__id__in=document_ids),
distinct=True,
),
)
document_types = DocumentType.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__id__in=document_ids),
distinct=True,
),
)
storage_paths = StoragePath.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__id__in=document_ids),
distinct=True,
),
)
# Tag and CustomField reach Document through an M2M/through-model table;
# a plain Count(filter=...) there is a much more expensive plan than the
# FK relations above once the bridge table is large -- see
# annotate_document_count_by_ids() for why.
tags = annotate_document_count_by_ids(
Tag.objects.all(),
through_model=Document.tags.through,
related_object_field="tag_id",
document_ids=document_ids,
)
custom_fields = annotate_document_count_by_ids(
CustomField.objects.all(),
through_model=CustomFieldInstance,
related_object_field="field_id",
document_ids=document_ids,
) )
for pk, correspondent, document_type, storage_path in rows:
# A filter can join, repeating a document row.
if pk in seen:
continue
seen.add(pk)
document_ids.append(pk)
# Documents without a value count under None, which selection()
# never looks up.
correspondents[correspondent] += 1
document_types[document_type] += 1
storage_paths[storage_path] += 1
def link_table_counts(link_model, field_name: str) -> Counter:
result: Counter = Counter()
batch_size = self.selection_data_link_batch_size
for start in range(0, len(document_ids), batch_size):
result.update(
dict(
link_model.objects.filter(
document_id__in=document_ids[start : start + batch_size],
)
.values_list(field_name)
.annotate(document_count=Count("document_id")),
),
)
return result
def selection(model, counts: Counter) -> list[dict[str, int]]:
# Every object is listed, with 0 for those with no matching document.
return [
{"id": pk, "document_count": counts.get(pk, 0)}
for pk in model.objects.values_list("pk", flat=True)
]
return { return {
"selected_correspondents": selection( "selected_correspondents": [
Correspondent, {"id": t.id, "document_count": t.document_count} for t in correspondents
correspondents, ],
), "selected_tags": [
"selected_tags": selection( {"id": t.id, "document_count": t.document_count} for t in tags
Tag, ],
link_table_counts(Document.tags.through, "tag_id"), "selected_document_types": [
), {"id": t.id, "document_count": t.document_count} for t in document_types
"selected_document_types": selection( ],
DocumentType, "selected_storage_paths": [
document_types, {"id": t.id, "document_count": t.document_count} for t in storage_paths
), ],
"selected_storage_paths": selection( "selected_custom_fields": [
StoragePath, {"id": t.id, "document_count": t.document_count} for t in custom_fields
storage_paths, ],
),
"selected_custom_fields": selection(
CustomField,
link_table_counts(CustomFieldInstance, "field_id"),
),
} }
@classmethod @classmethod