From 57c6fb5cfaedb4229cdbc34440195ffef2649db3 Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:55:20 -0800 Subject: [PATCH] Fix: move files per document during scheduled workflow runs check_scheduled_workflows runs the workflow once per matched document inside the task-wide file move scope, so every rename it triggered was deferred to the end of the task. A long run killed by the Celery time limit would then discard all of the renames. The task now flushes the pending file moves after each document's workflow run and websocket notification, the same way bulk_update_documents does, so renames land document by document while the scope stays open. --- src/documents/tasks.py | 5 +++ src/documents/tests/test_file_moves.py | 53 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 6d0147a844..923af0245c 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -574,6 +574,8 @@ def check_scheduled_workflows() -> None: Once a document satisfies this condition, and recurring/non-recurring constraints are met, the workflow is run. """ + from documents.file_moves import flush_pending_file_moves + scheduled_workflows = get_workflows_for_trigger( WorkflowTrigger.WorkflowTriggerType.SCHEDULED, ) @@ -701,6 +703,9 @@ def check_scheduled_workflows() -> None: sender=None, document=document, ) + # Move this document's files now rather than at the end + # of the task, so a long run makes steady progress. + flush_pending_file_moves() def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None: diff --git a/src/documents/tests/test_file_moves.py b/src/documents/tests/test_file_moves.py index 3106063af7..3cfd59be8e 100644 --- a/src/documents/tests/test_file_moves.py +++ b/src/documents/tests/test_file_moves.py @@ -25,7 +25,11 @@ from documents.file_moves import move_files_for_document from documents.file_moves import request_file_move from documents.models import CustomField from documents.models import Document +from documents.models import Workflow +from documents.models import WorkflowAction +from documents.models import WorkflowTrigger from documents.tasks import bulk_update_documents +from documents.tasks import check_scheduled_workflows from documents.tasks import empty_trash from paperless.celery import app as celery_app from paperless.middleware import FileMoveMiddleware @@ -838,6 +842,55 @@ class TestCeleryTaskScope: assert (paperless_dirs.originals_dir / "first-renamed.pdf").is_file() assert (paperless_dirs.originals_dir / "second-renamed.pdf").is_file() + def test_scheduled_workflows_move_each_document( + self, + document: Document, + paperless_dirs: PaperlessDirs, + mocker: MockerFixture, + ) -> None: + """ + GIVEN: + - Two documents matched by a scheduled workflow, where running the + workflow renames the document + WHEN: + - check_scheduled_workflows runs + THEN: + - The first document's file is already moved when the workflow + runs for the second, not only when the task ends + """ + (paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT) + second = DocumentFactory( + title="second", + filename="second.pdf", + mime_type="application/pdf", + checksum=hashlib.sha256(CONTENT).hexdigest(), + ) + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED, + schedule_offset_days=0, + schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED, + ) + workflow = Workflow.objects.create(name="scheduled", order=0) + workflow.triggers.add(trigger) + workflow.actions.add(WorkflowAction.objects.create(assign_title="unused")) + + seen: dict[int, bool] = {} + + def rename(*, document: Document, **kwargs: object) -> None: + seen[document.pk] = (paperless_dirs.originals_dir / "first.pdf").is_file() + document.title = "first" if document.pk == first_pk else "other" + document.save() + + first_pk = min(document.pk, second.pk) + mocker.patch("documents.tasks.run_workflows", side_effect=rename) + mocker.patch("documents.tasks.send_websocket_document_updated") + + check_scheduled_workflows.apply() + + second_pk = max(document.pk, second.pk) + assert seen[first_pk] is False + assert seen[second_pk] is True + @pytest.mark.django_db class TestEmptyTrashLockOrdering: