Compare commits

...
Author SHA1 Message Date
stumpylogandClaude Sonnet 5 8de1d18762 Fix: prevent overlapping mail-account processing runs
process_mail_accounts had no guard against a scheduled run still being
in progress when the next one fires (e.g. a large attachment batch
taking longer than the check interval). ProcessedMail dedup only
records a message once handling finishes, so an overlapping run could
still pick up the same not-yet-recorded message. Skip a run outright
if another MAIL_FETCH task is already PENDING/STARTED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 17:00:04 -07:00
GitHub Actions b989b74140 Auto translate strings 2026-09-08 15:57:05 +00:00
shamoon 5194f47291 Performance: ensure version-aware content filters on querysets (#13792) 2026-09-08 15:55:45 +00:00
GitHub Actions 714885d7a5 Auto translate strings 2026-09-08 15:32:41 +00:00
Trenton H 73e777a48c Fix: skip nested TagSerializer construction when a tag has no children (#14039)
TagSerializer.get_children() built a full nested TagSerializer(many=True)
for every tag, even when it had zero children, likely the common case for
most tags and maybe even most installs. Constructing a DRF ModelSerializer isn't
free (field introspection, deepcopy of declared fields, i18n lookups
all re-run per instantiation), so this scaled GET /api/tags/ linearly
with tag count in pure Python overhead, unrelated to SQL query count.
2026-09-08 15:30:58 +00:00
9 changed files with 282 additions and 76 deletions
+7 -17
View File
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING
from typing import Any
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count
@@ -53,6 +52,7 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.versioning import annotate_effective_content
if TYPE_CHECKING:
from collections.abc import Callable
@@ -182,14 +182,9 @@ class TitleContentFilter(Filter):
logger.warning(
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
)
try:
return qs.filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
except FieldError:
return qs.filter(
Q(title__icontains=value) | Q(content__icontains=value),
)
return annotate_effective_content(qs).filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
else:
return qs
@@ -200,14 +195,9 @@ class EffectiveContentFilter(Filter):
value = value.strip() if isinstance(value, str) else value
if not value:
return qs
try:
return qs.filter(
**{f"effective_content__{self.lookup_expr}": value},
)
except FieldError:
return qs.filter(
**{f"content__{self.lookup_expr}": value},
)
return annotate_effective_content(qs).filter(
**{f"effective_content__{self.lookup_expr}": value},
)
@extend_schema_field(serializers.BooleanField)
+3
View File
@@ -674,6 +674,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
ordering = ordering or (Lower("name"),)
children = children.order_by(*ordering)
if not children:
return []
serializer = TagSerializer(
children,
many=True,
@@ -2,14 +2,12 @@ from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock
from auditlog.models import LogEntry # type: ignore[import-untyped]
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase as DjangoTestCase
from django.utils import timezone
@@ -22,6 +20,7 @@ from documents.filters import TitleContentFilter
from documents.models import Document
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin
if TYPE_CHECKING:
@@ -892,32 +891,104 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
)
class TestVersionAwareFilters(TestCase):
def test_title_content_filter_falls_back_to_content(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
class TestVersionAwareFilters(DjangoTestCase):
"""
The filters annotate effective_content themselves rather than relying on
the caller's queryset carrying it, so they stay version-aware on a plain
Document queryset (e.g. the bulk-edit "select all matching" path).
"""
result = TitleContentFilter().filter(queryset, " latest ")
def setUp(self) -> None:
super().setUp()
self.root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
content="superseded-content",
)
Document.objects.create(
title="version",
checksum="version",
mime_type="application/pdf",
root_document=self.root,
version_index=1,
content="latest-content",
)
self.unversioned = Document.objects.create(
title="unversioned",
checksum="unversioned",
mime_type="application/pdf",
content="latest-content",
)
self.assertIs(result, fallback_queryset)
self.assertEqual(queryset.filter.call_count, 2)
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
result = EffectiveContentFilter(lookup_expr="icontains").filter(
queryset,
def test_title_content_filter_matches_latest_version_content(self) -> None:
result = TitleContentFilter().filter(
Document.objects.filter(root_document__isnull=True),
" latest ",
)
self.assertIs(result, fallback_queryset)
first_kwargs = queryset.filter.call_args_list[0].kwargs
second_kwargs = queryset.filter.call_args_list[1].kwargs
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_matches_latest_version_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
" latest ",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_ignores_superseded_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
"superseded",
)
self.assertEqual(list(result), [])
def test_filters_reuse_an_existing_annotation(self) -> None:
"""
Annotating twice under the same alias is an error, so an already
annotated queryset (the search path) has to be left alone.
"""
annotated = annotate_effective_content(
Document.objects.filter(root_document__isnull=True),
)
self.assertIs(annotate_effective_content(annotated), annotated)
result = EffectiveContentFilter(lookup_expr="icontains").filter(
annotated,
"latest",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
"""
Bulk edit's "select all matching" builds its own queryset, so before
the filters annotated for themselves it matched the root document's
superseded content -- selecting documents the list view, filtered by
the same term, does not show.
"""
user = User.objects.create_superuser(username="bulk_selection")
selected = DocumentSelectionMixin()._resolve_document_ids(
user=user,
validated_data={
"all": True,
"filters": {"content__icontains": "superseded"},
},
)
self.assertEqual(selected, [])
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
queryset = mock.Mock()
+23
View File
@@ -1947,6 +1947,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data["documents"]), 1)
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
def test_global_search_returns_latest_version_content(self) -> None:
root = Document.objects.create(
title="bank statement",
content="superseded content",
checksum="GSV1",
pk=23,
)
Document.objects.create(
title="bank statement v2",
content="latest content",
checksum="GSV2",
pk=24,
root_document=root,
version_index=1,
)
self.client.force_authenticate(self.user)
response = self.client.get("/api/search/?query=bank&db_only=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
self.assertEqual(returned.get(root.id), "latest content")
def test_global_search_filters_owned_mail_objects(self) -> None:
user1 = User.objects.create_user("mail-search-user")
user2 = User.objects.create_user("other-mail-search-user")
+6 -3
View File
@@ -27,10 +27,13 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
"""
Annotates documents with the content of their newest version, falling back
to their own, so get_effective_content() can answer from the row rather
than querying for the versions of each document
Annotates documents with the content of their newest version unless the
queryset already carries the annotation, falling back to their own, so
get_effective_content() can answer from the row rather than querying for
the versions of each document.
"""
if "effective_content" in documents.query.annotations:
return documents
return documents.annotate(
effective_content=Coalesce(
Subquery(
+8 -2
View File
@@ -232,6 +232,7 @@ from documents.tasks import train_classifier
from documents.tasks import update_document_parent_tags
from documents.utils import get_boolean
from documents.versioning import VersionResolutionError
from documents.versioning import annotate_effective_content
from documents.versioning import get_latest_version_for_root
from documents.versioning import get_request_version_param
from documents.versioning import get_root_document
@@ -3632,8 +3633,13 @@ class GlobalSearchView(PassUserMixin):
OBJECT_LIMIT = 3
docs = []
if request.user.has_perm("documents.view_document"):
all_docs = Document.objects.filter(
id__in=permitted_document_ids(request.user),
# Never more than OBJECT_LIMIT rows come back here, so annotating
# is cheap -- and without it these results show the root
# document's superseded content.
all_docs = annotate_effective_content(
Document.objects.filter(
id__in=permitted_document_ids(request.user),
),
)
if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
+29 -29
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-07 20:47+0000\n"
"POT-Creation-Date: 2026-09-08 15:56+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr ""
#: documents/filters.py:473
#: documents/filters.py:463
msgid "Value must be valid JSON."
msgstr ""
#: documents/filters.py:492
#: documents/filters.py:482
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:502
#: documents/filters.py:492
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:523
#: documents/filters.py:513
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:537
#: documents/filters.py:527
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:601
#: documents/filters.py:591
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:638
#: documents/filters.py:628
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:757 documents/models.py:136
#: documents/filters.py:747 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1119
#: documents/filters.py:1109
msgid "Custom field not found"
msgstr ""
@@ -1631,49 +1631,49 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:524 documents/serialisers.py:878
#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2624
#: documents/serialisers.py:524 documents/serialisers.py:881
#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:714
#: documents/serialisers.py:717
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:2315
#: documents/serialisers.py:2318
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2359
#: documents/serialisers.py:2362
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2366
#: documents/serialisers.py:2369
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2383 documents/serialisers.py:2393
#: documents/serialisers.py:2386 documents/serialisers.py:2396
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2388
#: documents/serialisers.py:2391
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2535
#: documents/serialisers.py:2538
msgid "Invalid variable detected."
msgstr ""
#: documents/serialisers.py:2894
#: documents/serialisers.py:2897
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2924 documents/views.py:4626
#: documents/serialisers.py:2927 documents/views.py:4632
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1941,36 +1941,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:307 documents/views.py:2621
#: documents/views.py:308 documents/views.py:2622
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1591
#: documents/views.py:1592
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1602
#: documents/views.py:1603
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2446 documents/views.py:2767
#: documents/views.py:2447 documents/views.py:2768
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4645
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4685
#: documents/views.py:4691
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4749
#: documents/views.py:4755
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4763
#: documents/views.py:4769
msgid "The share link bundle is unavailable."
msgstr ""
+23 -2
View File
@@ -1,7 +1,9 @@
import logging
from celery import Task
from celery import shared_task
from documents.models import PaperlessTask
from paperless_mail.mail import MailAccountHandler
from paperless_mail.mail import MailError
from paperless_mail.models import MailAccount
@@ -10,8 +12,27 @@ from paperless_mail.models import MailRule
logger = logging.getLogger("paperless.mail.tasks")
@shared_task
def process_mail_accounts(account_ids: list[int] | None = None) -> str:
@shared_task(bind=True)
def process_mail_accounts(self: Task, account_ids: list[int] | None = None) -> str:
# A scheduled check can still be running (or queued) when the next one
# fires, e.g. a large attachment batch that takes longer to process than
# the check interval. ProcessedMail dedup only records a message once its
# handling has finished, so an overlapping run can still pick up the same
# not-yet-recorded message. Skip outright rather than race it.
other_mail_fetch_running = (
PaperlessTask.objects.filter(
task_type=PaperlessTask.TaskType.MAIL_FETCH,
status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED],
)
.exclude(task_id=self.request.id)
.exists()
)
if other_mail_fetch_running:
logger.info(
"Mail account processing is already running; skipping this run.",
)
return "Skipped: mail account processing already in progress."
total_new_documents = 0
accounts = (
MailAccount.objects.filter(pk__in=account_ids)
@@ -0,0 +1,89 @@
from unittest import mock
import pytest
from documents.models import PaperlessTask
from paperless_mail import tasks
from paperless_mail.tests.factories import MailAccountFactory
from paperless_mail.tests.factories import MailRuleFactory
@pytest.mark.django_db
class TestProcessMailAccountsOverlap:
def test_skips_when_another_mail_fetch_task_is_running(self) -> None:
account = MailAccountFactory.create()
MailRuleFactory.create(account=account, enabled=True)
PaperlessTask.objects.create(
task_id="other-running-task",
task_type=PaperlessTask.TaskType.MAIL_FETCH,
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
status=PaperlessTask.Status.STARTED,
)
with mock.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
) as mocked_handle:
result = tasks.process_mail_accounts()
mocked_handle.assert_not_called()
assert result == "Skipped: mail account processing already in progress."
def test_runs_when_no_other_mail_fetch_task_is_running(self) -> None:
account = MailAccountFactory.create()
MailRuleFactory.create(account=account, enabled=True)
with mock.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
return_value=0,
) as mocked_handle:
result = tasks.process_mail_accounts()
mocked_handle.assert_called_once()
assert result == "No new documents were added."
def test_ignores_completed_mail_fetch_tasks(self) -> None:
account = MailAccountFactory.create()
MailRuleFactory.create(account=account, enabled=True)
PaperlessTask.objects.create(
task_id="finished-task",
task_type=PaperlessTask.TaskType.MAIL_FETCH,
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
status=PaperlessTask.Status.SUCCESS,
)
with mock.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
return_value=0,
) as mocked_handle:
result = tasks.process_mail_accounts()
mocked_handle.assert_called_once()
assert result == "No new documents were added."
def test_does_not_skip_due_to_its_own_task_row(self) -> None:
account = MailAccountFactory.create()
MailRuleFactory.create(account=account, enabled=True)
PaperlessTask.objects.create(
task_id="self-task-id",
task_type=PaperlessTask.TaskType.MAIL_FETCH,
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
status=PaperlessTask.Status.STARTED,
)
with mock.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
return_value=0,
) as mocked_handle:
result = tasks.process_mail_accounts.apply(
task_id="self-task-id",
).result
mocked_handle.assert_called_once()
assert result == "No new documents were added."