Compare commits

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

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

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

No files matched your search

+5 -5
View File
@@ -10334,18 +10334,18 @@
<context context-type="linenumber">294,295</context> <context context-type="linenumber">294,295</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3420321797707163677" datatype="html"> <trans-unit id="2179847500064178686" datatype="html">
<source>Preview document</source> <source>Edit document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">328</context> <context context-type="linenumber">328</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2179847500064178686" datatype="html"> <trans-unit id="3420321797707163677" datatype="html">
<source>Edit document</source> <source>Preview document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4512084577073831437" datatype="html"> <trans-unit id="4512084577073831437" datatype="html">
@@ -1,3 +1,10 @@
.custom-fields-dropdown { .custom-fields-dropdown {
min-width: 300px; min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -245px !important;
}
}
} }
@@ -308,7 +308,7 @@
</tr> </tr>
<tr class="align-middle"> <tr class="align-middle">
<td i18n>Original SHA256 checksum</td> <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>
<tr> <tr>
<td i18n>Original file size</td> <td i18n>Original file size</td>
@@ -465,7 +465,7 @@
@switch (archiveContentRenderType) { @switch (archiveContentRenderType) {
@case (ContentRenderType.PDF) { @case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) { @if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container" tabindex="-1"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pngx-pdf-viewer
[src]="pdfSource()" [src]="pdfSource()"
[sourceRevision]="previewRevision()" [sourceRevision]="previewRevision()"
@@ -477,7 +477,7 @@
(loadError)="onError($event)" (loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)"> (afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </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()) { @if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap"> <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 /> <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%; width: 100%;
height: 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 { .btn-group .dropdown-toggle-split {
@@ -208,7 +208,7 @@
i18n>Correspondent</th> i18n>Correspondent</th>
} }
@if (activeDisplayFields.includes(DisplayField.TITLE)) { @if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th class="cursor-pointer ps-5" <th class="cursor-pointer"
pngxSortable="title" pngxSortable="title"
title="Sort by title" i18n-title title="Sort by title" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
@@ -322,13 +322,13 @@
</td> </td>
} }
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) { @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)) { @if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()"> <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> <i-bs name="eye"></i-bs>
</pngx-preview-popup> </pngx-preview-popup>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
</div> </div>
} }
@if (activeDisplayFields.includes(DisplayField.TAGS)) { @if (activeDisplayFields.includes(DisplayField.TAGS)) {
@@ -87,10 +87,6 @@ $paperless-card-breakpoints: (
} }
} }
.table .ps-5 {
padding-left: 2.2rem !important;
}
a { a {
cursor: pointer; cursor: pointer;
} }
+11 -4
View File
@@ -291,12 +291,19 @@ def get_permission_grantees(
"permission__codename__in": list(action_by_codename), "permission__codename__in": list(action_by_codename),
"object_pk__in": [str(pk) for pk in pks], "object_pk__in": [str(pk) for pk in pks],
} }
for perm_model, identity_field, key in ( for perm_model, identity_model, identity_field, key in (
(UserObjectPermission, "user_id", "users"), (UserObjectPermission, User, "user_id", "users"),
(GroupObjectPermission, "group_id", "groups"), (GroupObjectPermission, Group, "group_id", "groups"),
): ):
# Every grant belongs to an existing user or group, so restricting to
# them changes no result. It lets MariaDB use guardian's
# (identity, content_type, object_pk) index; the content type alone
# matches every grant of the model and is scanned in full.
rows = ( rows = (
perm_model.objects.filter(**grant_filter) perm_model.objects.filter(
**{f"{identity_field}__in": identity_model.objects.values("pk")},
**grant_filter,
)
.values_list("object_pk", "permission__codename", identity_field) .values_list("object_pk", "permission__codename", identity_field)
.order_by(identity_field) .order_by(identity_field)
) )
+15 -3
View File
@@ -397,10 +397,14 @@ class OwnedObjectSerializer(
object_pks = list(obj.pk for obj in objects) object_pks = list(obj.pk for obj in objects)
pk_type = type(first_obj.pk) pk_type = type(first_obj.pk)
def get_pks_for_permission_type(model): def get_pks_for_permission_type(model, identity_field, identity_model):
# Restricting to existing users/groups changes no result but lets
# MariaDB use guardian's (identity, content_type, object_pk) index
# instead of scanning every grant of the model.
return map( return map(
pk_type, # coerce the pk to be the same type of the provided objects pk_type, # coerce the pk to be the same type of the provided objects
model.objects.filter( model.objects.filter(
**{f"{identity_field}__in": identity_model.objects.values("pk")},
content_type=ctype, content_type=ctype,
object_pk__in=object_pks, object_pk__in=object_pks,
) )
@@ -410,8 +414,16 @@ class OwnedObjectSerializer(
UserObjectPermission = get_user_obj_perms_model() UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model() GroupObjectPermission = get_group_obj_perms_model()
user_permission_pks = get_pks_for_permission_type(UserObjectPermission) user_permission_pks = get_pks_for_permission_type(
group_permission_pks = get_pks_for_permission_type(GroupObjectPermission) UserObjectPermission,
"user_id",
User,
)
group_permission_pks = get_pks_for_permission_type(
GroupObjectPermission,
"group_id",
Group,
)
return set(user_permission_pks) | set(group_permission_pks) return set(user_permission_pks) | set(group_permission_pks)
@@ -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. # a rebuild copies the live rows into a fresh table.
COMPACT_BLOAT_RATIO = 2.0 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 # Number of rows fetched/copied per batch whenever this module streams rows
# instead of materializing them all at once, keeping memory bounded regardless # 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 # 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 conn.enable_load_extension(True) # noqa: FBT003
sqlite_vec.load(conn) sqlite_vec.load(conn)
conn.enable_load_extension(False) # noqa: FBT003 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 journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA synchronous=NORMAL")
IndexMetaTable.create(conn) IndexMetaTable.create(conn)
@@ -344,8 +329,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
+ " +node_content TEXT," + " +node_content TEXT,"
+ " embedding float[" + " embedding float["
+ str(int(dim)) + str(int(dim))
+ "] distance_metric=cosine, chunk_size=" + "] distance_metric=cosine"
+ str(VEC0_CHUNK_SIZE)
+ ")", + ")",
) )