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>
This commit is contained in:
Chris Coutinho
2026-05-22 21:03:23 +02:00
co-authored by Claude Opus 4.7
parent 32fc03cf67
commit a5cbe91b29
4 changed files with 337 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
"""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"