diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 2e6b414d9..7e3900842 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -632,8 +632,20 @@ class Command(BaseCommand): # Process each change for change_type, path in changes: path = Path(path).resolve() + if change_type == Change.deleted: + # Consumed (or otherwise removed); a later file + # reusing this name must not be skipped as + # already-queued. + queued.discard(path) if not path.is_file(): continue + if path in queued: + # Already queued and awaiting consumption; a stray + # event (NAS metadata touch, AV scan, etc.) while + # the file sits on disk mid-consumption must not + # cause it to be queued a second time (GH #13511). + logger.debug(f"Ignoring event for queued file: {path}") + continue logger.debug(f"Event: {change_type.name} for {path}") tracker.track(path, change_type) diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py index a5f8fb350..3efe4490e 100644 --- a/src/documents/tests/test_management_consumer.py +++ b/src/documents/tests/test_management_consumer.py @@ -880,6 +880,41 @@ class TestCommandWatch: ] assert call_args.original_file.name == "valid.pdf" + def test_ignores_event_for_already_queued_file( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """ + A stray filesystem event (NAS metadata touch, AV scan, etc.) on a + file that has already been queued and is awaiting consumption must + not cause it to be queued a second time (GH #13511). The task is + mocked, so the file is never removed from disk, mirroring a + long-running OCR job still holding it. + """ + thread = start_consumer() + + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + + wait_for_mock_call(mock_consume_file_delay.apply_async, timeout_s=2.0) + + if thread.exception: + raise thread.exception + + assert mock_consume_file_delay.apply_async.call_count == 1 + + # Simulate a stray event on the still-present, already-queued file. + target.touch() + sleep(0.5) + + if thread.exception: + raise thread.exception + + assert mock_consume_file_delay.apply_async.call_count == 1 + @pytest.mark.django_db @pytest.mark.usefixtures("mock_supported_extensions") def test_stop_flag_stops_consumer(