refactor(search): address PR #750 round 2 review feedback

Implements fire-and-forget eviction (ADR-019 §"Lazy eviction"): the
search response no longer waits on Qdrant deletes, instead spawning
evict() on a long-lived lifespan-owned task group. Falls back to inline
eviction in modes without vector sync and in unit tests.

Also: harden _verify_news_items against non-numeric ids (fail open
instead of crashing the verifier); document the get_file_info None-on-404
contract; add INDEXED_DOC_TYPES single source of truth in vector/scanner.py
referenced by the CI-guard test; write a Verify-on-Read Latency Budget
section in docs/configuration.md covering the unbounded news.get_items
fetch. Closes the two remaining ADR-019 implementation checklist items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 18:53:32 +02:00
co-authored by Claude Opus 4.7
parent 7784ec02d7
commit 21e5608a39
7 changed files with 195 additions and 26 deletions
+50 -5
View File
@@ -17,6 +17,7 @@ from nextcloud_mcp_server.search.verification import (
get_supported_doc_types,
verify_search_results,
)
from nextcloud_mcp_server.vector.scanner import INDEXED_DOC_TYPES
# ---------------------------------------------------------------------------
# Helpers
@@ -58,13 +59,13 @@ def _http_error(status_code: int) -> HTTPStatusError:
@pytest.mark.unit
def test_supported_doc_types_covers_indexed_types():
"""ADR-019 implementation checklist: every indexed doc_type has a verifier.
"""ADR-019 CI guard: every doc_type indexed by the scanner has a verifier.
Indexed types are defined in vector/scanner.py and vector/processor.py:
note, file, deck_card, news_item.
`INDEXED_DOC_TYPES` is the single source of truth in `vector/scanner.py`;
this test fails if a new indexed type is added without a registered
verifier in `search/verification.py`.
"""
expected = {"note", "file", "deck_card", "news_item"}
assert get_supported_doc_types() >= expected
assert get_supported_doc_types() >= INDEXED_DOC_TYPES
# ---------------------------------------------------------------------------
@@ -423,6 +424,7 @@ async def test_verify_search_results_dedupes_chunks_per_document(mocker):
@pytest.mark.unit
async def test_verify_search_results_drops_inaccessible_and_evicts(mocker):
"""Inline-fallback path (no eviction_task_group): evict completes before return."""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
@@ -442,6 +444,49 @@ async def test_verify_search_results_drops_inaccessible_and_evicts(mocker):
spy_evict.assert_awaited_once_with(99, "note", "alice")
@pytest.mark.unit
async def test_verify_search_results_fire_and_forget_eviction(mocker):
"""When eviction_task_group is provided, eviction does not block the response.
Validates the ADR-019 design: spawn evict() on the lifespan-owned task
group via start_soon so the search response returns immediately. The
eviction still runs (verified after the task group exits).
"""
eviction_started = anyio.Event()
eviction_may_complete = anyio.Event()
eviction_completed = anyio.Event()
async def slow_delete(doc_id, doc_type, user_id):
eviction_started.set()
await eviction_may_complete.wait()
eviction_completed.set()
mocker.patch.object(
verification,
"delete_document_points",
mocker.AsyncMock(side_effect=slow_delete),
)
note_verifier = mocker.AsyncMock(return_value=set()) # both inaccessible
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
results = [_make_result(99, doc_type="note")]
client = SimpleNamespace(username="alice")
async with anyio.create_task_group() as tg:
kept = await verify_search_results(client, results, eviction_task_group=tg)
# 1. Search response was returned …
assert kept == []
# 2. … even though eviction has started but not finished.
await eviction_started.wait()
assert not eviction_completed.is_set()
# 3. Now allow eviction to complete; the task group exit awaits it.
eviction_may_complete.set()
# After the task group exits, the eviction must have run.
assert eviction_completed.is_set()
@pytest.mark.unit
async def test_verify_search_results_no_eviction_when_disabled(mocker):
spy_evict = mocker.AsyncMock()