Files
mcp-nextcloud/tests/unit/vector/test_placeholder.py
T
Chris CoutinhoandClaude Opus 4.8 8c9339501e fix(vector): dead-letter terminally-failed documents to stop multi-user re-queue loop
A pathological PDF (a 206-page ChronoScan scan with ~3400 JBIG2/JPX images)
jammed a tenant's structured ingest worker in an infinite reprocess loop,
re-burning a 120s pymupdf4llm parse (and occasionally OOM-racing the 2Gi pod)
every few minutes.

Root cause: the per-user placeholder "failed" mark could not stop the loop. The
placeholder point ID is user-agnostic (uuid5("file:<doc_id>:placeholder")) but
the scanner's freshness gate, query, and status update all filter by user_id.
For a file visible to several users the single shared placeholder's user_id is
overwritten by whoever scanned last, so every other user's scan sees "no record"
and re-queues -- an N-user ping-pong that never honours the failed status.

Fix: when a parse fails terminally (no higher escalation tier available, e.g.
structured with OCR off) record a durable, content-addressed, user-agnostic
dead-letter marker (mirrors vector/sharing_state.py). The scanner consults it
tenant-wide for every user and skips re-queuing until the content (etag) OR the
escalation-tier set (tiers_sig -- e.g. OCR enabled) changes, so the document is
attempted once per content-version instead of forever.

- new vector/dead_letter.py: mark/is/clear, content-addressed marker carrying
  is_placeholder=True (inherits search exclusion) + dead_letter=True
- escalation.escalation_tiers_signature(settings): retry-on-tier-change key
- processor: dead-letter terminal failures, clear on successful (re-)index
- scanner: user-agnostic is_dead_lettered skip beside claim_existing_index
- placeholder: exempt dead_letter markers from the orphan sweep (durability)
- metrics: astrolabe_document_dead_lettered_total{reason}

Deck #349.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:09:49 +02:00

217 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for placeholder orphan-sweep (card #101).
When the per-tenant Pod OOMKills mid-batch the in-memory processor
queue is lost. Placeholder points written to Qdrant survive, and the
next Pod's scanner would skip them under the
``5 × VECTOR_SYNC_SCAN_INTERVAL`` staleness gate (~5h with the deployed
1h scan interval). ``sweep_orphan_placeholders`` runs once at Pod
startup and deletes any placeholder whose ``instance_id`` payload
doesn't match the current Pod-process, restoring throughput within
one scan cycle.
The sweep contract this file pins:
* placeholder with ``instance_id != _INSTANCE_ID`` → deleted
* placeholder with **absent** ``instance_id`` → deleted
(back-compat for placeholders written by pre-fix Pod versions)
* placeholder with ``instance_id == _INSTANCE_ID`` → kept
* empty / no-results scroll → no-op (no spurious delete call)
* multi-page scroll → all pages visited, deletes batched per page
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from nextcloud_mcp_server.vector import placeholder as placeholder_module
from nextcloud_mcp_server.vector.placeholder import sweep_orphan_placeholders
def _scrolled_point(point_id, instance_id=...):
"""Stand-in for a qdrant_client Record. Sweep reads only
``id`` and ``payload``; passing ``instance_id=...`` (Ellipsis)
omits the field entirely (the back-compat case)."""
payload: dict = {"is_placeholder": True}
if instance_id is not ...:
payload["instance_id"] = instance_id
return SimpleNamespace(id=point_id, payload=payload)
def _make_client(scroll_pages):
"""Build an AsyncMock qdrant client whose ``scroll`` returns the
given ``(points, next_offset)`` pages in order, then mimics the
real client's exhausted-cursor return of ``([], None)``."""
client = AsyncMock()
pages = list(scroll_pages) + [([], None)]
client.scroll.side_effect = pages
return client
@pytest.mark.unit
async def test_sweeps_orphan_with_different_instance_id(monkeypatch):
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-new")
client = _make_client(
[
([_scrolled_point("p1", instance_id="pod-old")], None),
]
)
swept, kept = await sweep_orphan_placeholders(client, "nextcloud_content")
assert (swept, kept) == (1, 0)
client.delete.assert_awaited_once()
# Selector is the orphan's point id — own-Pod placeholders never
# reach the delete call.
assert client.delete.await_args.kwargs["points_selector"] == ["p1"]
@pytest.mark.unit
async def test_sweeps_placeholder_with_absent_instance_id(monkeypatch):
"""Back-compat: placeholders written by pre-fix Pod versions have
no ``instance_id`` field. They MUST be treated as orphans so the
first deploy of this fix doesn't leave old placeholders behind."""
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-new")
client = _make_client(
[
([_scrolled_point("legacy", instance_id=...)], None),
]
)
swept, kept = await sweep_orphan_placeholders(client, "nextcloud_content")
assert (swept, kept) == (1, 0)
assert client.delete.await_args.kwargs["points_selector"] == ["legacy"]
@pytest.mark.unit
async def test_keeps_own_pod_placeholders(monkeypatch):
"""A surviving Pod's own placeholders must NOT be deleted —
that's what the staleness gate is for, and the processor may
still be working through them."""
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-current")
client = _make_client(
[
(
[
_scrolled_point("mine-1", instance_id="pod-current"),
_scrolled_point("mine-2", instance_id="pod-current"),
],
None,
),
]
)
swept, kept = await sweep_orphan_placeholders(client, "nextcloud_content")
assert (swept, kept) == (0, 2)
client.delete.assert_not_awaited()
@pytest.mark.unit
async def test_keeps_dead_letter_markers(monkeypatch):
"""Dead-letter markers (vector/dead_letter.py) reuse is_placeholder=True for
the search exclusion but are DURABLE terminal-state records, not in-flight
placeholders. They carry a foreign/absent instance_id, so without the
carve-out the sweep would delete them on every Pod restart and the
dead-lettered document would loop again. They MUST be kept."""
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-new")
marker = SimpleNamespace(
id="dl-1",
payload={"is_placeholder": True, "dead_letter": True, "instance_id": "pod-old"},
)
client = _make_client([([marker], None)])
swept, kept = await sweep_orphan_placeholders(client, "nextcloud_content")
assert (swept, kept) == (0, 1)
client.delete.assert_not_awaited()
@pytest.mark.unit
async def test_noop_when_no_placeholders_exist(monkeypatch):
"""Cold-boot tenant with an empty collection — sweep does NOT
issue a delete request, so a trivially-empty batch can't trip
Qdrant validation on an empty selector list."""
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-fresh")
client = _make_client([([], None)])
swept, kept = await sweep_orphan_placeholders(client, "nextcloud_content")
assert (swept, kept) == (0, 0)
client.delete.assert_not_awaited()
@pytest.mark.unit
async def test_walks_multiple_scroll_pages(monkeypatch):
"""Scroll cursor exhaustion is signalled by ``offset is None`` per
Qdrant's contract. Sweep must keep paging until the cursor is
exhausted, batching the delete per page."""
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-current")
client = _make_client(
[
(
[
_scrolled_point("orphan-1", instance_id="pod-prev"),
_scrolled_point("mine", instance_id="pod-current"),
],
"next-cursor",
),
(
[_scrolled_point("orphan-2", instance_id=...)],
None,
),
]
)
swept, kept = await sweep_orphan_placeholders(
client, "nextcloud_content", batch_size=2
)
assert (swept, kept) == (2, 1)
# One delete per page (each page had at least one orphan).
assert client.delete.await_count == 2
delete_selectors = [
call.kwargs["points_selector"] for call in client.delete.await_args_list
]
assert delete_selectors == [["orphan-1"], ["orphan-2"]]
@pytest.mark.unit
async def test_write_placeholder_payload_includes_instance_id(monkeypatch):
"""Pin the new payload contract: ``write_placeholder_point`` MUST
stamp the current Pod's ``_INSTANCE_ID`` onto the payload it
upserts, so the next Pod's sweep can identify these placeholders
as belonging to a different process."""
monkeypatch.setattr(placeholder_module, "_INSTANCE_ID", "pod-pinned")
fake_qdrant = AsyncMock()
fake_settings = SimpleNamespace(get_collection_name=lambda: "nextcloud_content")
fake_embedding = SimpleNamespace(get_dimension=lambda: 4)
async def fake_get_qdrant_client():
return fake_qdrant
monkeypatch.setattr(placeholder_module, "get_qdrant_client", fake_get_qdrant_client)
monkeypatch.setattr(placeholder_module, "get_settings", lambda: fake_settings)
monkeypatch.setattr(
placeholder_module, "get_embedding_service", lambda: fake_embedding
)
await placeholder_module.write_placeholder_point(
doc_id="d-42",
doc_type="note",
user_id="alice",
modified_at=1700000000,
etag="abc",
)
fake_qdrant.upsert.assert_awaited_once()
upserted_point = fake_qdrant.upsert.await_args.kwargs["points"][0]
assert upserted_point.payload["instance_id"] == "pod-pinned"
# Sanity: the other contract-pinning fields are still emitted.
assert upserted_point.payload["is_placeholder"] is True
assert upserted_point.payload["status"] == "pending"