mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-19 08:07:58 +00:00
Refactor: extract QuerySetStream, shared by search and LLM indexing (#13431)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user