Compare commits

..
Author SHA1 Message Date
Trenton HandClaude Sonnet 5.5 4a305aad7e Performance: read shared_by grants once instead of probing per document
The shared_by__id filter counted a correlated guardian subquery per owned document. No guardian index leads with object_pk, so MariaDB re-scanned the grant table for every row (a 360k document install did not finish in 442 s), and Postgres took 21 s for an owner sharing 196k documents.

Match against each grant table once, as an uncorrelated DISTINCT set of integer keys, with a plain OR of two IN conditions. This also drops the aggregate and HAVING.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 08:38:03 -07:00
11 changed files with 80 additions and 259 deletions

No files matched your search

+5 -5
View File
@@ -10334,18 +10334,18 @@
<context context-type="linenumber">294,295</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">328</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">331</context>
<context context-type="linenumber">329</context>
</context-group>
</trans-unit>
<trans-unit id="4512084577073831437" datatype="html">
@@ -1,3 +1,10 @@
.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 text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr>
<tr>
<td i18n>Original file size</td>
@@ -465,7 +465,7 @@
@switch (archiveContentRenderType) {
@case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container" tabindex="-1">
<div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer
[src]="pdfSource()"
[sourceRevision]="previewRevision()"
@@ -477,7 +477,7 @@
(loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
@@ -12,22 +12,6 @@
width: 100%;
height: 100%;
}
&:focus {
outline: none;
}
.preview-controls {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
&:hover .preview-controls,
&:focus-within .preview-controls {
opacity: 1;
pointer-events: auto;
}
}
.btn-group .dropdown-toggle-split {
@@ -208,7 +208,7 @@
i18n>Correspondent</th>
}
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th class="cursor-pointer ps-5"
<th class="cursor-pointer"
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%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)">
<td width="30%">
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
<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>
<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>
<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,10 +87,6 @@ $paperless-card-breakpoints: (
}
}
.table .ps-5 {
padding-left: 2.2rem !important;
}
a {
cursor: pointer;
}
+21 -28
View File
@@ -15,7 +15,6 @@ import dateparser
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count
from django.db.models import Exists
from django.db.models import IntegerField
@@ -208,36 +207,30 @@ class EffectiveContentFilter(Filter):
@extend_schema_field(serializers.BooleanField)
class SharedByUser(Filter):
def filter(self, qs, value):
if value is None:
return qs
ctype = ContentType.objects.get_for_model(self.model)
UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model()
# see https://github.com/paperless-ngx/paperless-ngx/issues/5392, we limit subqueries
# to 1 because Postgres doesn't like returning > 1 row, but all we care about is > 0
return (
qs.filter(
owner_id=value,
)
.annotate(
num_shared_users=Count(
UserObjectPermission.objects.filter(
content_type=ctype,
object_pk=Cast(OuterRef("pk"), CharField()),
).values("user_id")[:1],
),
)
.annotate(
num_shared_groups=Count(
GroupObjectPermission.objects.filter(
content_type=ctype,
object_pk=Cast(OuterRef("pk"), CharField()),
).values("group_id")[:1],
),
)
.filter(
Q(num_shared_users__gt=0) | Q(num_shared_groups__gt=0),
)
if value is not None
else qs
# Guardian stores object_pk as a string. Each grant table is read once,
# as an uncorrelated set of integer keys, because probing it per
# document by string key has no usable index (none leads with
# object_pk) and MariaDB re-scans the table for every owned document.
# DISTINCT keeps Postgres hashing each set once: without it a large
# group grant table exceeds work_mem and is re-scanned for every row.
shared_with_users = (
UserObjectPermission.objects.filter(content_type=ctype)
.values(shared_pk=Cast("object_pk", IntegerField()))
.distinct()
)
shared_with_groups = (
GroupObjectPermission.objects.filter(content_type=ctype)
.values(shared_pk=Cast("object_pk", IntegerField()))
.distinct()
)
return qs.filter(owner_id=value).filter(
Q(pk__in=shared_with_users) | Q(pk__in=shared_with_groups),
)
+39
View File
@@ -1480,6 +1480,45 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
[u1_doc1.id],
)
def test_shared_by_filter_matches_user_and_group_grants_once(self) -> None:
"""
GIVEN:
- Documents owned by a user: one shared with a user, one shared with
a group, one shared with several users and a group, one unshared
- A shared document owned by someone else
WHEN:
- Documents are filtered by shared_by__id for the owner
THEN:
- Only the owner's shared documents are returned, each exactly once
"""
owner = UserFactory(username="owner")
other = UserFactory(username="other")
reader1 = UserFactory(username="reader1")
reader2 = UserFactory(username="reader2")
group = Group.objects.create(name="readers")
grant_all_global(owner)
shared_with_user = DocumentFactory.create(owner=owner)
shared_with_group = DocumentFactory.create(owner=owner)
shared_widely = DocumentFactory.create(owner=owner)
DocumentFactory.create(owner=owner)
others_shared = DocumentFactory.create(owner=other)
grant_object(reader1, shared_with_user, "view_document")
grant_object(group, shared_with_group, "view_document")
for target in (reader1, reader2, group):
grant_object(target, shared_widely, "view_document")
grant_object(reader1, others_shared, "view_document")
self.client.force_authenticate(owner)
response = self.client.get(f"/api/documents/?shared_by__id={owner.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
self.assertCountEqual(
[result["id"] for result in response.data["results"]],
[shared_with_user.id, shared_with_group.id, shared_widely.id],
)
def test_document_owned_and_group_shared_not_duplicated_when_filtering_by_tags(
self,
) -> None:
@@ -1,93 +0,0 @@
import sqlite3
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch
import pytest
from llama_index.core.schema import TextNode
from paperless_ai.tables import DocumentChunksTable
from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DEFAULT_TABLE_NAME
from paperless_ai.vector_store import VEC0_CHUNK_SIZE
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
DIM = 16
def make_node(node_id: str, document_id: int) -> TextNode:
node = TextNode(
id_=node_id,
text="some text",
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
)
node.relationships = {}
node.embedding = [document_id + i / 100 for i in range(DIM)]
return node
def vec_table_sql(db_path: Path) -> str:
conn = sqlite3.connect(db_path)
try:
return conn.execute(
"SELECT sql FROM sqlite_master WHERE name = ?",
(DEFAULT_TABLE_NAME,),
).fetchone()[0]
finally:
conn.close()
def create_legacy_vec_table(conn: sqlite3.Connection, dim: int) -> None:
"""The table definition from before chunk_size was set explicitly."""
conn.execute(
f"CREATE VIRTUAL TABLE {DEFAULT_TABLE_NAME} USING vec0("
"id TEXT PRIMARY KEY, document_id INTEGER, +node_content TEXT, "
f"embedding float[{dim}] distance_metric=cosine)",
)
@pytest.fixture
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
yield store
class TestVecChunkSize:
def test_new_table_uses_configured_chunk_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN an empty store
WHEN the first document is indexed and the vec0 table is created
THEN the table definition carries the configured chunk size
"""
store.upsert_document(1, [make_node("1-a", 1)])
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
def test_compact_moves_legacy_table_to_configured_chunk_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN an index whose vec0 table was created with the default chunk size
WHEN the index is compacted
THEN the rebuilt table uses the configured chunk size
AND no indexed chunks are lost
"""
with patch.object(
PaperlessSqliteVecVectorStore,
"_create_vec_table",
staticmethod(create_legacy_vec_table),
):
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
store.upsert_document(2, [make_node("2-a", 2)])
assert "chunk_size" not in vec_table_sql(tmp_path / DB_FILENAME)
store.compact(force=True)
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
assert DocumentChunksTable.count(store.client) == 3
@@ -1,89 +0,0 @@
import sqlite3
from collections.abc import Generator
from pathlib import Path
import pytest
from llama_index.core.schema import TextNode
from paperless_ai.tables import DocumentChunksTable
from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DB_PAGE_SIZE
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
DIM = 16
LEGACY_PAGE_SIZE = 4096
def make_node(node_id: str, document_id: int) -> TextNode:
node = TextNode(
id_=node_id,
text="some text",
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
)
node.relationships = {}
node.embedding = [document_id + i / 100 for i in range(DIM)]
return node
def page_size_of(db_path: Path) -> int:
conn = sqlite3.connect(db_path)
try:
return conn.execute("PRAGMA page_size").fetchone()[0]
finally:
conn.close()
@pytest.fixture
def legacy_page_size_db(tmp_path: Path) -> Path:
"""An existing index file created before the page size was set."""
db_path = tmp_path / DB_FILENAME
conn = sqlite3.connect(db_path)
conn.execute(f"PRAGMA page_size={LEGACY_PAGE_SIZE}")
conn.execute("CREATE TABLE _seed(x)")
conn.execute("DROP TABLE _seed")
conn.close()
assert page_size_of(db_path) == LEGACY_PAGE_SIZE
return db_path
@pytest.fixture
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
yield store
class TestIndexPageSize:
def test_new_index_file_uses_configured_page_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN no existing index file
WHEN a store is opened and a document is indexed
THEN the database file uses the configured page size
"""
store.upsert_document(1, [make_node("1-a", 1)])
assert page_size_of(tmp_path / DB_FILENAME) == DB_PAGE_SIZE
def test_compact_moves_legacy_file_to_configured_page_size(
self,
legacy_page_size_db: Path,
) -> None:
"""
GIVEN an existing index file created with a different page size
WHEN the index is compacted
THEN the rebuilt file uses the configured page size
AND no indexed chunks are lost
"""
with PaperlessSqliteVecVectorStore(
uri=str(legacy_page_size_db.parent),
) as store:
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
assert page_size_of(legacy_page_size_db) == LEGACY_PAGE_SIZE
store.compact(force=True)
assert page_size_of(legacy_page_size_db) == DB_PAGE_SIZE
assert DocumentChunksTable.count(store.client) == 2
+1 -17
View File
@@ -47,20 +47,6 @@ SCHEMA_VERSION = 2
# a rebuild copies the live rows into a fresh table.
COMPACT_BLOAT_RATIO = 2.0
# Rows per vec0 vector chunk (vec0's default is 1024, which makes each chunk a
# multi-megabyte blob). On spinning disks, inserts and re-indexing were dozens
# of times faster with small chunks, at no cost in query speed or file size.
# Only applies to tables created from here on; an existing index picks it up
# the next time it is rebuilt (compact or rebuild).
VEC0_CHUNK_SIZE = 32
# SQLite page size for the index file, in bytes. With the small vec0 chunks above,
# 64 KiB pages made cold scans about 35% faster and warm ones about 45% faster,
# at the cost of a larger write-ahead log (up to about 70 MB). Only takes effect
# on a newly created file, so like the chunk size it reaches an existing index
# when it is rebuilt.
DB_PAGE_SIZE = 65536
# Number of rows fetched/copied per batch whenever this module streams rows
# instead of materializing them all at once, keeping memory bounded regardless
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
@@ -236,7 +222,6 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
conn.enable_load_extension(True) # noqa: FBT003
sqlite_vec.load(conn)
conn.enable_load_extension(False) # noqa: FBT003
conn.execute(f"PRAGMA page_size={DB_PAGE_SIZE}")
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
IndexMetaTable.create(conn)
@@ -344,8 +329,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
+ " +node_content TEXT,"
+ " embedding float["
+ str(int(dim))
+ "] distance_metric=cosine, chunk_size="
+ str(VEC0_CHUNK_SIZE)
+ "] distance_metric=cosine"
+ ")",
)