feat(benchmark): seed custom fields and time the list request with selection data

Also fixes the factory imports, which moved to paperless_testing.factories. Custom fields give the CustomFieldInstance counts real work to do, and the new documents_selection_data endpoint is the request the web UI makes on every list load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Trenton HandClaude Sonnet 5.5 committed 2026-10-30 10:21:56 -07:00
1 parent a66803456e
commit e9303f0c58
5 files changed
+66 -10

No files matched your search

@@ -34,8 +34,9 @@ manage.py benchmark list-scenarios
creates two named users, `perf_target` (mixed owned/shared documents,
realistic guardian permission grants) and `perf_admin` (superuser), plus a
general user/group pool with realistic permission-row ratios.
- **`run`** times the 3 built-in API endpoint benchmarks
(`/api/documents/`, `/api/documents/?page_size=50`, `/api/tags/?page_size=100000`) for both
- **`run`** times the 4 built-in API endpoint benchmarks
(`/api/documents/`, `/api/documents/?page_size=50`, `/api/tags/?page_size=100000`,
and the UI's list request with `include_selection_data=true`) for both
`perf_target` and `perf_admin`, reporting min/median/max wall-clock and SQL
query count. Requires `seed` to have already run — it reuses that data, it
does not seed its own.
+4
View File
@@ -13,6 +13,8 @@ def _reset_table_names() -> list[str]:
from guardian.models import UserObjectPermission
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
@@ -20,6 +22,8 @@ def _reset_table_names() -> list[str]:
return [
Document.tags.through._meta.db_table,
CustomFieldInstance._meta.db_table,
CustomField._meta.db_table,
Document._meta.db_table,
Tag._meta.db_table,
Correspondent._meta.db_table,
+7
View File
@@ -14,6 +14,13 @@ ENDPOINTS: tuple[tuple[str, str], ...] = (
("documents_default", "/api/documents/"),
("documents_page50", "/api/documents/?page_size=50"),
("tags_all", "/api/tags/?page_size=100000"),
(
"documents_selection_data",
(
"/api/documents/?page=1&page_size=50&ordering=-created"
"&truncate_content=true&include_selection_data=true"
),
),
)
@@ -135,12 +135,12 @@ class Command(BaseCommand):
self.stdout.write(f"# label={options['label']} repeat={options['repeat']}")
self.stdout.write(
f"{'user':7s} {'endpoint':20s} {'queries':>8s} "
f"{'user':7s} {'endpoint':26s} {'queries':>8s} "
f"{'min_ms':>9s} {'median_ms':>10s} {'max_ms':>9s}",
)
for r in results:
self.stdout.write(
f"{r.user_label:7s} {r.endpoint_name:20s} {r.query_count:8d} "
f"{r.user_label:7s} {r.endpoint_name:26s} {r.query_count:8d} "
f"{r.min_ms:9.1f} {r.median_ms:10.1f} {r.max_ms:9.1f}",
)
append_history(
+50 -6
View File
@@ -43,6 +43,8 @@ SHARED_WITH_CHANGE_FRACTION = 0.5
# realistic permission-join shape for arbitrary users, not only perf_target.
USER_PERM_ROWS_PER_DOC = 1_414 / 12_000
GROUP_PERM_ROWS_PER_DOC = 27_232 / 12_000
# Share of documents that carry any custom field values.
CUSTOM_FIELD_DOC_FRACTION = 0.4
@dataclass(frozen=True, slots=True)
@@ -55,6 +57,7 @@ class _TierCounts:
other_users: int
groups: int
tags_per_doc: tuple[int, int]
custom_fields: int = 0
TIERS: dict[Tier, _TierCounts] = {
@@ -67,6 +70,7 @@ TIERS: dict[Tier, _TierCounts] = {
other_users=3,
groups=2,
tags_per_doc=(1, 3),
custom_fields=3,
),
"medium": _TierCounts(
documents=20_000,
@@ -77,6 +81,7 @@ TIERS: dict[Tier, _TierCounts] = {
other_users=10,
groups=5,
tags_per_doc=(2, 6),
custom_fields=10,
),
"large": _TierCounts(
documents=360_000,
@@ -87,6 +92,7 @@ TIERS: dict[Tier, _TierCounts] = {
other_users=25,
groups=10,
tags_per_doc=(3, 7),
custom_fields=25,
),
}
@@ -150,7 +156,7 @@ def _create_users_and_groups(
) -> tuple[User, User, tuple[User, ...], tuple[Group, ...]]:
from django.contrib.auth.models import Group
from documents.tests.factories import UserFactory
from paperless_testing.factories import UserFactory
perf_target = UserFactory.create(username="perf_target")
_grant_model_level_permissions(perf_target)
@@ -171,10 +177,10 @@ def _create_lookup_tables(counts: _TierCounts):
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
from paperless_testing.factories import CorrespondentFactory
from paperless_testing.factories import DocumentTypeFactory
from paperless_testing.factories import StoragePathFactory
from paperless_testing.factories import TagFactory
tags = tuple(Tag.objects.bulk_create(TagFactory.build_batch(counts.tags)))
correspondents = tuple(
@@ -230,8 +236,10 @@ def _seed_documents(
from django.contrib.contenttypes.models import ContentType
from guardian.models import UserObjectPermission
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.tests.factories import DocumentFactory
from paperless_testing.factories import DocumentFactory
tag_ids = [t.pk for t in tags]
correspondent_ids = [c.pk for c in correspondents]
@@ -249,6 +257,21 @@ def _seed_documents(
)
through_model = Document.tags.through
# A subset of documents carries 1-3 custom field values, so the
# CustomFieldInstance link table is populated for the counts that join it.
custom_field_ids = [
f.pk
for f in CustomField.objects.bulk_create(
[
CustomField(
name=f"bench field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(counts.custom_fields)
],
)
]
document_count = 0
owned_document_pks: list[int] = []
remaining = counts.documents
@@ -300,6 +323,27 @@ def _seed_documents(
if through_rows:
through_model.objects.bulk_create(through_rows, batch_size=CHUNK_SIZE)
if custom_field_ids:
custom_field_rows = []
for doc in created:
if rng.random() >= CUSTOM_FIELD_DOC_FRACTION:
continue
for field_id in rng.sample(
custom_field_ids,
min(rng.randint(1, 3), len(custom_field_ids)),
):
custom_field_rows.append(
CustomFieldInstance(
document_id=doc.pk,
field_id=field_id,
value_text=f"value {doc.pk}",
),
)
CustomFieldInstance.objects.bulk_create(
custom_field_rows,
batch_size=CHUNK_SIZE,
)
perm_rows = []
for doc, bucket in zip(created, owner_buckets, strict=True):
if bucket == "unowned":