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:
co-authored by
Claude Opus 4.7
parent
7784ec02d7
commit
21e5608a39
@@ -225,5 +225,5 @@ In `server/semantic.py::nc_semantic_search_answer`, replace the per-type `if res
|
|||||||
- [ ] Update existing docstrings in `search/semantic.py:52` and `search/bm25_hybrid.py:75` to point at the new helper.
|
- [ ] Update existing docstrings in `search/semantic.py:52` and `search/bm25_hybrid.py:75` to point at the new helper.
|
||||||
- [ ] Unit tests: each verifier handles 200/403/404/transient distinctly; dedup collapses chunks; eviction is scheduled on `False`.
|
- [ ] Unit tests: each verifier handles 200/403/404/transient distinctly; dedup collapses chunks; eviction is scheduled on `False`.
|
||||||
- [ ] Integration test: index a note, delete via API (no webhook), confirm the next semantic search does not return it.
|
- [ ] Integration test: index a note, delete via API (no webhook), confirm the next semantic search does not return it.
|
||||||
- [ ] CI guard: enumerate indexed doc_types in `vector/scanner.py` and assert each has a registered verifier.
|
- [x] CI guard: enumerate indexed doc_types in `vector/scanner.py` and assert each has a registered verifier. (`INDEXED_DOC_TYPES` in `vector/scanner.py`; `tests/unit/search/test_verification.py::test_supported_doc_types_covers_indexed_types`.)
|
||||||
- [ ] Document the latency budget and rate-limit posture in `docs/configuration.md`.
|
- [x] Document the latency budget and rate-limit posture in `docs/configuration.md`. (See "Verify-on-Read Latency Budget" section.)
|
||||||
|
|||||||
@@ -474,6 +474,45 @@ DOCUMENT_CHUNK_OVERLAP=100
|
|||||||
|
|
||||||
**Important**: Changing chunk size requires re-embedding all documents. The collection naming strategy (see "Qdrant Collection Naming" above) helps manage this by creating separate collections for different configurations.
|
**Important**: Changing chunk size requires re-embedding all documents. The collection naming strategy (see "Qdrant Collection Naming" above) helps manage this by creating separate collections for different configurations.
|
||||||
|
|
||||||
|
### Verify-on-Read Latency Budget
|
||||||
|
|
||||||
|
Every semantic search request runs an access-control verification pass over its
|
||||||
|
results before returning them, to filter out documents the user can no longer
|
||||||
|
access (deleted, unshared, permissions changed). See
|
||||||
|
[ADR-019](ADR-019-verify-on-read-for-semantic-search.md) for the full design.
|
||||||
|
|
||||||
|
This adds Nextcloud round-trips to the search path that operators should be
|
||||||
|
aware of:
|
||||||
|
|
||||||
|
- **Per-search cost**: one Nextcloud round-trip per *unique* `(doc_id, doc_type)`
|
||||||
|
in the result set. Chunking means a 10-result page typically references 3-5
|
||||||
|
unique documents, so verification adds 3-5 round-trips. With the default
|
||||||
|
20-way concurrency this is one parallel batch — usually under 100 ms on a
|
||||||
|
healthy connection.
|
||||||
|
- **Concurrency**: all verifications fan out under a shared semaphore
|
||||||
|
(`DEFAULT_VERIFICATION_CONCURRENCY = 20` in `search/verification.py`). The
|
||||||
|
limit is not currently exposed as an env var; if production workloads
|
||||||
|
saturate Nextcloud, consider opening an issue to make it tunable.
|
||||||
|
- **News API caveat**: the News app has no per-item endpoint, so the news
|
||||||
|
verifier issues a single `news.get_items(batch_size=-1, get_read=True)` call
|
||||||
|
per search that contains any news result, then intersects locally. The
|
||||||
|
payload is **unbounded** — for users with very large feed backlogs this can
|
||||||
|
dominate verification latency. Disabling News in the indexer or running with
|
||||||
|
a smaller backlog mitigates this; per-item paginated verification is tracked
|
||||||
|
as a future improvement.
|
||||||
|
- **Eviction**: when verification finds a definitive miss (404 / 403), the
|
||||||
|
corresponding Qdrant points are deleted in the background on a lifespan-owned
|
||||||
|
task group — fire-and-forget, does **not** block the search response.
|
||||||
|
Eviction failures are logged but never propagated; the next query will
|
||||||
|
re-verify and re-attempt (self-healing).
|
||||||
|
- **Failure modes**: transient errors (5xx, network) keep results visible
|
||||||
|
(fail open) so a flaky link does not silently shrink result pages; only
|
||||||
|
*definitive* 404 / 403 drops them.
|
||||||
|
|
||||||
|
If verification ever needs to be disabled (debugging, benchmarking), the
|
||||||
|
`evict_on_missing=False` flag on `verify_search_results()` skips eviction
|
||||||
|
without changing what is returned to the caller.
|
||||||
|
|
||||||
### Environment Variables Reference
|
### Environment Variables Reference
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from urllib.parse import urlparse
|
|||||||
import anyio
|
import anyio
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
|
from anyio.abc import TaskGroup
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
@@ -319,6 +320,10 @@ class VectorSyncState:
|
|||||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||||
shutdown_event: Optional[anyio.Event] = None
|
shutdown_event: Optional[anyio.Event] = None
|
||||||
scanner_wake_event: Optional[anyio.Event] = None
|
scanner_wake_event: Optional[anyio.Event] = None
|
||||||
|
# Long-lived task group used for fire-and-forget background work spawned
|
||||||
|
# from the request path (e.g. ADR-019 verify-on-read eviction). Set by the
|
||||||
|
# starlette lifespan after entering its task group; cleared on shutdown.
|
||||||
|
eviction_task_group: Optional[TaskGroup] = None
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton for vector sync state
|
# Module-level singleton for vector sync state
|
||||||
@@ -335,6 +340,7 @@ class AppContext:
|
|||||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||||
shutdown_event: Optional[anyio.Event] = None
|
shutdown_event: Optional[anyio.Event] = None
|
||||||
scanner_wake_event: Optional[anyio.Event] = None
|
scanner_wake_event: Optional[anyio.Event] = None
|
||||||
|
eviction_task_group: Optional[TaskGroup] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -353,6 +359,7 @@ class OAuthAppContext:
|
|||||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||||
shutdown_event: Optional[anyio.Event] = None
|
shutdown_event: Optional[anyio.Event] = None
|
||||||
scanner_wake_event: Optional[anyio.Event] = None
|
scanner_wake_event: Optional[anyio.Event] = None
|
||||||
|
eviction_task_group: Optional[TaskGroup] = None
|
||||||
|
|
||||||
|
|
||||||
class BasicAuthMiddleware:
|
class BasicAuthMiddleware:
|
||||||
@@ -569,6 +576,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||||
shutdown_event=_vector_sync_state.shutdown_event,
|
shutdown_event=_vector_sync_state.shutdown_event,
|
||||||
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
||||||
|
eviction_task_group=_vector_sync_state.eviction_task_group,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
logger.info("Shutting down BasicAuth session")
|
logger.info("Shutting down BasicAuth session")
|
||||||
@@ -1189,6 +1197,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||||
shutdown_event=_vector_sync_state.shutdown_event,
|
shutdown_event=_vector_sync_state.shutdown_event,
|
||||||
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
||||||
|
eviction_task_group=_vector_sync_state.eviction_task_group,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
logger.info("Shutting down MCP server")
|
logger.info("Shutting down MCP server")
|
||||||
@@ -1604,6 +1613,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
username,
|
username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Expose this long-lived task group to request-path code that
|
||||||
|
# wants to spawn background work (e.g. ADR-019 verify-on-read
|
||||||
|
# eviction). Eviction coroutines have their own try/except, so
|
||||||
|
# they cannot panic the parent group.
|
||||||
|
_vector_sync_state.eviction_task_group = tg
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Background sync tasks started: 1 scanner + "
|
f"Background sync tasks started: 1 scanner + "
|
||||||
f"{settings.vector_sync_processor_workers} processors"
|
f"{settings.vector_sync_processor_workers} processors"
|
||||||
@@ -1617,6 +1632,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Shutdown signal
|
# Shutdown signal
|
||||||
logger.info("Shutting down background sync tasks")
|
logger.info("Shutting down background sync tasks")
|
||||||
shutdown_event.set()
|
shutdown_event.set()
|
||||||
|
# Request path must not spawn into a cancelling group.
|
||||||
|
_vector_sync_state.eviction_task_group = None
|
||||||
await client.close()
|
await client.close()
|
||||||
# TaskGroup automatically cancels all tasks on exit
|
# TaskGroup automatically cancels all tasks on exit
|
||||||
|
|
||||||
@@ -1786,6 +1803,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
use_basic_auth, # Pass as positional arg (before task_status)
|
use_basic_auth, # Pass as positional arg (before task_status)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Expose this long-lived task group to request-path code
|
||||||
|
# that wants to spawn background work (e.g. ADR-019
|
||||||
|
# verify-on-read eviction). Eviction coroutines have their
|
||||||
|
# own try/except, so they cannot panic the parent group.
|
||||||
|
_vector_sync_state.eviction_task_group = tg
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Background sync tasks started: 1 user manager + "
|
f"Background sync tasks started: 1 user manager + "
|
||||||
f"{settings.vector_sync_processor_workers} processors"
|
f"{settings.vector_sync_processor_workers} processors"
|
||||||
@@ -1799,6 +1822,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Shutdown signal
|
# Shutdown signal
|
||||||
logger.info("Shutting down background sync tasks")
|
logger.info("Shutting down background sync tasks")
|
||||||
shutdown_event.set()
|
shutdown_event.set()
|
||||||
|
# Request path must not spawn into a cancelling group.
|
||||||
|
_vector_sync_state.eviction_task_group = None
|
||||||
# Close token broker HTTP client
|
# Close token broker HTTP client
|
||||||
if token_broker._http_client:
|
if token_broker._http_client:
|
||||||
await token_broker._http_client.aclose()
|
await token_broker._http_client.aclose()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
from anyio.abc import TaskGroup
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
from nextcloud_mcp_server.search.algorithms import SearchResult
|
from nextcloud_mcp_server.search.algorithms import SearchResult
|
||||||
@@ -128,7 +129,11 @@ async def _verify_files(
|
|||||||
try:
|
try:
|
||||||
info = await client.webdav.get_file_info(file_path)
|
info = await client.webdav.get_file_info(file_path)
|
||||||
if info is None:
|
if info is None:
|
||||||
# get_file_info returns None on definitive 404
|
# Contract: WebDAVClient.get_file_info returns None on 404
|
||||||
|
# and raises HTTPStatusError on 403/5xx/network. If that
|
||||||
|
# contract changes (e.g. a future refactor that raises 404
|
||||||
|
# like other client methods), the `except HTTPStatusError`
|
||||||
|
# block below already handles it via _is_definitive_404_or_403.
|
||||||
return
|
return
|
||||||
accessible.add(doc_id)
|
accessible.add(doc_id)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
@@ -256,13 +261,27 @@ async def _verify_news_items(
|
|||||||
)
|
)
|
||||||
return set(doc_ids)
|
return set(doc_ids)
|
||||||
|
|
||||||
present_ids = {int(item.get("id")) for item in items if item.get("id") is not None}
|
# Cast safely: a non-numeric id from the API or in our doc_ids would
|
||||||
# Map back to the original doc_id types (caller may pass ints or strs).
|
# otherwise raise ValueError after the semaphore block exits and surface
|
||||||
accessible: set[int | str] = set()
|
# as a verifier crash. Treat as transient (fail open) instead.
|
||||||
for d in doc_ids:
|
try:
|
||||||
if int(d) in present_ids:
|
present_ids = {
|
||||||
accessible.add(d)
|
int(item.get("id")) for item in items if item.get("id") is not None
|
||||||
return accessible
|
}
|
||||||
|
# Map back to the original doc_id types (caller may pass ints or strs).
|
||||||
|
accessible: set[int | str] = set()
|
||||||
|
for d in doc_ids:
|
||||||
|
if int(d) in present_ids:
|
||||||
|
accessible.add(d)
|
||||||
|
return accessible
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
logger.warning(
|
||||||
|
"Non-numeric id while verifying news items (sample=%r, doc_ids=%r): %s; keeping all results",
|
||||||
|
items[:3] if items else items,
|
||||||
|
doc_ids,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return set(doc_ids)
|
||||||
|
|
||||||
|
|
||||||
_VERIFIERS: dict[str, BatchVerifier] = {
|
_VERIFIERS: dict[str, BatchVerifier] = {
|
||||||
@@ -293,6 +312,7 @@ async def verify_search_results(
|
|||||||
*,
|
*,
|
||||||
evict_on_missing: bool = True,
|
evict_on_missing: bool = True,
|
||||||
max_concurrent: int = DEFAULT_VERIFICATION_CONCURRENCY,
|
max_concurrent: int = DEFAULT_VERIFICATION_CONCURRENCY,
|
||||||
|
eviction_task_group: TaskGroup | None = None,
|
||||||
) -> list[SearchResult]:
|
) -> list[SearchResult]:
|
||||||
"""Filter search results to those the user can currently access.
|
"""Filter search results to those the user can currently access.
|
||||||
|
|
||||||
@@ -301,9 +321,13 @@ async def verify_search_results(
|
|||||||
concurrently per doc_type and concurrently per id within each verifier,
|
concurrently per doc_type and concurrently per id within each verifier,
|
||||||
bounded by a shared semaphore (``max_concurrent``).
|
bounded by a shared semaphore (``max_concurrent``).
|
||||||
|
|
||||||
When ``evict_on_missing=True``, points for documents that fail
|
When ``evict_on_missing=True``, points for documents that fail verification
|
||||||
verification are deleted from Qdrant in-line. Eviction failures are
|
are deleted from Qdrant. If ``eviction_task_group`` is provided (the
|
||||||
logged but never propagated.
|
lifespan-owned task group from ``app.py::VectorSyncState``), eviction is
|
||||||
|
fire-and-forget — the search response returns immediately and Qdrant
|
||||||
|
deletes happen in the background. If no task group is provided (unit
|
||||||
|
tests, modes without vector sync), eviction falls back to running inline
|
||||||
|
in a local task group. Eviction failures are logged but never propagated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated NextcloudClient (must expose ``username``).
|
client: Authenticated NextcloudClient (must expose ``username``).
|
||||||
@@ -312,6 +336,10 @@ async def verify_search_results(
|
|||||||
evict_on_missing: Schedule lazy eviction for inaccessible docs.
|
evict_on_missing: Schedule lazy eviction for inaccessible docs.
|
||||||
max_concurrent: Cap on concurrent verification round-trips against
|
max_concurrent: Cap on concurrent verification round-trips against
|
||||||
Nextcloud. Defaults to ``DEFAULT_VERIFICATION_CONCURRENCY``.
|
Nextcloud. Defaults to ``DEFAULT_VERIFICATION_CONCURRENCY``.
|
||||||
|
eviction_task_group: Optional long-lived task group on which to
|
||||||
|
spawn fire-and-forget eviction. Pass
|
||||||
|
``ctx.request_context.lifespan_context.eviction_task_group``
|
||||||
|
from FastMCP tools.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filtered list preserving the original order.
|
Filtered list preserving the original order.
|
||||||
@@ -385,10 +413,19 @@ async def verify_search_results(
|
|||||||
# list still contains all chunks).
|
# list still contains all chunks).
|
||||||
kept = [r for r in results if (r.id, r.doc_type) not in inaccessible]
|
kept = [r for r in results if (r.id, r.doc_type) not in inaccessible]
|
||||||
|
|
||||||
# Lazy eviction. Runs inline before returning — slow Qdrant will delay
|
# Lazy eviction.
|
||||||
# the search response. Background eviction would need a task registered
|
#
|
||||||
# on the lifespan context; the inline approach is acceptable given
|
# Preferred path: spawn evict() on the lifespan-owned task group via
|
||||||
# typical Qdrant delete latency, but callers should be aware.
|
# `start_soon`, which returns immediately — the search response is not
|
||||||
|
# blocked on Qdrant deletes. If the server is shutting down, the task
|
||||||
|
# group is cleared back to None (see app.py) and we fall through to the
|
||||||
|
# inline path. Cancellation mid-eviction is fine: the next query will
|
||||||
|
# re-verify and re-attempt (self-healing per ADR-019).
|
||||||
|
#
|
||||||
|
# Fallback path: when no task group is supplied (unit tests, deployment
|
||||||
|
# modes without vector sync), run eviction inline in a local task group.
|
||||||
|
# This preserves prior behaviour for tests that rely on eviction being
|
||||||
|
# complete by the time `verify_search_results` returns.
|
||||||
if evict_on_missing and inaccessible:
|
if evict_on_missing and inaccessible:
|
||||||
|
|
||||||
async def evict(doc_id: int | str, doc_type: str) -> None:
|
async def evict(doc_id: int | str, doc_type: str) -> None:
|
||||||
@@ -399,8 +436,12 @@ async def verify_search_results(
|
|||||||
"Failed to evict %s_%s from Qdrant: %s", doc_type, doc_id, e
|
"Failed to evict %s_%s from Qdrant: %s", doc_type, doc_id, e
|
||||||
)
|
)
|
||||||
|
|
||||||
async with anyio.create_task_group() as tg:
|
if eviction_task_group is not None:
|
||||||
for doc_id, doc_type in inaccessible:
|
for doc_id, doc_type in inaccessible:
|
||||||
tg.start_soon(evict, doc_id, doc_type)
|
eviction_task_group.start_soon(evict, doc_id, doc_type)
|
||||||
|
else:
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
for doc_id, doc_type in inaccessible:
|
||||||
|
tg.start_soon(evict, doc_id, doc_type)
|
||||||
|
|
||||||
return kept
|
return kept
|
||||||
|
|||||||
@@ -156,8 +156,17 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
# BEFORE trimming to `limit`, so we don't lose accessible results
|
# BEFORE trimming to `limit`, so we don't lose accessible results
|
||||||
# to the limit slot that ghosts would otherwise occupy. We also
|
# to the limit slot that ghosts would otherwise occupy. We also
|
||||||
# run this BEFORE context expansion to avoid re-fetching docs that
|
# run this BEFORE context expansion to avoid re-fetching docs that
|
||||||
# are about to be dropped.
|
# are about to be dropped. Pass the lifespan-owned task group so
|
||||||
verified_results = await verify_search_results(client, all_results)
|
# eviction of dropped points is fire-and-forget (does not block
|
||||||
|
# the response).
|
||||||
|
eviction_task_group = getattr(
|
||||||
|
ctx.request_context.lifespan_context, "eviction_task_group", None
|
||||||
|
)
|
||||||
|
verified_results = await verify_search_results(
|
||||||
|
client,
|
||||||
|
all_results,
|
||||||
|
eviction_task_group=eviction_task_group,
|
||||||
|
)
|
||||||
search_results = verified_results[:limit]
|
search_results = verified_results[:limit]
|
||||||
|
|
||||||
# Convert SearchResult objects to SemanticSearchResult for response
|
# Convert SearchResult objects to SemanticSearchResult for response
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Single source of truth for which doc_types this scanner indexes. The verifier
|
||||||
|
# registry in `search/verification.py` must cover every type listed here
|
||||||
|
# (enforced by `tests/unit/search/test_verification.py`). Add a verifier in the
|
||||||
|
# same PR that adds a new indexed doc_type, or accept ghost-record exposure for
|
||||||
|
# that type (see ADR-019).
|
||||||
|
INDEXED_DOC_TYPES: frozenset[str] = frozenset(
|
||||||
|
{"note", "file", "deck_card", "news_item"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DocumentTask:
|
class DocumentTask:
|
||||||
"""Document task for processing queue."""
|
"""Document task for processing queue."""
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from nextcloud_mcp_server.search.verification import (
|
|||||||
get_supported_doc_types,
|
get_supported_doc_types,
|
||||||
verify_search_results,
|
verify_search_results,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.vector.scanner import INDEXED_DOC_TYPES
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -58,13 +59,13 @@ def _http_error(status_code: int) -> HTTPStatusError:
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_supported_doc_types_covers_indexed_types():
|
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:
|
`INDEXED_DOC_TYPES` is the single source of truth in `vector/scanner.py`;
|
||||||
note, file, deck_card, news_item.
|
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() >= INDEXED_DOC_TYPES
|
||||||
assert get_supported_doc_types() >= expected
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -423,6 +424,7 @@ async def test_verify_search_results_dedupes_chunks_per_document(mocker):
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
async def test_verify_search_results_drops_inaccessible_and_evicts(mocker):
|
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()
|
spy_evict = mocker.AsyncMock()
|
||||||
mocker.patch.object(verification, "delete_document_points", spy_evict)
|
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")
|
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
|
@pytest.mark.unit
|
||||||
async def test_verify_search_results_no_eviction_when_disabled(mocker):
|
async def test_verify_search_results_no_eviction_when_disabled(mocker):
|
||||||
spy_evict = mocker.AsyncMock()
|
spy_evict = mocker.AsyncMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user