From e9303f0c5895a565a793582b095df35b9c73dea9 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:21:56 -0700 Subject: [PATCH] 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 --- .../skills/paperless-benchmarking/SKILL.md | 5 +- src/paperless_benchmark/db.py | 4 ++ src/paperless_benchmark/endpoints.py | 7 +++ .../management/commands/benchmark.py | 4 +- src/paperless_benchmark/seeding.py | 56 +++++++++++++++++-- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/.claude/skills/paperless-benchmarking/SKILL.md b/.claude/skills/paperless-benchmarking/SKILL.md index d118da1ad..c872057e8 100644 --- a/.claude/skills/paperless-benchmarking/SKILL.md +++ b/.claude/skills/paperless-benchmarking/SKILL.md @@ -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. diff --git a/src/paperless_benchmark/db.py b/src/paperless_benchmark/db.py index 20a617497..cc763c4f3 100644 --- a/src/paperless_benchmark/db.py +++ b/src/paperless_benchmark/db.py @@ -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, diff --git a/src/paperless_benchmark/endpoints.py b/src/paperless_benchmark/endpoints.py index 5cf4d4df9..27d86582a 100644 --- a/src/paperless_benchmark/endpoints.py +++ b/src/paperless_benchmark/endpoints.py @@ -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" + ), + ), ) diff --git a/src/paperless_benchmark/management/commands/benchmark.py b/src/paperless_benchmark/management/commands/benchmark.py index 4b219510c..e3a8f08ef 100644 --- a/src/paperless_benchmark/management/commands/benchmark.py +++ b/src/paperless_benchmark/management/commands/benchmark.py @@ -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( diff --git a/src/paperless_benchmark/seeding.py b/src/paperless_benchmark/seeding.py index 86a74bcf7..b4913d860 100644 --- a/src/paperless_benchmark/seeding.py +++ b/src/paperless_benchmark/seeding.py @@ -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":