mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-03 07:24:12 +08:00
Handles the case where the Celery enqueue fails and don't track the file as queued if it does
This commit is contained in:
@@ -314,7 +314,7 @@ def _consume_file(
|
||||
consumption_dir: Path,
|
||||
*,
|
||||
subdirs_as_tags: bool,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""
|
||||
Queue a file for consumption.
|
||||
|
||||
@@ -322,15 +322,20 @@ def _consume_file(
|
||||
filepath: Path to the file to consume.
|
||||
consumption_dir: Base consumption directory.
|
||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||
|
||||
Returns:
|
||||
True if the file was successfully handed to Celery, False otherwise.
|
||||
Callers must not treat the file as queued on failure, or it will be
|
||||
stranded until the consumer process restarts (GH #13923).
|
||||
"""
|
||||
# Verify file still exists and is accessible
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||
return
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning(f"Not consuming {filepath}: {e}")
|
||||
return
|
||||
return False
|
||||
|
||||
# Get tags from path if configured
|
||||
tag_ids: list[int] | None = None
|
||||
@@ -355,6 +360,9 @@ def _consume_file(
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error while queuing document {filepath}")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -492,12 +500,12 @@ class Command(BaseCommand):
|
||||
if not consumer_filter(Change.added, str(filepath)):
|
||||
continue
|
||||
|
||||
_consume_file(
|
||||
if _consume_file(
|
||||
filepath=filepath,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
queued.add(filepath.resolve())
|
||||
):
|
||||
queued.add(filepath.resolve())
|
||||
|
||||
return queued
|
||||
|
||||
@@ -651,14 +659,18 @@ class Command(BaseCommand):
|
||||
|
||||
# Check for stable files
|
||||
for stable_path in tracker.get_stable_files():
|
||||
_consume_file(
|
||||
if _consume_file(
|
||||
filepath=stable_path,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
# Remember it so the rescan does not re-queue it while
|
||||
# the consume task has yet to remove it from disk
|
||||
queued.add(stable_path)
|
||||
):
|
||||
# Remember it so the rescan does not re-queue it
|
||||
# while the consume task has yet to remove it
|
||||
# from disk
|
||||
queued.add(stable_path)
|
||||
# else: leave it untracked and un-queued so the next
|
||||
# rescan retries the failed publish (GH #13923)
|
||||
# instead of stranding it until a restart.
|
||||
|
||||
# Exit watch loop to reconfigure timeout
|
||||
break
|
||||
|
||||
@@ -445,12 +445,13 @@ class TestConsumeFile:
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||
@@ -464,11 +465,12 @@ class TestConsumeFile:
|
||||
mock_consume_file_delay: MagicMock,
|
||||
) -> None:
|
||||
"""Test _consume_file handles nonexistent files gracefully."""
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=consumption_dir / "nonexistent.pdf",
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_directory(
|
||||
@@ -480,11 +482,12 @@ class TestConsumeFile:
|
||||
subdir = consumption_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=subdir,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_permission_error(
|
||||
@@ -499,13 +502,39 @@ class TestConsumeFile:
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_apply_async_failure(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
Test _consume_file reports failure when apply_async raises.
|
||||
|
||||
Callers rely on this return value to avoid marking the file as
|
||||
queued when the broker publish itself failed (GH #13923) - a false
|
||||
positive here would strand the file until the consumer restarts.
|
||||
"""
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
|
||||
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_consume_with_tags_error(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
@@ -522,11 +551,12 @@ class TestConsumeFile:
|
||||
side_effect=DatabaseError("Something happened"),
|
||||
)
|
||||
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=True,
|
||||
)
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||
@@ -1249,6 +1279,54 @@ class TestProcessExistingFilesQueued:
|
||||
assert target.resolve() in queued
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestCommandRetryAfterQueueFailure:
|
||||
"""
|
||||
Regression test for GH #13923.
|
||||
|
||||
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
|
||||
must not be marked as queued, so the periodic rescan retries it once
|
||||
the broker recovers, instead of stranding it until the consumer
|
||||
process is restarted.
|
||||
"""
|
||||
|
||||
def test_watch_loop_retries_failed_publish_on_rescan(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
start_consumer: Callable[..., ConsumerThread],
|
||||
) -> None:
|
||||
"""A publish failure from the watch loop is retried by the rescan."""
|
||||
call_count = 0
|
||||
|
||||
def flaky_apply_async(*args: object, **kwargs: object) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise Exception("broker down")
|
||||
|
||||
mock_consume_file_delay.apply_async.side_effect = flaky_apply_async
|
||||
|
||||
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
start_time = monotonic()
|
||||
while call_count < 2 and monotonic() - start_time < 5.0:
|
||||
sleep(0.1)
|
||||
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert call_count >= 2, (
|
||||
"Expected the failed publish to be retried by the rescan, "
|
||||
f"but apply_async was only called {call_count} time(s)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestCommandRescanRecovery:
|
||||
|
||||
Reference in New Issue
Block a user