diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index cf23f9215..da98e88cd 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -390,7 +390,11 @@ class Command(CryptMixin, PaperlessCommand): description="Exporting documents...", total=len(document_manifest), ): - if document.pk != document_dict["pk"]: + # Both document_manifest and documents_stream come from the same + # Document.global_objects.order_by("id") query, taken while + # MEDIA_LOCK is held, so this should be unreachable -- it guards + # against silent data corruption if that invariant ever breaks. + if document.pk != document_dict["pk"]: # pragma: no cover raise CommandError( "Document export ordering mismatch: expected " f"pk={document_dict['pk']}, got pk={document.pk}. " diff --git a/src/documents/tests/export/test_sinks.py b/src/documents/tests/export/test_sinks.py index 2425c816a..fe421b234 100644 --- a/src/documents/tests/export/test_sinks.py +++ b/src/documents/tests/export/test_sinks.py @@ -5,6 +5,7 @@ import zipfile from pathlib import Path import pytest +from pytest_django.fixtures import SettingsWrapper from documents.export.sinks import DirectoryExportSink from documents.export.sinks import ExportSink @@ -236,6 +237,43 @@ class TestZipExportSink: assert not (target / "export.zip").exists() assert not (target / "export.zip.tmp").exists() + def test_exception_inside_stream_cleans_up_manifest_tmp( + self, + tmp_path: Path, + source_file: Path, + settings: SettingsWrapper, + ) -> None: + scratch_dir = tmp_path / "scratch" + settings.SCRATCH_DIR = scratch_dir + target: Path = tmp_path / "out" + target.mkdir() + with pytest.raises(RuntimeError): + with ZipExportSink(target, "export", delete=False) as sink: + sink.add_file(source_file, "doc.pdf") + with sink.stream("manifest.json") as handle: + handle.write("[") + raise RuntimeError("boom") + assert list(scratch_dir.glob("export-manifest-*")) == [] + assert not (target / "export.zip").exists() + assert not (target / "export.zip.tmp").exists() + + def test_abort_after_manifest_written_cleans_up_pending_tmp( + self, + tmp_path: Path, + settings: SettingsWrapper, + ) -> None: + scratch_dir = tmp_path / "scratch" + settings.SCRATCH_DIR = scratch_dir + target: Path = tmp_path / "out" + target.mkdir() + with pytest.raises(RuntimeError): + with ZipExportSink(target, "export", delete=False) as sink: + with sink.stream("manifest.json") as handle: + handle.write("[]") + raise RuntimeError("boom") + assert list(scratch_dir.glob("export-manifest-*")) == [] + assert not (target / "export.zip").exists() + def test_delete_wipes_destination_on_success( self, tmp_path: Path,