fix(vector): address PR review round 9 — drop redundant guard, add init lock, test float doc_id path

Addresses the four 🟡 important findings from claude-bot review on PR #773:

str (non-Optional) and the guard would silently skip the Qdrant lookup
for an empty string. Removing the guard matches the type signature.

(`all([…, doc_id, …])` rejects None and empty string, plus
`assert doc_id is not None`). No code change needed.

`get_qdrant_client()` with a module-level `anyio.Lock`. Double-checked
locking keeps the steady-state hot path lock-free. Without this,
parallel cold-start callers could all enter the init block and run
`_backfill_doc_id_to_string` + `_ensure_payload_indexes` redundantly
(idempotent, but noisy). Pattern matches `auth/storage.py:2071`.

behavior with three tests covering the float-warning path (the gap
called out in the review), the str/None silent-skip paths, and the
int-grouping happy path.

Verification:
- ruff check / format: clean
- ty check -- nextcloud_mcp_server: clean
- uv run pytest tests/unit/: 969 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-09 15:53:33 +02:00
co-authored by Claude Opus 4.7
parent 0c14501a2b
commit fec1596784
4 changed files with 233 additions and 131 deletions
-1
View File
@@ -7,7 +7,6 @@ description: |
in this repo's automated PR reviews. Use when the user is about to push, says "ready in this repo's automated PR reviews. Use when the user is about to push, says "ready
to push", "review my work", "check before PR", or invokes /pre-push-review. to push", "review my work", "check before PR", or invokes /pre-push-review.
Report-only — does not modify code. Report-only — does not modify code.
model: sonnet
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
-1
View File
@@ -369,7 +369,6 @@ async def get_chunk_with_context(
# Prefer chunk_index lookup (always-indexed field) when caller supplied it; # Prefer chunk_index lookup (always-indexed field) when caller supplied it;
# fall back to (chunk_start, chunk_end) lookup otherwise. # fall back to (chunk_start, chunk_end) lookup otherwise.
chunk_text: str | None = None chunk_text: str | None = None
if doc_id:
if chunk_index is not None: if chunk_index is not None:
chunk_text = await _get_chunk_by_index_from_qdrant( chunk_text = await _get_chunk_by_index_from_qdrant(
user_id, doc_id, doc_type, chunk_index user_id, doc_id, doc_type, chunk_index
+28 -2
View File
@@ -3,6 +3,7 @@
import logging import logging
from typing import Any from typing import Any
import anyio
from qdrant_client import AsyncQdrantClient, models from qdrant_client import AsyncQdrantClient, models
from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.models import ( from qdrant_client.models import (
@@ -43,8 +44,13 @@ _PAYLOAD_INDEX_FIELDS: dict[str, PayloadSchemaType] = {
_DOC_ID_BACKFILL_SENTINEL_ID: str = "00000000-0000-0000-0000-d0c1d0d1d0c1" _DOC_ID_BACKFILL_SENTINEL_ID: str = "00000000-0000-0000-0000-d0c1d0d1d0c1"
_DOC_ID_BACKFILL_SENTINEL_PAYLOAD: dict[str, str] = {"_migration_marker": "doc_id_v1"} _DOC_ID_BACKFILL_SENTINEL_PAYLOAD: dict[str, str] = {"_migration_marker": "doc_id_v1"}
# Singleton instance # Singleton instance + init lock. The lock serialises concurrent first
# callers so the idempotent-but-expensive startup migration
# (``_backfill_doc_id_to_string`` + ``_ensure_payload_indexes``) only runs
# once per process. Steady-state callers hit the fast path above the lock
# and never acquire it.
_qdrant_client: AsyncQdrantClient | None = None _qdrant_client: AsyncQdrantClient | None = None
_qdrant_init_lock: anyio.Lock = anyio.Lock()
async def _ensure_payload_indexes( async def _ensure_payload_indexes(
@@ -392,6 +398,20 @@ async def get_qdrant_client() -> AsyncQdrantClient:
""" """
global _qdrant_client global _qdrant_client
# Fast path: already initialized — skip lock acquisition for the
# steady-state hot path (every MCP tool call after first start).
if _qdrant_client is not None:
return _qdrant_client
# Slow path: serialise concurrent first-callers so the idempotent-but-
# expensive startup migration (``_backfill_doc_id_to_string`` +
# ``_ensure_payload_indexes``) runs exactly once. Without this lock,
# parallel cold-start callers would all enter the init block, run the
# migration N times, and emit duplicate "skip-because-exists" warnings
# from the index helper — annoying log noise but not data corruption.
async with _qdrant_init_lock:
# Double-checked: another waiter may have initialized while we
# blocked on the lock.
if _qdrant_client is None: if _qdrant_client is None:
settings = get_settings() settings = get_settings()
@@ -411,7 +431,9 @@ async def get_qdrant_client() -> AsyncQdrantClient:
_qdrant_client = AsyncQdrantClient(":memory:") _qdrant_client = AsyncQdrantClient(":memory:")
else: else:
# Persistent local mode - use path parameter # Persistent local mode - use path parameter
logger.info(f"Using Qdrant persistent mode: {settings.qdrant_location}") logger.info(
f"Using Qdrant persistent mode: {settings.qdrant_location}"
)
_qdrant_client = AsyncQdrantClient(path=settings.qdrant_location) _qdrant_client = AsyncQdrantClient(path=settings.qdrant_location)
else: else:
# Should not happen due to __post_init__ validation, but handle gracefully # Should not happen due to __post_init__ validation, but handle gracefully
@@ -519,4 +541,8 @@ async def get_qdrant_client() -> AsyncQdrantClient:
_qdrant_client, collection_name, existing_schema={} _qdrant_client, collection_name, existing_schema={}
) )
# Lock released. ``_qdrant_client`` is guaranteed non-None here:
# either the fast path returned earlier, the lock-protected branch
# set it, or a sibling waiter set it before we got the lock.
assert _qdrant_client is not None
return _qdrant_client return _qdrant_client
+78
View File
@@ -28,6 +28,7 @@ from nextcloud_mcp_server.vector.qdrant_client import (
_PAYLOAD_INDEX_FIELDS, _PAYLOAD_INDEX_FIELDS,
_backfill_doc_id_to_string, _backfill_doc_id_to_string,
_ensure_payload_indexes, _ensure_payload_indexes,
_group_int_doc_ids,
) )
@@ -597,6 +598,83 @@ async def test_backfill_emits_progress_log_every_20_batches(mocker, caplog):
assert "test-collection" in progress_messages[0] assert "test-collection" in progress_messages[0]
# ---------------------------------------------------------------------------
# _group_int_doc_ids
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_group_int_doc_ids_skips_float_and_warns(caplog):
"""A float doc_id is not stringified; it logs WARNING and is skipped.
Producers always write int or str. A float would round-trip to e.g.
``"3.0"``, which the keyword index and verification path
(``int(doc_id)``) would never match. Skipping with a loud warning is
the only safe choice.
"""
float_point = SimpleNamespace(id=99, payload={"doc_id": 3.0})
int_point = SimpleNamespace(id=42, payload={"doc_id": 7})
with caplog.at_level("WARNING", logger="nextcloud_mcp_server.vector.qdrant_client"):
by_value, scanned = _group_int_doc_ids([float_point, int_point])
# Only the int point made it into by_value; float was dropped.
assert by_value == {"7": [42]}
# Both points still count toward the scanned total — the warning
# should not hide them from progress logs.
assert scanned == 2
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert len(warnings) == 1
msg = warnings[0].getMessage()
assert "float" in msg
assert "99" in msg
@pytest.mark.unit
def test_group_int_doc_ids_handles_str_and_missing_silently(caplog):
"""str / missing doc_id payloads are skipped without warning.
These are the steady-state paths — already-migrated str values and
sentinel-style points without a doc_id key. Neither should noise up
the log on every restart.
"""
str_point = SimpleNamespace(id=1, payload={"doc_id": "abc"})
none_payload_point = SimpleNamespace(id=2, payload=None)
missing_key_point = SimpleNamespace(id=3, payload={"other": "value"})
explicit_none_point = SimpleNamespace(id=4, payload={"doc_id": None})
with caplog.at_level("WARNING", logger="nextcloud_mcp_server.vector.qdrant_client"):
by_value, scanned = _group_int_doc_ids(
[str_point, none_payload_point, missing_key_point, explicit_none_point]
)
assert by_value == {}
assert scanned == 4
# No warnings — these paths are expected and silent.
assert not [r for r in caplog.records if r.levelname == "WARNING"]
@pytest.mark.unit
def test_group_int_doc_ids_groups_ints_by_str_value():
"""Multiple int-doc_id points sharing a value collapse into one entry.
Pins the chunk-batching contract: all chunks of one document share its
doc_id, so the helper hands ``_apply_backfill_writes`` a single key
with all chunk point-ids attached.
"""
by_value, scanned = _group_int_doc_ids(
[
SimpleNamespace(id=10, payload={"doc_id": 42}),
SimpleNamespace(id=11, payload={"doc_id": 42}),
SimpleNamespace(id=12, payload={"doc_id": 7}),
]
)
assert by_value == {"42": [10, 11], "7": [12]}
assert scanned == 3
@pytest.mark.unit @pytest.mark.unit
async def test_ensure_payload_indexes_summarises_failed_fields(mocker, caplog): async def test_ensure_payload_indexes_summarises_failed_fields(mocker, caplog):
"""A non-400 failure surfaces both as ERROR and a WARNING summary. """A non-400 failure surfaces both as ERROR and a WARNING summary.