Files
mcp-nextcloud/tests/unit/vector/test_placeholder.py
T
Chris CoutinhoandClaude Opus 4.7 a5cbe91b29 fix(vector-sync): sweep placeholder orphans at Pod startup (#101)
When the per-tenant nextcloud-mcp-server Pod OOMKills mid-batch, the
in-memory anyio processor queue is lost but the placeholder Qdrant
points (is_placeholder=true, status=pending) survive. The next Pod's
scanner re-runs, sees the existing placeholders, applies the
5 × VECTOR_SYNC_SCAN_INTERVAL staleness gate (~5h with the deployed
1h scan interval), and skips them. Result: 0 documents indexed for
the duration of the gate after every restart.

Stamps a process-level instance_id (UUID per Pod-process) onto every
placeholder write. A new sweep_orphan_placeholders helper, called
once from starlette_lifespan after the Qdrant client is initialised
and before the scanner / user-manager spawns, scrolls the collection
and deletes any placeholder whose instance_id doesn't match the
current Pod's (including placeholders with no instance_id field —
back-compat for pre-fix Pod versions). The scanner's next cycle
naturally re-creates fresh placeholders and queues work normally;
no DocumentTask reconstruction needed.

Sweep is one-shot at startup, not periodic — the existing staleness
gate still covers same-Pod recovery, and the cross-Pod-restart gap
was the only failure mode. Failure is non-fatal (logged via
vector_sync.orphan_sweep_failed) so a transient Qdrant hiccup at
boot doesn't prevent the scanner from running.

Both lifespan branches (single-user BasicAuth, OAuth / multi-user
BasicAuth) call the sweep via a module-local helper. A new
VECTOR_SYNC_ORPHAN_SWEEP_ENABLED setting (default True) provides
an escape hatch.

Closes Deck #101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:03:23 +02:00

197 lines
7.1 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_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"