diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py index 4230edb8d..16aeda776 100644 --- a/src/paperless_ai/indexing.py +++ b/src/paperless_ai/indexing.py @@ -5,6 +5,7 @@ from datetime import timedelta from typing import TYPE_CHECKING from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from filelock import FileLock from filelock import ReadWriteLock @@ -167,6 +168,19 @@ def write_store(embed_model_name: str | None = None): yield store +def _safe_related_name(document: Document, field: str) -> str | None: + """ + Returns the ``name`` of a related object (correspondent, document_type, + storage_path), or None if the FK is unset or points at a row that has + since been deleted (e.g. concurrently with this call). + """ + try: + related = getattr(document, field) + except ObjectDoesNotExist: + return None + return related.name if related else None + + def build_document_node( document: Document, *, @@ -180,14 +194,10 @@ def build_document_node( "document_id": str(document.id), "title": document.title, "tags": [t.name for t in document.tags.all()], - "correspondent": document.correspondent.name - if document.correspondent - else None, - "document_type": document.document_type.name - if document.document_type - else None, + "correspondent": _safe_related_name(document, "correspondent"), + "document_type": _safe_related_name(document, "document_type"), "filename": document.filename, - "storage_path": document.storage_path.name if document.storage_path else None, + "storage_path": _safe_related_name(document, "storage_path"), "archive_serial_number": document.archive_serial_number, "created": document.created.isoformat() if document.created else None, "added": document.added.isoformat() if document.added else None, diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py index c73959895..e69834810 100644 --- a/src/paperless_ai/tests/test_ai_indexing.py +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -8,7 +8,9 @@ from django.test import override_settings from django.utils import timezone from llama_index.core.schema import MetadataMode +from documents.models import Correspondent from documents.models import Document +from documents.models import DocumentType from documents.models import PaperlessTask from documents.signals import document_consumption_finished from documents.signals import document_updated @@ -95,6 +97,38 @@ def test_build_document_node_structured_fields_in_metadata( assert "modified" in node.metadata +@pytest.mark.django_db +def test_build_document_node_survives_concurrently_deleted_correspondent( + real_document: Document, +) -> None: + """Regression test for #13314. + + If a document's correspondent (or document type) is deleted after the + in-memory Document instance was loaded but before build_document_node + resolves the relation, accessing the FK must not raise -- it should + behave like an unset FK and produce None in the metadata instead of + aborting the whole indexing pass. + """ + correspondent = Correspondent.objects.create(name="Stale Correspondent") + document_type = DocumentType.objects.create(name="Stale Type") + real_document.correspondent = correspondent + real_document.document_type = document_type + real_document.save() + + # Re-fetch to get an instance whose correspondent/document_type relations + # are unresolved (not yet cached), mirroring a task that loaded the + # document before the concurrent deletion below. + stale_document = Document.objects.get(pk=real_document.pk) + + correspondent.delete() + document_type.delete() + + nodes = indexing.build_document_node(stale_document) + assert len(nodes) > 0 + assert nodes[0].metadata["correspondent"] is None + assert nodes[0].metadata["document_type"] is None + + @pytest.mark.django_db def test_build_document_node_excludes_document_id_from_llm_context( real_document: Document,