Compare commits

...
Author SHA1 Message Date
stumpylog dfdc8aa3bb Refactor: extract QuerySetStream, shared by _StreamedDocuments and _DocumentViewerStream 2026-07-30 08:42:05 -07:00
stumpylog a9161e7d84 Test: consolidate TestStreamedDocuments into one test 2026-07-30 08:13:43 -07:00
stumpylogandClaude Sonnet 5 42208ec70c Perf: stream update_llm_index()'s document queryset instead of loading it whole
update_llm_index() built its documents queryset with
prefetch_related("tags", "notes", "custom_fields__field") and iterated
it directly -- no .iterator(), so a full rebuild pulled every Document
(including content, which can be megabytes each) plus every prefetch
cache into memory simultaneously.

Add _StreamedDocuments, a thin QuerySet wrapper matching the shape of
documents/search/_backend.py's _DocumentViewerStream: iterates via
.iterator(chunk_size=1000) so Django streams from a server-side cursor
(and, since 4.1, still runs prefetch_related per batch instead of all at
once), while __len__ still supports a real progress-bar total. Used for
both loop sites (the full rebuild and the modified-time-scoped update).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:28:52 -07:00
4 changed files with 95 additions and 13 deletions
+6 -11
View File
@@ -39,6 +39,7 @@ from documents.search._tokenizer import ascii_fold
from documents.search._tokenizer import autocomplete_tokens
from documents.search._tokenizer import register_tokenizers
from documents.utils import IterWrapper
from documents.utils import QuerySetStream
from documents.utils import identity
if TYPE_CHECKING:
@@ -1035,14 +1036,15 @@ _EMPTY_VIEWER_GRANT: Final[ViewerGrant] = ViewerGrant(
)
class _DocumentViewerStream:
class _DocumentViewerStream(QuerySetStream["Document"]):
"""Yield document permission data while batch-loading grants.
Viewer permissions are fetched in batches (see
``_bulk_get_viewer_permissions``), but documents are yielded individually so a
progress bar wrapped around this stream advances per document rather than
jumping a whole chunk at a time. ``__len__`` lets the progress helper still
discover the total (it inspects ``QuerySet``/``Sized``).
jumping a whole chunk at a time. ``__len__`` (inherited from
``QuerySetStream``) lets the progress helper still discover the total (it
inspects ``QuerySet``/``Sized``).
The viewer and group ids travel with each document in the yielded pair
rather than through a separate mutable attribute, so the pairing survives
@@ -1051,18 +1053,11 @@ class _DocumentViewerStream:
generator in lock-step.
"""
def __init__(self, documents: QuerySet[Document], *, chunk_size: int) -> None:
self._documents = documents
self._chunk_size = chunk_size
def __len__(self) -> int:
return self._documents.count()
def __iter__(self) -> Iterator[tuple[Document, ViewerGrant]]:
# iterator(chunk_size=…) streams from a server-side cursor instead of
# materialising the whole queryset in memory; since Django 4.1 it still
# honours prefetch_related, running the prefetches one batch at a time.
documents = self._documents.iterator(chunk_size=self._chunk_size)
documents = self._queryset.iterator(chunk_size=self._chunk_size)
for chunk in chunked(documents, self._chunk_size):
grants_by_pk = _bulk_get_viewer_permissions([doc.pk for doc in chunk])
for doc in chunk:
+35
View File
@@ -0,0 +1,35 @@
import pytest_mock
from documents.utils import QuerySetStream
class TestQuerySetStream:
def test_len_and_iter_delegate_to_streaming_queryset_methods(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- A mock queryset
WHEN:
- A QuerySetStream wrapping it is measured and iterated
THEN:
- len() uses count() (not a materializing len()), and iteration
uses .iterator(chunk_size=...) (not plain iteration, which
would materialize the whole queryset, plus any prefetch
caches, into Django's own result cache at once)
"""
mock_queryset = mocker.MagicMock()
mock_queryset.count.return_value = 42
mock_queryset.iterator.return_value = iter(["row-1", "row-2"])
streamed = QuerySetStream(mock_queryset, chunk_size=1000)
assert len(streamed) == 42
assert list(streamed) == ["row-1", "row-2"]
# count.call_count isn't asserted exactly: list()'s own size-hint
# optimization calls len(streamed) again internally, on top of the
# explicit len() call above -- both legitimately delegate to
# count(), so only the delegation itself (not the call count) is
# the thing being verified here.
mock_queryset.count.assert_called_with()
mock_queryset.iterator.assert_called_once_with(chunk_size=1000)
+42
View File
@@ -3,16 +3,24 @@ import logging
import shutil
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from os import utime
from pathlib import Path
from subprocess import CompletedProcess
from subprocess import run
from typing import TYPE_CHECKING
from typing import Generic
from typing import TypeVar
from django.conf import settings
from PIL import Image
if TYPE_CHECKING:
from django.db.models import Model
from django.db.models import QuerySet
_T = TypeVar("_T")
_M = TypeVar("_M", bound="Model")
# A function that wraps an iterable — typically used to inject a progress bar.
IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
@@ -23,6 +31,40 @@ def identity(iterable: Iterable[_T]) -> Iterable[_T]:
return iterable
class QuerySetStream(Generic[_M]):
"""Stream a QuerySet via .iterator(chunk_size=...) instead of
materializing it (plus any prefetch caches) all at once, while still
supporting len() via count() so a progress bar wrapped around this
(e.g. via IterWrapper) shows a real total instead of falling back to
indeterminate.
Plain QuerySet iteration (``for row in queryset:``) is not lazy: Django
fetches every matching row in one query and caches the fully-hydrated
result in the queryset's own ``_result_cache`` before yielding the
first item -- wrapping that in a progress bar or any other iterable
adapter doesn't change this, since none of them alter how the
underlying queryset produces items. ``.iterator(chunk_size=...)`` is
the specific Django API that bypasses ``_result_cache`` and streams
from a server-side cursor instead, discarding each chunk once consumed
(and, since Django 4.1, still honours ``prefetch_related``, running the
prefetches one batch at a time rather than for the whole queryset).
Subclass to layer additional per-batch work on top (see
``documents.search._backend._DocumentViewerStream``) by overriding
``__iter__`` -- ``__len__`` and the constructor are inherited for free.
"""
def __init__(self, queryset: "QuerySet[_M]", *, chunk_size: int) -> None:
self._queryset = queryset
self._chunk_size = chunk_size
def __len__(self) -> int:
return self._queryset.count()
def __iter__(self) -> Iterator[_M]:
return iter(self._queryset.iterator(chunk_size=self._chunk_size))
def _coerce_to_path(
source: Path | str,
dest: Path | str,
+12 -2
View File
@@ -14,6 +14,7 @@ from filelock import Timeout
from documents.models import Document
from documents.models import PaperlessTask
from documents.utils import IterWrapper
from documents.utils import QuerySetStream
from documents.utils import identity
from paperless.config import AIConfig
from paperless_ai.db import db_connection_released
@@ -32,6 +33,11 @@ logger = logging.getLogger("paperless_ai.indexing")
RAG_NUM_OUTPUT = 512
RAG_CHUNK_OVERLAP = 200
# update_llm_index(): row count per .iterator() batch when streaming
# documents for a rebuild/update via QuerySetStream, matching
# _DocumentViewerStream's chunk size in documents/search/_backend.py.
_INDEX_STREAM_CHUNK_SIZE = 1000
def queue_llm_index_update_if_needed(*, rebuild: bool, reason: str) -> bool:
# NOTE: The check-then-enqueue sequence below is non-atomic (TOCTOU): two
@@ -385,7 +391,9 @@ def update_llm_index(
if rebuild or not store.table_exists():
logger.info("Rebuilding LLM index.")
store.drop_table()
for document in iter_wrapper(documents):
for document in iter_wrapper(
QuerySetStream(documents, chunk_size=_INDEX_STREAM_CHUNK_SIZE),
):
nodes = build_document_node(document, chunk_size=chunk_size)
_embed_nodes(nodes, embed_model)
store.add(nodes)
@@ -398,7 +406,9 @@ def update_llm_index(
)
existing = store.get_modified_times()
changed = 0
for document in iter_wrapper(scoped_documents):
for document in iter_wrapper(
QuerySetStream(scoped_documents, chunk_size=_INDEX_STREAM_CHUNK_SIZE),
):
doc_id = str(document.id)
if existing.get(doc_id) == document.modified.isoformat():
continue