diff --git a/nextcloud_mcp_server/capabilities.py b/nextcloud_mcp_server/capabilities.py index cf181bc8..be7a00cf 100644 --- a/nextcloud_mcp_server/capabilities.py +++ b/nextcloud_mcp_server/capabilities.py @@ -94,6 +94,8 @@ async def allowed_doc_types( result = _parse_enabled_doc_types(payload) _cache[user_id] = (now, result) + # New key: __setitem__ already appends (no-op). Existing expired key: the + # update keeps its old position, so move it to the end to preserve LRU order. _cache.move_to_end(user_id) while len(_cache) > _CACHE_MAXSIZE: _cache.popitem(last=False) # evict least-recently-used diff --git a/nextcloud_mcp_server/server/semantic.py b/nextcloud_mcp_server/server/semantic.py index 07225aae..03b7fa5b 100644 --- a/nextcloud_mcp_server/server/semantic.py +++ b/nextcloud_mcp_server/server/semantic.py @@ -330,7 +330,7 @@ def configure_semantic_tools(mcp: FastMCP): if not doc_types: logger.info( "Semantic search short-circuited for user %s: no requested " - "doc_type is both installed and admin-approved", + "doc_type is admin-approved for semantic search", username, ) return SemanticSearchResponse( diff --git a/nextcloud_mcp_server/vector/scanner.py b/nextcloud_mcp_server/vector/scanner.py index 1587b078..738c74e3 100644 --- a/nextcloud_mcp_server/vector/scanner.py +++ b/nextcloud_mcp_server/vector/scanner.py @@ -291,13 +291,14 @@ _TEXT_BACKSTOP_DOC_TYPES: tuple[str, ...] = tuple(sorted(INDEXED_DOC_TYPES - {"f # already been enqueued, so a *standing* admin-disable doesn't re-flood the # processor with idempotent deletes on every scan tick. An entry is cleared once # the type is allowed again, so a later re-disable re-triggers the backstop. -_consent_backstop_done: set[tuple[str, str]] = set() +# A dict (not a set) so it stays insertion-ordered for oldest-first eviction. +_consent_backstop_done: dict[tuple[str, str], None] = {} -# Safety bound on the tracking set so a long-running multi-tenant process with +# Safety bound on the tracking dict so a long-running multi-tenant process with # heavy user churn (deprovisioned users leave stale entries) can't grow it # without limit. At <= len(INDEXED_DOC_TYPES) entries per user this is generous; -# on overflow we clear the whole set, which at worst re-fires the (idempotent) -# backstop once for currently-disabled types. +# on overflow we evict the *oldest* entries down to half capacity (not a full +# clear) so the backstop re-fires for only those, avoiding a fleet-wide burst. _CONSENT_BACKSTOP_MAX = 50_000 @@ -321,7 +322,7 @@ async def _enqueue_deletes_for_disabled_types( # re-triggers the backstop. for doc_type in _TEXT_BACKSTOP_DOC_TYPES: if doc_type in allowed: - _consent_backstop_done.discard((user_id, doc_type)) + _consent_backstop_done.pop((user_id, doc_type), None) # Disabled types not yet backstopped this episode. disabled = [ @@ -374,12 +375,18 @@ async def _enqueue_deletes_for_disabled_types( # Mark this (user, doc_type) backstopped for the current disable episode # so subsequent scans don't re-enqueue the same idempotent deletes. if len(_consent_backstop_done) >= _CONSENT_BACKSTOP_MAX: + # Evict oldest-first down to half capacity (insertion-ordered dict), + # so overflow re-fires the backstop for only the oldest markers + # rather than the whole fleet at once. + overage = len(_consent_backstop_done) - _CONSENT_BACKSTOP_MAX // 2 logger.info( - "consent backstop tracking set hit %d entries; clearing", + "consent backstop tracking hit %d entries; evicting %d oldest", _CONSENT_BACKSTOP_MAX, + overage, ) - _consent_backstop_done.clear() - _consent_backstop_done.add((user_id, doc_type)) + for stale_key in list(_consent_backstop_done)[:overage]: + del _consent_backstop_done[stale_key] + _consent_backstop_done[(user_id, doc_type)] = None return queued diff --git a/tests/unit/test_processor_drop_reason.py b/tests/unit/test_processor_drop_reason.py index ee1e05c9..109afd55 100644 --- a/tests/unit/test_processor_drop_reason.py +++ b/tests/unit/test_processor_drop_reason.py @@ -72,7 +72,7 @@ def test_nested_exception_group_descends_to_leaf(): def test_qdrant_namespace_classified(): from qdrant_client.http.exceptions import UnexpectedResponse - exc = UnexpectedResponse(500, "err", b"", headers=None) + exc = UnexpectedResponse(500, "err", b"", headers=httpx.Headers()) assert processor._drop_reason(exc) == "qdrant" @@ -109,3 +109,69 @@ async def test_process_document_records_drop_on_exhausted_retries(mocker): await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1) rec.assert_called_once_with("connection") + + +async def test_process_document_drops_admin_disabled_index_task(mocker): + """A near-real-time index task for an admin-disabled doc_type is dropped + before indexing, and recorded under the ``admin_disabled`` reason.""" + from nextcloud_mcp_server.vector.scanner import DocumentTask + + doc_task = DocumentTask( + user_id="alice", + doc_id="42", + doc_type="note", + operation="index", + modified_at=0, + file_path="/x.md", # set so the tag-reconcile branch is skipped + ) + + mocker.patch.object( + processor, + "get_qdrant_client", + mocker.AsyncMock(return_value=mocker.MagicMock()), + ) + # Admin disabled everything → note is not allowed. + mocker.patch.object( + processor, "allowed_doc_types", mocker.AsyncMock(return_value=frozenset()) + ) + index = mocker.patch.object(processor, "_index_document") + rec = mocker.patch.object(processor, "record_ingest_dropped") + + await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1) + + index.assert_not_called() + rec.assert_called_once_with("admin_disabled") + + +async def test_process_document_allows_when_doc_type_approved(mocker): + """The consent gate does not drop an index task for an allowed doc_type.""" + from nextcloud_mcp_server.vector.scanner import DocumentTask + + doc_task = DocumentTask( + user_id="alice", + doc_id="42", + doc_type="note", + operation="index", + modified_at=0, + file_path="/x.md", + ) + + mocker.patch.object( + processor, + "get_qdrant_client", + mocker.AsyncMock(return_value=mocker.MagicMock()), + ) + mocker.patch.object( + processor, + "allowed_doc_types", + mocker.AsyncMock(return_value=frozenset({"note"})), + ) + index = mocker.patch.object( + processor, "_index_document", mocker.AsyncMock(return_value=1) + ) + rec = mocker.patch.object(processor, "record_ingest_dropped") + + await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1) + + index.assert_awaited() # indexing proceeded + rec.assert_not_called() diff --git a/tests/unit/vector/test_purge.py b/tests/unit/vector/test_purge.py index 5aa3e8dd..ffb13f2c 100644 --- a/tests/unit/vector/test_purge.py +++ b/tests/unit/vector/test_purge.py @@ -53,6 +53,19 @@ async def test_purges_each_doc_type_and_reports_counts(monkeypatch): assert client.delete.await_count == 2 +async def test_purge_is_owner_agnostic_global(monkeypatch): + # The admin disable is global, so the delete filter must match by doc_type + # ONLY — no owner_id/user_id condition that would scope it to one user. + client = _patch_qdrant(monkeypatch, counts={"file": 1}) + + await purge_doc_types(["file"]) + + flt = client.delete.await_args.kwargs["points_selector"] + keys = [c.key for c in flt.must] + assert keys == ["doc_type"] + assert flt.must[0].match.value == "file" + + async def test_dedupes_doc_types(monkeypatch): client = _patch_qdrant(monkeypatch, counts={"file": 2})