Compare commits

..
Author SHA1 Message Date
Trenton HandClaude Sonnet 5.5 5304e46d5d fix(benchmark): reset SQLite with plain DELETEs instead of ORM deletes
The ORM path fires per-document delete signals, which is very slow at the large tier and fails when the cache backend is unreachable. Matches the TRUNCATE-based resets on the other backends.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 10:21:57 -07:00
Trenton HandClaude Sonnet 5.5 e9303f0c58 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>
2026-10-30 10:21:56 -07:00
Trenton HandClaude Sonnet 5.5 a66803456e fix(benchmark): use restrict_queryset_to_visible in the visibility scenarios
get_objects_for_user_owner_aware no longer exists, so guardian_visibility_query raised ImportError.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 10:21:55 -07:00
stumpylogandClaude Sonnet 5 431f855772 chore: remove benchmark-commands spec/plan docs now that work is complete
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
stumpylogandClaude Sonnet 5 0b825c4599 Fix: apply final-review fix wave (safety guard, date spread, MariaDB explain, history fields, memory footprint, minors)
Final whole-branch review before this tooling branch settles: add a
required --yes-i-know-this-wipes-the-database confirmation flag ahead of
--reset (it deletes ALL users/groups/documents in the target DB, not just
benchmark-created ones); spread seeded documents' created dates over a
3-year window instead of leaving them all on one default date; fix
capture_explain() to use MariaDB's ANALYZE syntax instead of Postgres-only
EXPLAIN ANALYZE (verified against real MariaDB 12.3 -- the old code was a
silent 1064 syntax error); record db_vendor/document_count in run/profile
history entries; shrink SeededData's memory footprint at scale by
returning counts instead of full ORM instance tuples; and a handful of
minor fixes (storage_path assignment, --explain warning instead of silent
no-op, harness.py repeat<1 guard, type annotations). All changes verified
against real Postgres and MariaDB containers on the VM, not just SQLite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
stumpylogandClaude Sonnet 5 c7914b9882 Fix: correct /api/tags/ endpoint URL in benchmarking skill
The skill documentation stated that run benchmarks /api/tags/ but
src/paperless_benchmark/endpoints.py actually uses
/api/tags/?page_size=100000. Update the skill's Command reference
section to match the actual endpoint definition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
stumpylog fec492fbe7 docs(benchmark): add paperless-benchmarking skill 2026-10-30 10:18:50 -07:00
stumpylog 1a587937f2 fix(benchmark): fully clear users/groups on reset, grant perf_target model perms 2026-10-30 10:18:50 -07:00
stumpylogandClaude Sonnet 5 2e62819a55 docs: add Task 10 (quirk fixes) and Task 11 (skill) to benchmark plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
stumpylog 17ffec18cb fix(benchmark): reuse seeded data in profile instead of reseeding
_handle_profile unconditionally called seed_benchmark_dataset every
run, colliding with an IntegrityError against data seeded by a prior
seed/profile call. Look up the existing perf_target user instead,
matching _handle_run's already-correct pattern, and raise a
CommandError pointing at `seed` if no dataset exists.

Scenario.run/queryset_for_explain now take a User directly instead of
a SeededData, since only data.users[0] (perf_target) was ever used.

profile no longer seeds, so it drops the --tier dependency: the
printed line and history entry no longer include a tier/(tier=...)
suffix, and --tier's help text now only mentions `seed`.
2026-10-30 10:18:50 -07:00
stumpylog d3bf158319 feat(benchmark): add manage.py benchmark command (seed/run/profile/list-scenarios) 2026-10-30 10:18:50 -07:00
stumpylog 7b28cb6b0a feat(benchmark): add pluggable profile-scenario registry 2026-10-30 10:18:50 -07:00
stumpylog 5b3ecfbc86 feat(benchmark): add timed API endpoint scenarios 2026-10-30 10:18:50 -07:00
stumpylogandClaude Sonnet 5 c9a010a1ef docs: add implementation plan for benchmark management commands
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
stumpylog 2d07f469ef feat(benchmark): add local JSONL results history log 2026-10-30 10:18:50 -07:00
stumpylog 90f63bd553 feat(benchmark): add tiered dataset seeding 2026-10-30 10:18:50 -07:00
stumpylog 694b831118 feat(benchmark): add per-backend reset and explain helpers 2026-10-30 10:18:50 -07:00
stumpylog 956cf7e03c feat(benchmark): add run_profile timing harness 2026-10-30 10:18:50 -07:00
stumpylogandClaude Sonnet 5 6fcacbb1aa feat(benchmark): scaffold paperless_benchmark app
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
stumpylogandClaude Sonnet 5 57f44de03a docs: add design spec for benchmark management commands
Merges the standalone run_benchmarks.py/seed_benchmark_data.py scripts
and the tools/profiling branch's Postgres-only harness into one
cross-backend `manage.py benchmark` command suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 10:18:50 -07:00
GitHub Actions 8a67481a9d Auto translate strings 2026-10-30 16:59:59 +00:00
shamoon 652f964f5d Tweak: move preview button in doc list (#14594) 2026-10-30 09:58:29 -07:00
shamoon 80a1827fa0 Fix: checksum light mode display and cf dropdown mobile position (#14590) 2026-10-30 07:03:08 -07:00
43 changed files with 1421 additions and 127 deletions

No files matched your search

@@ -0,0 +1,188 @@
---
name: paperless-benchmarking
description: Use when profiling paperless-ngx performance, running `manage.py benchmark`, investigating a slow query or endpoint, or deciding whether a profiling finding should become a permanent registered scenario. Covers command reference, the fork/merge-back branch workflow for perf investigations, and how to read query-plan output.
---
# Paperless-ngx Benchmarking
This repo has a built-in benchmarking/profiling tool: `manage.py benchmark`, in
the `paperless_benchmark` Django app. It replaces ad hoc standalone scripts —
use it instead of writing new one-off seed/timing scripts.
## Command reference
```
manage.py benchmark seed --tier {home,medium,large} [--reset --yes-i-know-this-wipes-the-database] [--seed N]
manage.py benchmark run --repeat 5 [--label baseline]
manage.py benchmark profile <scenario_name> [--repeat 5] [--explain]
manage.py benchmark list-scenarios
```
- **`seed`** builds a realistic dataset at one of three scales: `home` (500
documents — fast, use this for iteration), `medium` (20,000 — the default
when `--tier` is omitted; a multi-minute seed), `large` (360,000 — matches
the scale reported in real large-install bug reports; slow, only use it
when a finding needs confirming at real scale). `--reset` wipes any
previously-seeded benchmark data first — but it is destructive and
irreversible: it deletes **all** users, **all** groups, and **all**
documents/tags/correspondents/document types/storage paths in the target
database, not just benchmark-created rows. Only run it against a disposable
benchmark database, never a real install. Because of that, `--reset` also
requires passing `--yes-i-know-this-wipes-the-database` in the same
invocation, or the command raises an error and does nothing. Omit both
flags if you want to layer more data onto an existing seed instead. `seed`
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 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.
- **`profile`** times one named scenario from the registry (see
`list-scenarios`) via best-of-N repeat timing and SQL query count, against
`perf_target`. `--explain` additionally captures and prints the query plan:
real `EXPLAIN ANALYZE` execution stats on PostgreSQL/MariaDB, or
`EXPLAIN QUERY PLAN` (plan only, no real timing/row counts — clearly labeled
as such) on SQLite. Like `run`, `profile` requires `seed` to have already
run — it does not seed its own data either.
- Every `run`/`profile` invocation appends a JSON line to
`benchmark_results/history.jsonl` at the repo root (local-only, gitignored —
never commit this file). Use it to compare a `before`/`after` pair across
two invocations without hand-copying numbers.
- Full chain example: `seed --reset` once, then `run` and `profile` as many
times as you want against that same seeded data — no need to reseed between
them.
## Adding a new scenario
A "scenario" is a named, registered query/operation that `profile` can time
and explain. To add one, edit `src/paperless_benchmark/scenarios.py`: write a
`_<name>_run(user)` function (returns whatever `run_profile` should time) and
optionally a `_<name>_queryset(user)` function (returns the `QuerySet` for
`--explain` to analyze), then `register(Scenario(name=..., describe=...,
run=..., queryset_for_explain=...))` at module level. Both functions receive
the already-seeded `perf_target` user — they should not seed their own data.
## Branch workflow
`tools/benchmark-management-commands` is a **long-lived tooling branch**, not
a feature branch that gets merged and closed:
1. It is periodically brought up to date with `dev` (merge `dev` into it) so
the tooling doesn't drift from the schema/codebase it profiles. Do this
before starting a new investigation if it's been a while since the last
sync.
2. **Every performance investigation forks its own branch from
`tools/benchmark-management-commands`** (not from `dev`). Do the
investigation there: write throwaway profiling code, try fixes, capture
before/after numbers.
3. **That investigation branch never merges into `dev` or production.** Its
only job is to produce evidence and, optionally, a reusable scenario.
4. If the investigation turns up a scenario worth keeping permanently (see
"When to graduate a scenario" below), open a PR that adds **just that
scenario** back into `tools/benchmark-management-commands` — not the rest
of the investigation branch's throwaway code.
5. Any actual production fix the investigation motivates (e.g. an ORM query
change) goes into its own normal feature branch off `dev`, following the
project's regular contribution process — profiling evidence informs that
PR's description, but the profiling code itself does not travel with it.
## Reading query-plan output
- **PostgreSQL** `EXPLAIN ANALYZE`: look for `Seq Scan` on a large table
(missing index), a large gap between `rows=N` (planner's estimate) and the
actual row count in parentheses (stale statistics or a bad cardinality
estimate), and nested-loop joins driven by an outer relation with many
rows (usually the N+1 pattern this tool exists to catch).
- **MariaDB**: verified against a real MariaDB 12.3 container that MariaDB
does NOT accept MySQL 8.0.18+'s `EXPLAIN ANALYZE` syntax (it's a 1064
syntax error) -- `capture_explain()` instead runs MariaDB's own
`ANALYZE <statement>` form (no `EXPLAIN` keyword), which returns a
tabular plan with real per-row execution columns: `rows` (estimate) vs.
`r_rows` (actual), and `filtered` vs. `r_filtered`. A large gap between
`rows` and `r_rows`, or `type: ALL` (full table scan) on a large table,
are the signals to look for -- the same underlying concerns as Postgres's
`Seq Scan`/estimate-vs-actual gap, just in MariaDB's column-based output
instead of Postgres's nested-tree text format.
- **SQLite** `EXPLAIN QUERY PLAN`: no real timing/row-count data, only the
chosen access path (`SCAN` vs `SEARCH`, which index if any). Useful for
confirming an index is even being considered, not for judging real-world
cost — corroborate any SQLite finding against Postgres/MariaDB before
trusting it, since planner behavior differs meaningfully between them.
- Compare query **count**, not just timing, between before/after: a fix that
keeps the same wall-clock time but drops query count from O(n) to O(1) is
still a real, durable improvement — timing alone is noisy and
environment-dependent, query count is not.
## Cleaning up after an interrupted run
If a `seed`/`run`/`profile` invocation gets killed mid-run (Ctrl-C, `kill -9`,
a timed-out SSH session, etc.), check whether it left anything behind before
trusting the next benchmark's numbers. This was verified for real: a
`benchmark seed --tier large --reset ...` was started against both a fresh
PostgreSQL 18 container and a fresh MariaDB 12.3 container and `kill -9`'d a
few seconds into document seeding. In both cases, the database-side
connection disappeared immediately -- no stuck backend, no lingering query,
no held lock was observed in either backend once the killed process's PID
was confirmed gone. That said, this was one interruption point (mid
bulk-seed, between chunks); a run killed mid-query, or a driver/network
hiccup that doesn't cleanly close the socket, could behave differently, so
still check before trusting a number if any run in the session was
interrupted:
- **PostgreSQL**: look for leftover connections against the benchmark
database:
```sql
SELECT pid, state, query, query_start
FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid();
```
If a stuck backend shows up, clear it with:
```sql
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid();
```
- **MariaDB**: look for leftover connections/queries:
```sql
SHOW FULL PROCESSLIST;
```
If a stuck connection shows up (anything other than your current admin
session), clear it with:
```sql
KILL <id>;
```
A stray connection left running concurrently with a subsequent benchmark run
would add real, contaminating load (extra queries competing for the same
rows, possibly held locks slowing the next run's timings) -- cheap to rule
out, expensive to silently trust a number that was actually measured
alongside a zombie connection.
## When to graduate a one-off finding into a permanent scenario
Register a scenario (rather than leaving it as throwaway code on the
investigation branch) when **both** are true:
- The query pattern is one this codebase is likely to regress on again (e.g.
it involves a permission-check join, a bulk operation, or anything else
with an easy-to-reintroduce N+1) — not a one-time fluke specific to this
investigation.
- Re-running it later, against a fresh seed, would still produce a
meaningful signal (it doesn't depend on investigation-specific throwaway
data or a fix that's already permanently landed and can't regress the same
way).
If a finding doesn't meet both bars, keep it as disposable code on the
investigation branch and let the branch's evidence (captured in the PR
description of whatever production fix it motivates) be the permanent
record instead.
+3
View File
@@ -115,3 +115,6 @@ celerybeat-schedule*
# Git worktree local folder
.worktrees
# Benchmark tooling output (local only, never committed)
/benchmark_results/
+5 -5
View File
@@ -10334,18 +10334,18 @@
<context context-type="linenumber">294,295</context>
</context-group>
</trans-unit>
<trans-unit id="2179847500064178686" datatype="html">
<source>Edit document</source>
<trans-unit id="3420321797707163677" datatype="html">
<source>Preview document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">328</context>
</context-group>
</trans-unit>
<trans-unit id="3420321797707163677" datatype="html">
<source>Preview document</source>
<trans-unit id="2179847500064178686" datatype="html">
<source>Edit document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">329</context>
<context context-type="linenumber">331</context>
</context-group>
</trans-unit>
<trans-unit id="4512084577073831437" datatype="html">
@@ -1,10 +1,3 @@
.custom-fields-dropdown {
min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -245px !important;
}
}
}
@@ -308,7 +308,7 @@
</tr>
<tr class="align-middle">
<td i18n>Original SHA256 checksum</td>
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr>
<tr>
<td i18n>Original file size</td>
@@ -208,7 +208,7 @@
i18n>Correspondent</th>
}
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th class="cursor-pointer"
<th class="cursor-pointer ps-5"
pngxSortable="title"
title="Sort by title" i18n-title
[currentSortField]="list.sortField"
@@ -322,13 +322,13 @@
</td>
}
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td width="30%">
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)">
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<i-bs name="eye"></i-bs>
</pngx-preview-popup>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
</div>
}
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
@@ -87,6 +87,10 @@ $paperless-card-breakpoints: (
}
}
.table .ps-5 {
padding-left: 2.2rem !important;
}
a {
cursor: pointer;
}
+3 -7
View File
@@ -10,6 +10,9 @@ from typing import TYPE_CHECKING
import regex as regex_mod
from django.conf import settings
from pdf2image import convert_from_path
from pikepdf import Page
from pikepdf import PasswordError
from pikepdf import Pdf
from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument
@@ -31,7 +34,6 @@ from documents.utils import maybe_override_pixel_limit
from paperless.config import BarcodeConfig
if TYPE_CHECKING:
from pikepdf import Page
from PIL import Image
logger = logging.getLogger("paperless.barcodes")
@@ -280,8 +282,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
# No op if not a TIFF
self.convert_from_tiff_to_pdf()
from pikepdf import PasswordError
try:
self.barcodes = scan_pdf(
self.pdf_file,
@@ -433,8 +433,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
document_paths = []
fname: str = self.input_doc.original_file.stem
from pikepdf import Pdf
with Pdf.open(self.pdf_file) as input_pdf:
# Start with an empty document
current_document: list[Page] = []
@@ -518,8 +516,6 @@ def scan_pdf(pdf_path: Path, settings: BarcodeConfig, work_dir: Path) -> list[Ba
"""
barcodes: list[Barcode] = []
from pikepdf import Pdf
with Pdf.open(pdf_path) as pdf:
num_of_pages = len(pdf.pages)
logger.debug(f"PDF has {num_of_pages} pages")
+1 -2
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import img2pdf
from PIL import Image
from documents.utils import copy_basic_file_stats
@@ -16,8 +17,6 @@ def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
Returns the path of the PDF created.
"""
import img2pdf
# override pixel setting if needed
maybe_override_pixel_limit()
+1 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Final
from django.conf import settings
from pikepdf import Pdf
from documents.consumer import ConsumerError
from documents.converters import convert_from_tiff_to_pdf
@@ -84,8 +85,6 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
valid_staging_exists = True
if valid_staging_exists:
from pikepdf import Pdf
try:
# Collate pages from second PDF in reverse order
with Pdf.open(staging) as pdf1, Pdf.open(pdf_file) as pdf2:
+1 -2
View File
@@ -11,6 +11,7 @@ from decimal import InvalidOperation
from typing import TYPE_CHECKING
from typing import Any
import dateparser
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
@@ -349,8 +350,6 @@ class DateQueryField(serializers.DateField):
if not isinstance(value, str):
raise
import dateparser
try:
parsed = dateparser.parse(
value,
+2 -14
View File
@@ -14,6 +14,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from typing import NamedTuple
import pikepdf
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Iterable
@@ -41,8 +43,6 @@ def rotate_pdf(src: Path, dst: Path, degrees: int) -> None:
Rotate every page relatively on the opened document, not a rebuild, so Info,
XMP and outlines are kept. ``src`` is not modified.
"""
import pikepdf
with pikepdf.open(src) as pdf:
for page in pdf.pages:
page.rotate(degrees, relative=True)
@@ -58,8 +58,6 @@ def remove_pages(src: Path, dst: Path, pages: Iterable[int]) -> None:
Duplicates are ignored. Pages are removed highest first so earlier removals
never shift the index of later ones.
"""
import pikepdf
unique = sorted(set(pages))
_require_positive(unique)
with pikepdf.open(src) as pdf:
@@ -83,8 +81,6 @@ def build_pdfs(
(Info, XMP, outlines) is not carried over. Returns the written paths in output
order.
"""
import pikepdf
for specs, _ in outputs:
_require_positive(spec.page for spec in specs)
@@ -144,8 +140,6 @@ def needs_decrypt(src: Path) -> bool:
True if ``src`` is encrypted. A PDF that needs a password to open at all
counts as encrypted.
"""
import pikepdf
try:
with pikepdf.open(src) as pdf:
return bool(pdf.is_encrypted)
@@ -160,8 +154,6 @@ def decrypt_pdf(src: Path, make_dst: Callable[[], Path], password: str) -> Path:
``make_dst`` is only called once the password has been accepted, so a wrong
password never leaves a destination behind.
"""
import pikepdf
with pikepdf.open(src, password=password) as pdf:
pdf.remove_unreferenced_resources()
dst = make_dst()
@@ -178,8 +170,6 @@ class PdfMerger:
"""
def __init__(self) -> None:
import pikepdf
self._pdf = pikepdf.new()
self._version: str = self._pdf.pdf_version
@@ -195,8 +185,6 @@ class PdfMerger:
self._pdf.close()
def add(self, path: Path) -> None:
import pikepdf
with pikepdf.open(str(path)) as pdf:
self._version = max(self._version, pdf.pdf_version)
self._pdf.pages.extend(pdf.pages)
+2 -2
View File
@@ -7,6 +7,8 @@ from dataclasses import dataclass
from types import TracebackType
from typing import Self
import dateparser
logger = logging.getLogger(__name__)
@@ -78,8 +80,6 @@ class DateParserPluginBase(ABC):
Uses configuration from `self.config`.
"""
import dateparser
try:
return dateparser.parse(
date_string,
+3 -8
View File
@@ -76,6 +76,9 @@ from paperless.logging import consume_task_id
from paperless.parsers import ParserContext
from paperless.parsers.registry import get_parser_registry
from paperless_ai.exceptions import LLMTimeoutError
from paperless_ai.indexing import llm_index_add_or_update_document
from paperless_ai.indexing import llm_index_remove_document
from paperless_ai.indexing import update_llm_index
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
@@ -336,8 +339,6 @@ def bulk_update_documents(document_ids) -> None:
ai_config = AIConfig()
if ai_config.llm_index_enabled:
from paperless_ai.indexing import update_llm_index
update_llm_index(
rebuild=False,
document_ids=document_ids,
@@ -498,8 +499,6 @@ def update_document_content_maybe_archive_file(
ai_config = AIConfig()
if ai_config.llm_index_enabled:
from paperless_ai.indexing import llm_index_add_or_update_document
llm_index_add_or_update_document(document)
clear_document_caches(document.pk)
@@ -801,15 +800,11 @@ def apply_ai_suggestions(self, action_id: int, document_id: int) -> None:
@shared_task
def update_document_in_llm_index(document) -> None:
from paperless_ai.indexing import llm_index_add_or_update_document
llm_index_add_or_update_document(document)
@shared_task
def remove_document_from_llm_index(document) -> None:
from paperless_ai.indexing import llm_index_remove_document
llm_index_remove_document(document)
@@ -71,7 +71,7 @@ class TestParseString:
):
# We still need to mock dateparser.parse to force the exception
mocker.patch(
"dateparser.parse",
"documents.plugins.date_parsing.base.dateparser.parse",
side_effect=ValueError(
"Parsing error: 01/01/2024",
),
@@ -260,7 +260,7 @@ class TestRegexDateParser:
parser = RegexDateParserPlugin(base_config)
# Patch the dateparser.parse
target = "dateparser.parse"
target = "documents.plugins.date_parsing.base.dateparser.parse"
def fake_parse(
date_string: str,
@@ -335,7 +335,7 @@ class TestRegexDateParser:
)
parser = RegexDateParserPlugin(cfg)
target = "dateparser.parse"
target = "documents.plugins.date_parsing.base.dateparser.parse"
def fake_parse(
date_string: str,
@@ -400,7 +400,7 @@ class TestRegexDateParser:
parser = RegexDateParserPlugin(cfg)
# Patch the module's dateparser.parse so we can inspect calls
target = "dateparser.parse"
target = "documents.plugins.date_parsing.base.dateparser.parse"
def fake_parse(
date_string: str,
+5 -5
View File
@@ -1070,7 +1070,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists,
patch("paperless.views.llm_index_exists") as mock_exists,
):
mock_exists.return_value = False
self.client.patch(
@@ -1095,7 +1095,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists", return_value=False),
patch("paperless.views.llm_index_exists", return_value=False),
):
self.client.patch(
f"{self.ENDPOINT}1/",
@@ -1115,7 +1115,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists,
patch("paperless.views.llm_index_exists") as mock_exists,
):
mock_exists.return_value = True
self.client.patch(
@@ -1136,7 +1136,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists,
patch("paperless.views.llm_index_exists") as mock_exists,
):
mock_exists.return_value = True
self.client.patch(
@@ -1174,7 +1174,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists,
patch("paperless.views.llm_index_exists") as mock_exists,
):
mock_exists.return_value = True
self.client.patch(
+2 -2
View File
@@ -61,7 +61,7 @@ class TestChatStreamingViewInputValidation(APITestCase):
return_value=self._mock_ai_enabled(),
),
mock.patch(
"paperless_ai.chat.stream_chat_with_documents",
"documents.views.stream_chat_with_documents",
return_value=iter(chunks),
),
):
@@ -104,7 +104,7 @@ class TestChatStreamingViewUnrestrictedFlag:
"""
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
return mocker.patch(
"paperless_ai.chat.stream_chat_with_documents",
"documents.views.stream_chat_with_documents",
return_value=iter(()),
)
@@ -1110,7 +1110,7 @@ class TestVersionActionPermissions(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, 403)
with (
mock.patch("documents.views.AIConfig") as ai_config,
mock.patch("paperless_ai.chat.stream_chat_with_documents") as chat,
mock.patch("documents.views.stream_chat_with_documents") as chat,
):
ai_config.return_value.ai_enabled = True
response = self.client.post(
+1 -1
View File
@@ -2686,7 +2686,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
response = self.client.get("/api/documents/34676/suggestions/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@mock.patch("paperless_ai.ai_classifier.get_ai_document_classification")
@mock.patch("documents.views.get_ai_document_classification")
@override_settings(AI_ENABLED=True)
def test_suggestions_still_uses_classifier_when_ai_enabled(
self,
@@ -572,7 +572,7 @@ class TestAiChatAllDocumentsPermissionBoundary:
ENDPOINT = "/api/documents/chat/"
@override_settings(AI_ENABLED=True)
@patch("paperless_ai.chat.stream_chat_with_documents")
@patch("documents.views.stream_chat_with_documents")
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
mock_stream_chat.return_value = iter([b"data"])
+4 -4
View File
@@ -322,7 +322,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
)
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
@mock.patch("paperless_ai.indexing.llm_index_add_or_update_document")
@mock.patch("documents.tasks.llm_index_add_or_update_document")
@mock.patch("documents.search.get_backend")
def test_update_content_version_updates_llm_index(
self,
@@ -450,7 +450,7 @@ class TestAIIndex(DirectoriesMixin, TestCase):
checksum="wow",
)
with mock.patch(
"paperless_ai.indexing.llm_index_add_or_update_document",
"documents.tasks.llm_index_add_or_update_document",
) as llm_index_add_or_update_document:
tasks.update_document_in_llm_index(doc)
llm_index_add_or_update_document.assert_called_once_with(doc)
@@ -470,7 +470,7 @@ class TestAIIndex(DirectoriesMixin, TestCase):
checksum="wow",
)
with mock.patch(
"paperless_ai.indexing.llm_index_remove_document",
"documents.tasks.llm_index_remove_document",
) as llm_index_remove_document:
tasks.remove_document_from_llm_index(doc)
llm_index_remove_document.assert_called_once_with(doc)
@@ -495,7 +495,7 @@ class TestAIIndex(DirectoriesMixin, TestCase):
"documents.tasks.update_document_in_llm_index",
) as update_document_in_llm_index,
mock.patch(
"paperless_ai.indexing.update_llm_index",
"documents.tasks.update_llm_index",
) as update_llm_index,
):
doc_ids = [doc.pk for doc in docs]
+16 -16
View File
@@ -474,7 +474,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [])
self.assertEqual(response.json()["suggested_tags"], [])
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -524,7 +524,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
None,
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -562,7 +562,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"KI Title",
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -601,7 +601,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"Titre IA",
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -637,7 +637,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
),
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -709,7 +709,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
),
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
@@ -738,7 +738,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
@@ -765,7 +765,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
@@ -797,7 +797,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -835,7 +835,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -874,7 +874,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], [])
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -996,7 +996,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn(b"AI is required for this feature", response.content)
@patch("paperless_ai.chat.stream_chat_with_documents")
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids")
@override_settings(AI_ENABLED=True)
def test_post_no_document_id(self, mock_permitted_ids, mock_stream_chat) -> None:
@@ -1016,7 +1016,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertIsNone(call_kwargs["output_language"])
@patch("paperless_ai.chat.stream_chat_with_documents")
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids")
@override_settings(AI_ENABLED=True)
def test_post_uses_user_display_language(
@@ -1042,7 +1042,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertEqual(call_kwargs["output_language"], "de-de")
@patch("paperless_ai.chat.stream_chat_with_documents")
@patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
def test_post_with_document_id(self, mock_stream_chat) -> None:
self.grant_view_document_permission()
@@ -1079,7 +1079,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 403)
self.assertIn(b"Insufficient permissions", response.content)
@patch("paperless_ai.chat.stream_chat_with_documents")
@patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
def test_post_no_document_id_requires_view_document_permission(
self,
@@ -1093,7 +1093,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 403)
mock_stream_chat.assert_not_called()
@patch("paperless_ai.chat.stream_chat_with_documents")
@patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
def test_post_with_document_id_requires_view_document_permission(
self,
+12 -15
View File
@@ -250,8 +250,20 @@ from paperless.parsers.remote import RemoteEngineConfig
from paperless.serialisers import GroupSerializer
from paperless.serialisers import UserSerializer
from paperless.views import StandardPagination
from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.chat import stream_chat_with_documents
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError
from paperless_ai.matching import extract_unmatched_names
from paperless_ai.matching import match_correspondents_by_name
from paperless_ai.matching import match_document_types_by_name
from paperless_ai.matching import match_storage_paths_by_name
from paperless_ai.matching import match_tags_by_name
from paperless_ai.matching import resolve_correspondent_ids
from paperless_ai.matching import resolve_document_type_ids
from paperless_ai.matching import resolve_storage_path_ids
from paperless_ai.matching import resolve_tag_ids
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.oauth import PaperlessMailOAuth2Manager
@@ -1601,18 +1613,6 @@ class DocumentViewSet(
if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature")
from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.matching import extract_unmatched_names
from paperless_ai.matching import match_correspondents_by_name
from paperless_ai.matching import match_document_types_by_name
from paperless_ai.matching import match_storage_paths_by_name
from paperless_ai.matching import match_tags_by_name
from paperless_ai.matching import resolve_correspondent_ids
from paperless_ai.matching import resolve_document_type_ids
from paperless_ai.matching import resolve_storage_path_ids
from paperless_ai.matching import resolve_tag_ids
output_language = get_llm_output_language(
ai_config=ai_config,
user=request.user,
@@ -2446,9 +2446,6 @@ class ChatStreamingView(GenericAPIView[Any]):
)
unrestricted = user_is_unrestricted(request.user)
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.chat import stream_chat_with_documents
output_language = get_llm_output_language(
ai_config=ai_config,
user=request.user,
+1 -1
View File
@@ -17,7 +17,7 @@ from paperless.urls import websocket_urlpatterns # noqa: E402
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
},
)
+3 -4
View File
@@ -36,6 +36,9 @@ from humanize import naturalsize
from imap_tools import MailAttachment
from imap_tools import MailMessage
from tika_client import TikaClient
from turbohtml.clean import Linkify
from turbohtml.clean import linkify
from turbohtml.migration.bleach import clean
from documents.parsers import ParseError
from documents.parsers import make_thumbnail_from_pdf
@@ -619,10 +622,6 @@ class MailDocumentParser:
def clean_html(text: str) -> str:
"""Attempt to clean, escape, and linkify the given HTML string."""
from turbohtml.clean import Linkify
from turbohtml.clean import linkify
from turbohtml.migration.bleach import clean
if isinstance(text, list):
text = "\n".join([str(e) for e in text])
if not isinstance(text, str):
+4 -8
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import functools
import importlib.resources
import logging
import os
@@ -40,12 +39,9 @@ if TYPE_CHECKING:
logger = logging.getLogger("paperless.parsing.tesseract")
@functools.cache
def _srgb_icc_data() -> bytes:
# Read on first use, importing ocrmypdf.data pulls in the whole ocrmypdf package
return importlib.resources.files("ocrmypdf.data").joinpath("sRGB.icc").read_bytes()
_SRGB_ICC_DATA: Final[bytes] = (
importlib.resources.files("ocrmypdf.data").joinpath("sRGB.icc").read_bytes()
)
_SUPPORTED_MIME_TYPES: Final[dict[str, str]] = {
"application/pdf": ".pdf",
@@ -407,7 +403,7 @@ class RasterisedDocumentParser:
pdfa_path = Path(self.tempdir) / "archive.pdf"
try:
with pikepdf.open(plain_pdf_path) as pdf:
cs = pdf.make_stream(_srgb_icc_data())
cs = pdf.make_stream(_SRGB_ICC_DATA)
cs["/N"] = 3
output_intent = pikepdf.Dictionary(
Type=pikepdf.Name("/OutputIntent"),
+1
View File
@@ -158,6 +158,7 @@ INSTALLED_APPS = [
"drf_spectacular",
"drf_spectacular_sidecar",
"treenode",
"paperless_benchmark.apps.PaperlessBenchmarkConfig",
*env_apps,
]
+1 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any
from celery.schedules import crontab
from dateparser.languages.loader import LocaleDataLoader
from paperless.settings.parsers import get_choice_from_env
from paperless.settings.parsers import get_int_from_env
@@ -351,8 +352,6 @@ def parse_dateparser_languages(languages: str | None) -> list[str]:
)
language_list.append("zh")
from dateparser.languages.loader import LocaleDataLoader
return list(LocaleDataLoader().get_locale_map(locales=language_list))
+1 -4
View File
@@ -59,10 +59,7 @@ def test_ocr_to_dateparser_languages_exception(
raise RuntimeError("Simulated error")
with caplog.at_level(logging.WARNING):
monkeypatch.setattr(
"dateparser.languages.loader.LocaleDataLoader",
lambda: DummyLoader(),
)
monkeypatch.setattr(utils, "LocaleDataLoader", lambda: DummyLoader())
result = utils.ocr_to_dateparser_languages("eng+fra")
assert result == []
assert (
+2 -2
View File
@@ -1,5 +1,7 @@
import logging
from dateparser.languages.loader import LocaleDataLoader
logger = logging.getLogger("paperless.utils")
OCR_TO_DATEPARSER_LANGUAGES = {
@@ -123,8 +125,6 @@ def ocr_to_dateparser_languages(ocr_languages: str) -> list[str]:
- If a language cannot be mapped or validated, it is skipped with a warning.
- Returns a list of valid locales, or an empty list if none could be converted.
"""
from dateparser.languages.loader import LocaleDataLoader
loader = LocaleDataLoader()
result = []
try:
+1 -1
View File
@@ -3,6 +3,7 @@ from io import BytesIO
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from lxml import etree
from PIL import Image
ALLOWED_SVG_TAGS: set[str] = {
@@ -185,7 +186,6 @@ def reject_dangerous_svg(file: UploadedFile) -> None:
Raises ValidationError if unsafe content is found.
See GHSA-6p53-hqqw-8j62
"""
from lxml import etree
try:
parser = etree.XMLParser(resolve_entities=False)
+1 -2
View File
@@ -49,6 +49,7 @@ from paperless.serialisers import GroupSerializer
from paperless.serialisers import PaperlessAuthTokenSerializer
from paperless.serialisers import ProfileSerializer
from paperless.serialisers import UserSerializer
from paperless_ai.indexing import llm_index_exists
class PaperlessObtainAuthTokenView(ObtainAuthToken):
@@ -470,8 +471,6 @@ class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
or old_llm_embedding_model != new_llm_embedding_model
or old_llm_context_size != new_llm_context_size
)
from paperless_ai.indexing import llm_index_exists
rebuild_needed = new_ai_index_enabled and (
not llm_index_exists() or embedding_config_changed
)
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class PaperlessBenchmarkConfig(AppConfig):
name = "paperless_benchmark"
verbose_name = _("Paperless benchmark")
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import connection
if TYPE_CHECKING:
from django.db.models import QuerySet
def _reset_table_names() -> list[str]:
from guardian.models import GroupObjectPermission
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
from documents.models import Tag
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,
DocumentType._meta.db_table,
StoragePath._meta.db_table,
UserObjectPermission._meta.db_table,
GroupObjectPermission._meta.db_table,
]
def _delete_all_users_and_groups() -> None:
# ASSUMPTION: this tool assumes a disposable benchmark database, never
# point it at a real install. This deletes EVERY user and group in the
# database (not just benchmark-created ones) -- there is no way to
# distinguish "real" users from seeded ones, so this is only safe against
# a database that exists solely to run this benchmarking tool. The
# `benchmark seed --reset` CLI path requires an explicit
# `--yes-i-know-this-wipes-the-database` flag before reaching here; do
# not remove that guard.
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
get_user_model().objects.all().delete()
Group.objects.all().delete()
def _reset_postgresql() -> None:
tables = _reset_table_names()
with connection.cursor() as cursor:
cursor.execute(f"TRUNCATE TABLE {', '.join(tables)} RESTART IDENTITY CASCADE;")
_delete_all_users_and_groups()
def _reset_mariadb() -> None:
# MariaDB's TRUNCATE has no CASCADE clause and refuses to truncate a
# table referenced by a foreign key while checks are enabled, so
# checks are disabled for the duration of the reset.
tables = _reset_table_names()
with connection.cursor() as cursor:
cursor.execute("SET FOREIGN_KEY_CHECKS = 0;")
try:
for table in tables:
cursor.execute(f"TRUNCATE TABLE {table};")
finally:
cursor.execute("SET FOREIGN_KEY_CHECKS = 1;")
_delete_all_users_and_groups()
def _reset_sqlite() -> None:
# Plain DELETEs, the SQLite equivalent of the TRUNCATEs above. ORM deletes
# fire per-document signals and cascade collection, which is far too slow
# at the large tier and needs the cache backend to be reachable.
# constraint_checks_disabled() mirrors MariaDB's SET FOREIGN_KEY_CHECKS = 0.
tables = _reset_table_names()
with connection.constraint_checks_disabled(), connection.cursor() as cursor:
for table in tables:
cursor.execute(f"DELETE FROM {table};")
_delete_all_users_and_groups()
def reset_benchmark_data() -> None:
"""
Remove all previously-seeded benchmark data (documents, tags,
correspondents, document types, storage paths, guardian permission
rows, users, and groups) so a fresh `benchmark seed` run starts
from an empty slate. Dispatches per-backend because TRUNCATE syntax
and cascade behavior differ across the 3 supported databases.
"""
if connection.vendor == "postgresql":
_reset_postgresql()
elif connection.vendor == "mysql":
# MariaDB also reports vendor == "mysql" under Django's mysql backend.
_reset_mariadb()
else:
_reset_sqlite()
def capture_explain(queryset: QuerySet) -> str:
"""
Return the query plan for `queryset` using the current backend's
explain facility. PostgreSQL supports `EXPLAIN ANALYZE {sql}` (real
execution stats). MariaDB does NOT accept that syntax -- verified
against a real MariaDB 12.3 container: `EXPLAIN ANALYZE {sql}` raises a
1064 syntax error, while MariaDB's own `ANALYZE {sql}` form (no
`EXPLAIN` keyword) works and returns real per-row execution stats
(`r_rows`, `r_filtered`, etc. columns) -- this is MariaDB's
EXPLAIN-ANALYZE-equivalent, distinct from MySQL 8.0.18+'s
`EXPLAIN ANALYZE` syntax, which MariaDB does not implement. SQLite only
supports EXPLAIN QUERY PLAN (the chosen plan, not real timing/row
counts) -- that output is clearly labeled rather than silently looking
equivalent to the other two backends' output.
"""
sql, params = queryset.query.sql_with_params()
with connection.cursor() as cursor:
if connection.vendor == "postgresql":
cursor.execute(f"EXPLAIN ANALYZE {sql}", params)
return "\n".join(str(row[0]) for row in cursor.fetchall())
if connection.vendor == "mysql":
# MariaDB also reports vendor == "mysql" under Django's mysql
# backend. Unlike MySQL 8.0.18+, MariaDB has no `EXPLAIN
# ANALYZE` syntax -- its equivalent is `ANALYZE <statement>`.
cursor.execute(f"ANALYZE {sql}", params)
columns = [c[0] for c in cursor.description]
header = " | ".join(columns)
rows = "\n".join(
" | ".join(str(c) for c in row) for row in cursor.fetchall()
)
return f"{header}\n{rows}"
cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params)
rows = "\n".join(" | ".join(str(c) for c in row) for row in cursor.fetchall())
return f"(plan only -- no execution stats on SQLite)\n{rows}"
+88
View File
@@ -0,0 +1,88 @@
# src/paperless_benchmark/endpoints.py
from __future__ import annotations
import statistics
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from django.contrib.auth.models import User
from rest_framework.test import APIClient
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"
),
),
)
@dataclass(frozen=True, slots=True)
class EndpointTiming:
user_label: str
endpoint_name: str
query_count: int
min_ms: float
median_ms: float
max_ms: float
def _timed_requests(client: APIClient, url: str, n: int) -> list[float]:
times = []
for _ in range(n):
t0 = time.perf_counter()
resp = client.get(url)
t1 = time.perf_counter()
if resp.status_code != 200:
raise RuntimeError(
f"GET {url} -> {resp.status_code}: {resp.content[:300]!r}",
)
times.append(t1 - t0)
return times
def _query_count(client: APIClient, url: str) -> int:
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
resp = client.get(url)
if resp.status_code != 200:
raise RuntimeError(f"GET {url} -> {resp.status_code}: {resp.content[:300]!r}")
return len(ctx.captured_queries)
def run_endpoint_benchmarks(
*,
perf_target: User,
perf_admin: User,
repeat: int,
) -> list[EndpointTiming]:
from rest_framework.test import APIClient
results: list[EndpointTiming] = []
for user_label, user in (("target", perf_target), ("admin", perf_admin)):
client = APIClient()
client.force_authenticate(user=user)
for name, url in ENDPOINTS:
client.get(url) # warm-up request, not counted
qcount = _query_count(client, url)
times_ms = [t * 1000 for t in _timed_requests(client, url, repeat)]
results.append(
EndpointTiming(
user_label=user_label,
endpoint_name=name,
query_count=qcount,
min_ms=min(times_ms),
median_ms=statistics.median(times_ms),
max_ms=max(times_ms),
),
)
return results
+56
View File
@@ -0,0 +1,56 @@
# src/paperless_benchmark/harness.py
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Generic
from typing import TypeVar
from django.db import connection
from django.test.utils import CaptureQueriesContext
if TYPE_CHECKING:
from collections.abc import Callable
T = TypeVar("T")
@dataclass(frozen=True, slots=True)
class ProfileResult(Generic[T]):
best_seconds: float
all_seconds: tuple[float, ...]
query_count: int
result: T
def run_profile(fn: Callable[[], T], *, repeat: int = 5) -> ProfileResult[T]:
"""
Call `fn` `repeat` times, capturing wall-clock time for every call and
the SQL query count for the final call. Returns the best (minimum)
time across all repeats, since the first call(s) can be skewed by
connection warm-up or cold caches.
"""
if repeat < 1:
raise ValueError("repeat must be >= 1")
all_seconds: list[float] = []
result: T | None = None
query_count = 0
for i in range(repeat):
with CaptureQueriesContext(connection) as ctx:
start = time.perf_counter()
result = fn()
all_seconds.append(time.perf_counter() - start)
if i == repeat - 1:
query_count = len(ctx.captured_queries)
# Purely a type-narrowing aid for the type checker: the `repeat < 1`
# guard above already turns the one case that could leave `result`
# unset into a clear ValueError, so this is unreachable in practice.
assert result is not None
return ProfileResult(
best_seconds=min(all_seconds),
all_seconds=tuple(all_seconds),
query_count=query_count,
result=result,
)
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,222 @@
# src/paperless_benchmark/management/commands/benchmark.py
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
from django.core.management.base import CommandParser
class Command(BaseCommand):
help = "Seed, run, and profile paperless-ngx performance benchmarks."
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"action",
choices=["seed", "run", "profile", "list-scenarios"],
help="Which benchmark action to perform.",
)
parser.add_argument(
"scenario",
nargs="?",
default=None,
help="Scenario name (required for `profile`; see `list-scenarios`).",
)
parser.add_argument(
"--tier",
choices=["home", "medium", "large"],
default="medium",
help="Dataset scale tier for `seed` (default: medium).",
)
parser.add_argument(
"--reset",
action="store_true",
default=False,
help="For `seed`: truncate existing benchmark data first.",
)
parser.add_argument(
"--yes-i-know-this-wipes-the-database",
action="store_true",
default=False,
help=(
"Required alongside --reset: confirms you understand `seed "
"--reset` deletes ALL users, ALL groups, and ALL documents/"
"tags/correspondents/document types/storage paths in this "
"database, not just benchmark-created ones."
),
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="RNG seed for reproducible datasets (default: 42).",
)
parser.add_argument(
"--repeat",
type=int,
default=5,
help="Number of timed repetitions for `run`/`profile` (default: 5).",
)
parser.add_argument(
"--label",
default="baseline",
help="Free-text tag for a `run`, printed and recorded in history only.",
)
parser.add_argument(
"--explain",
action="store_true",
default=False,
help="For `profile`: also capture and print the query plan.",
)
def handle(self, *args: Any, **options: Any) -> None:
action = options["action"]
if action == "seed":
self._handle_seed(options)
elif action == "run":
self._handle_run(options)
elif action == "profile":
self._handle_profile(options)
else:
self._handle_list_scenarios()
def _handle_seed(self, options: dict[str, Any]) -> None:
from paperless_benchmark.db import reset_benchmark_data
from paperless_benchmark.seeding import seed_benchmark_dataset
if options["reset"]:
if not options["yes_i_know_this_wipes_the_database"]:
raise CommandError(
"--reset requires --yes-i-know-this-wipes-the-database. "
"This deletes ALL users, ALL groups, and ALL documents, "
"tags, correspondents, document types, and storage paths "
"in this database -- not just benchmark-created ones. "
"Only run this against a disposable benchmark database, "
"never a real install. Re-run with "
"--reset --yes-i-know-this-wipes-the-database to proceed.",
)
self.stdout.write("Resetting existing benchmark data...")
reset_benchmark_data()
data = seed_benchmark_dataset(options["tier"], seed=options["seed"])
self.stdout.write(
self.style.SUCCESS(
f"Seeded tier={options['tier']!r}: {data.documents} documents, "
f"{len(data.users)} users, {len(data.groups)} groups.",
),
)
def _handle_run(self, options: dict[str, Any]) -> None:
from django.contrib.auth import get_user_model
from django.db import connection
from documents.models import Document
from paperless_benchmark.endpoints import run_endpoint_benchmarks
from paperless_benchmark.results import append_history
user_model = get_user_model()
try:
perf_target = user_model.objects.get(username="perf_target")
perf_admin = user_model.objects.get(username="perf_admin")
except user_model.DoesNotExist as e:
raise CommandError(
"No benchmark dataset found. Run `manage.py benchmark seed` first.",
) from e
db_vendor = connection.vendor
document_count = Document.objects.count()
results = run_endpoint_benchmarks(
perf_target=perf_target,
perf_admin=perf_admin,
repeat=options["repeat"],
)
self.stdout.write(f"# label={options['label']} repeat={options['repeat']}")
self.stdout.write(
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:26s} {r.query_count:8d} "
f"{r.min_ms:9.1f} {r.median_ms:10.1f} {r.max_ms:9.1f}",
)
append_history(
{
"mode": "run",
"label": options["label"],
"user": r.user_label,
"endpoint": r.endpoint_name,
"query_count": r.query_count,
"min_ms": r.min_ms,
"median_ms": r.median_ms,
"max_ms": r.max_ms,
"db_vendor": db_vendor,
"document_count": document_count,
},
)
def _handle_profile(self, options: dict[str, Any]) -> None:
from django.contrib.auth import get_user_model
from django.db import connection
from documents.models import Document
from paperless_benchmark.db import capture_explain
from paperless_benchmark.harness import run_profile
from paperless_benchmark.results import append_history
from paperless_benchmark.scenarios import get as get_scenario
if not options["scenario"]:
raise CommandError(
"`profile` requires a scenario name; see `list-scenarios`.",
)
scenario = get_scenario(options["scenario"])
user_model = get_user_model()
try:
perf_target = user_model.objects.get(username="perf_target")
except user_model.DoesNotExist as e:
raise CommandError(
"No benchmark dataset found. Run `manage.py benchmark seed` first.",
) from e
profile = run_profile(
lambda: scenario.run(perf_target),
repeat=options["repeat"],
)
self.stdout.write(
f"{scenario.name}: best={profile.best_seconds:.4f}s "
f"queries={profile.query_count}",
)
if options["explain"]:
if scenario.queryset_for_explain is not None:
plan = capture_explain(scenario.queryset_for_explain(perf_target))
self.stdout.write(plan)
else:
self.stdout.write(
self.style.WARNING(
f"--explain was requested but scenario {scenario.name!r} "
"does not support it (no queryset_for_explain); skipping.",
),
)
append_history(
{
"mode": "profile",
"scenario": scenario.name,
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
"db_vendor": connection.vendor,
"document_count": Document.objects.count(),
},
)
def _handle_list_scenarios(self) -> None:
from paperless_benchmark.scenarios import all_scenarios
for scenario in all_scenarios():
self.stdout.write(f"{scenario.name}: {scenario.describe}")
+39
View File
@@ -0,0 +1,39 @@
# src/paperless_benchmark/results.py
from __future__ import annotations
import json
import subprocess
from datetime import UTC
from datetime import datetime
from pathlib import Path
from typing import Any
RESULTS_DIR = Path(__file__).resolve().parent.parent.parent / "benchmark_results"
def _current_git_ref() -> str:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() or "unknown"
def append_history(entry: dict[str, Any], *, code_ref: str | None = None) -> None:
"""
Append one line to benchmark_results/history.jsonl -- a local-only,
append-only, cross-session record of every `benchmark run`/`profile`
invocation. Unlike a single overwritten snapshot file, this survives
across sessions so a benchmarking effort picked back up days later has
a full timeline instead of only the most recent result.
"""
RESULTS_DIR.mkdir(exist_ok=True)
record = {
"timestamp": datetime.now(UTC).isoformat(),
"code_ref": code_ref or _current_git_ref(),
**entry,
}
with (RESULTS_DIR / "history.jsonl").open("a") as f:
f.write(json.dumps(record) + "\n")
+103
View File
@@ -0,0 +1,103 @@
# src/paperless_benchmark/scenarios.py
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Any
if TYPE_CHECKING:
from collections.abc import Callable
from django.contrib.auth.models import User
from django.db.models import QuerySet
@dataclass(frozen=True, slots=True)
class Scenario:
name: str
describe: str
run: Callable[[User], Any]
queryset_for_explain: Callable[[User], QuerySet] | None = None
_SCENARIOS: dict[str, Scenario] = {}
def register(scenario: Scenario) -> None:
_SCENARIOS[scenario.name] = scenario
def get(name: str) -> Scenario:
from django.core.management.base import CommandError
try:
return _SCENARIOS[name]
except KeyError:
available = ", ".join(sorted(_SCENARIOS)) or "(none registered)"
raise CommandError(
f"Unknown benchmark scenario {name!r}. Available: {available}",
) from None
def all_scenarios() -> tuple[Scenario, ...]:
return tuple(_SCENARIOS.values())
def _guardian_visibility_query_run(user: User) -> list[int]:
return list(
_guardian_visibility_query_queryset(user).values_list("id", flat=True),
)
def _guardian_visibility_query_queryset(user: User) -> QuerySet:
from documents.models import Document
from documents.permissions import restrict_queryset_to_visible
return restrict_queryset_to_visible(Document.objects.all(), user, "view_document")
register(
Scenario(
name="guardian_visibility_query",
describe=(
"Document-visibility queryset for a user with mixed owned/shared "
"documents -- exercises documents.permissions."
"restrict_queryset_to_visible's permitted_object_ids subquery."
),
run=_guardian_visibility_query_run,
queryset_for_explain=_guardian_visibility_query_queryset,
),
)
def _permitted_document_ids_run(user: User) -> list[int]:
from documents.models import Document
from documents.permissions import permitted_document_ids
return list(
Document.objects.filter(id__in=permitted_document_ids(user)).values_list(
"id",
flat=True,
),
)
def _permitted_document_ids_queryset(user: User) -> QuerySet:
from documents.models import Document
from documents.permissions import permitted_document_ids
return Document.objects.filter(id__in=permitted_document_ids(user))
register(
Scenario(
name="permitted_document_ids",
describe=(
"Document-visibility query built from documents.permissions."
"permitted_document_ids -- like guardian_visibility_query, but a "
"version is authorized by its root document."
),
run=_permitted_document_ids_run,
queryset_for_explain=_permitted_document_ids_queryset,
),
)
+489
View File
@@ -0,0 +1,489 @@
# src/paperless_benchmark/seeding.py
from __future__ import annotations
import datetime
import random
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Literal
if TYPE_CHECKING:
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
Tier = Literal["home", "medium", "large"]
CHUNK_SIZE = 5_000
MIME_TYPES = (
"application/pdf",
"image/png",
"image/jpeg",
"text/plain",
)
# Ownership split for documents, mirroring the shape used in the #11950
# perf-benchmark dataset (owned-by-target / owned-by-other / unowned).
OWNED_BY_TARGET_FRACTION = 0.60
OWNED_BY_OTHER_FRACTION = 0.30
# remainder (0.10) is unowned
# Of documents owned by "other" users, the fraction explicitly shared
# (view, or view+change) with perf_target via guardian permissions --
# this is what exercises the get_user_can_change() per-row N+1 that the
# `run` endpoint benchmarks measure.
SHARED_WITH_TARGET_FRACTION = 0.5
SHARED_WITH_CHANGE_FRACTION = 0.5
# Guardian permission-row ratios measured from a real install (discussion
# #13276): 1,414 user-perm rows / 27,232 group-perm rows over 12,000
# documents. Layered across ALL owned documents for the general user/group
# pool (not just perf_target's shares), so `profile` scenarios exercise a
# 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)
class _TierCounts:
documents: int
tags: int
correspondents: int
document_types: int
storage_paths: int
other_users: int
groups: int
tags_per_doc: tuple[int, int]
custom_fields: int = 0
TIERS: dict[Tier, _TierCounts] = {
"home": _TierCounts(
documents=500,
tags=20,
correspondents=10,
document_types=8,
storage_paths=5,
other_users=3,
groups=2,
tags_per_doc=(1, 3),
custom_fields=3,
),
"medium": _TierCounts(
documents=20_000,
tags=100,
correspondents=300,
document_types=50,
storage_paths=20,
other_users=10,
groups=5,
tags_per_doc=(2, 6),
custom_fields=10,
),
"large": _TierCounts(
documents=360_000,
tags=1_000,
correspondents=5_000,
document_types=300,
storage_paths=50,
other_users=25,
groups=10,
tags_per_doc=(3, 7),
custom_fields=25,
),
}
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) # noqa: T201
@dataclass(frozen=True, slots=True)
class SeededData:
"""
Summary of a completed seed run. `documents`/`tags`/`correspondents`/
`document_types`/`storage_paths` are counts, not the seeded ORM
instances: at the `large` tier (360,000 documents) holding every
instance in memory simultaneously is a real risk for zero benefit, since
no caller consumes anything but the counts. `perf_target`/`perf_admin`/
`users`/`groups` stay as real objects -- at most ~26 users/12 groups even
at `large` tier, and small enough to be useful to a future caller.
"""
perf_target: User
perf_admin: User
users: tuple[User, ...]
groups: tuple[Group, ...]
documents: int
tags: int
correspondents: int
document_types: int
storage_paths: int
def _grant_model_level_permissions(user: User) -> None:
"""
Grant perf_target Django model-level view/add/change permissions on
Document and Tag, on top of the per-object guardian grants seeding
creates elsewhere. DRF's PaperlessObjectPermissions checks model-level
permissions before guardian's object-level ones are ever consulted, so
without this perf_target gets a blanket 403 on /api/documents/ and
/api/tags/ regardless of which documents guardian says it can see.
"""
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from documents.models import Document
from documents.models import Tag
for model in (Document, Tag):
content_type = ContentType.objects.get_for_model(model)
codenames = [
f"{action}_{model._meta.model_name}" for action in ("view", "add", "change")
]
perms = Permission.objects.filter(
content_type=content_type,
codename__in=codenames,
)
user.user_permissions.add(*perms)
def _create_users_and_groups(
counts: _TierCounts,
) -> tuple[User, User, tuple[User, ...], tuple[Group, ...]]:
from django.contrib.auth.models import Group
from paperless_testing.factories import UserFactory
perf_target = UserFactory.create(username="perf_target")
_grant_model_level_permissions(perf_target)
perf_admin = UserFactory.create(username="perf_admin", superuser=True)
other_users = tuple(UserFactory.create_batch(counts.other_users))
groups = tuple(
Group.objects.create(name=f"benchmark_group_{i}") for i in range(counts.groups)
)
log(
f"Created users: 1 target, 1 superuser, {len(other_users)} other, "
f"{len(groups)} groups.",
)
return perf_target, perf_admin, other_users, groups
def _create_lookup_tables(counts: _TierCounts):
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
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(
Correspondent.objects.bulk_create(
CorrespondentFactory.build_batch(counts.correspondents),
),
)
document_types = tuple(
DocumentType.objects.bulk_create(
DocumentTypeFactory.build_batch(counts.document_types),
),
)
storage_paths = tuple(
StoragePath.objects.bulk_create(
StoragePathFactory.build_batch(counts.storage_paths),
),
)
log(
f"Created {len(tags)} tags, {len(correspondents)} correspondents, "
f"{len(document_types)} document types, {len(storage_paths)} storage paths.",
)
return tags, correspondents, document_types, storage_paths
def _assign_owner(rng: random.Random, perf_target: User, other_users: tuple[User, ...]):
roll = rng.random()
if roll < OWNED_BY_TARGET_FRACTION:
return perf_target, "target"
if roll < OWNED_BY_TARGET_FRACTION + OWNED_BY_OTHER_FRACTION:
return rng.choice(other_users), "other"
return None, "unowned"
def _seed_documents(
rng: random.Random,
counts: _TierCounts,
tags,
correspondents,
document_types,
storage_paths,
perf_target,
other_users,
) -> tuple[int, list[int]]:
"""
Bulk-create `counts.documents` documents in chunks. Returns the total
document count plus a lightweight list of pks for documents that ended
up with an owner (target or other) -- that's all
`_grant_general_permissions` needs to sample from, so full `Document`
instances aren't accumulated across chunks (a real memory concern at the
`large` tier's 360,000 documents).
"""
from django.contrib.auth.models import Permission
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 paperless_testing.factories import DocumentFactory
tag_ids = [t.pk for t in tags]
correspondent_ids = [c.pk for c in correspondents]
document_type_ids = [d.pk for d in document_types]
storage_path_ids = [s.pk for s in storage_paths]
doc_content_type = ContentType.objects.get_for_model(Document)
view_perm = Permission.objects.get(
codename="view_document",
content_type=doc_content_type,
)
change_perm = Permission.objects.get(
codename="change_document",
content_type=doc_content_type,
)
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
while remaining > 0:
chunk_n = min(CHUNK_SIZE, remaining)
remaining -= chunk_n
batch = []
owner_buckets = []
for _ in range(chunk_n):
doc = DocumentFactory.build(
mime_type=rng.choice(MIME_TYPES),
page_count=rng.randint(1, 30),
correspondent_id=(
rng.choice(correspondent_ids)
if correspondent_ids and rng.random() < 0.8
else None
),
document_type_id=(
rng.choice(document_type_ids)
if document_type_ids and rng.random() < 0.6
else None
),
storage_path_id=(
rng.choice(storage_path_ids)
if storage_path_ids and rng.random() < 0.8
else None
),
# Document.created is a plain DateField (default: today).
# Leaving it unset would give every seeded document the same
# date, collapsing Document's ("-created",) ordering index
# into a single-valued sort key -- spread it over a
# realistic multi-year window instead.
created=datetime.date.today()
- datetime.timedelta(days=rng.randint(0, 365 * 3)),
)
owner, bucket = _assign_owner(rng, perf_target, other_users)
doc.owner_id = owner.pk if owner else None
batch.append(doc)
owner_buckets.append(bucket)
created = Document.objects.bulk_create(batch, batch_size=CHUNK_SIZE)
through_rows = []
for doc in created:
k = rng.randint(*counts.tags_per_doc)
for tag_id in rng.sample(tag_ids, min(k, len(tag_ids))):
through_rows.append(through_model(document_id=doc.pk, tag_id=tag_id))
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":
continue
owned_document_pks.append(doc.pk)
if bucket != "other":
continue
if rng.random() >= SHARED_WITH_TARGET_FRACTION:
continue
perm_rows.append(
UserObjectPermission(
permission=view_perm,
content_type=doc_content_type,
object_pk=str(doc.pk),
user=perf_target,
),
)
if rng.random() < SHARED_WITH_CHANGE_FRACTION:
perm_rows.append(
UserObjectPermission(
permission=change_perm,
content_type=doc_content_type,
object_pk=str(doc.pk),
user=perf_target,
),
)
if perm_rows:
UserObjectPermission.objects.bulk_create(perm_rows, batch_size=CHUNK_SIZE)
document_count += len(created)
log(f" {document_count}/{counts.documents} documents seeded")
return document_count, owned_document_pks
def _grant_general_permissions(
rng: random.Random,
owned_document_pks: list[int],
users,
groups,
) -> None:
"""
Layer realistic (issue #13276-derived) guardian permission-row ratios
across owned documents for the general user/group pool, so `profile`
scenarios exercise the same permission-join shape regardless of which
user they check visibility for (not just perf_target).
"""
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
from documents.models import Document
if not owned_document_pks or not users:
return
doc_content_type = ContentType.objects.get_for_model(Document)
view_perm = Permission.objects.get(
codename="view_document",
content_type=doc_content_type,
)
n_user_perms = round(len(owned_document_pks) * USER_PERM_ROWS_PER_DOC)
n_group_perms = (
round(len(owned_document_pks) * GROUP_PERM_ROWS_PER_DOC) if groups else 0
)
user_rows = [
UserObjectPermission(
permission=view_perm,
content_type=doc_content_type,
object_pk=str(rng.choice(owned_document_pks)),
user=rng.choice(users),
)
for _ in range(n_user_perms)
]
if user_rows:
UserObjectPermission.objects.bulk_create(
user_rows,
batch_size=CHUNK_SIZE,
ignore_conflicts=True,
)
group_rows = [
GroupObjectPermission(
permission=view_perm,
content_type=doc_content_type,
object_pk=str(rng.choice(owned_document_pks)),
group=rng.choice(groups),
)
for _ in range(n_group_perms)
]
if group_rows:
GroupObjectPermission.objects.bulk_create(
group_rows,
batch_size=CHUNK_SIZE,
ignore_conflicts=True,
)
log(f" Granted {len(user_rows)} user perms, {len(group_rows)} group perms.")
def seed_benchmark_dataset(tier: Tier, *, seed: int = 42) -> SeededData:
"""
Build a benchmark dataset at the given scale tier: a named perf_target
(mixed owned/shared documents) and perf_admin (superuser) for endpoint
benchmarking, plus a general user/group pool with realistic guardian
permission-row ratios for profile scenarios.
"""
counts = TIERS[tier]
rng = random.Random(seed)
log(f"Seeding tier={tier!r}")
perf_target, perf_admin, other_users, groups = _create_users_and_groups(counts)
tags, correspondents, document_types, storage_paths = _create_lookup_tables(counts)
document_count, owned_document_pks = _seed_documents(
rng,
counts,
tags,
correspondents,
document_types,
storage_paths,
perf_target,
other_users,
)
all_users = (perf_target, *other_users)
_grant_general_permissions(rng, owned_document_pks, all_users, groups)
log(f"Done. {document_count} documents seeded for tier={tier!r}.")
return SeededData(
perf_target=perf_target,
perf_admin=perf_admin,
users=all_users,
groups=groups,
documents=document_count,
tags=len(tags),
correspondents=len(correspondents),
document_types=len(document_types),
storage_paths=len(storage_paths),
)