fix(vector): address PR review round 10 — index chunk_index, harden index loop, lazy-init lock

Three coordinated fixes flagged as Important in the round-10 review of
PR #773:

1. Index chunk_index. The chunk-context fast path in
   _get_chunk_by_index_from_qdrant and get_chunk_bbox_and_page_from_qdrant
   filters on chunk_index, but the field was absent from
   _PAYLOAD_INDEX_FIELDS. On Qdrant Cloud strict mode every chunk-context
   lookup via chunk_index would 400 and silently fall back to the
   document re-fetch path — the exact failure mode the chunk_index
   shortcut exists to avoid. Added as INTEGER schema.

2. Catch raw network errors in _ensure_payload_indexes. The
   create_payload_index loop only caught UnexpectedResponse, so an
   httpx.ConnectError or asyncio.TimeoutError mid-loop would propagate
   uncaught — leaving _qdrant_client assigned and silently skipping all
   remaining fields. Added a broad Exception catch with the same
   per-field containment as the 5xx path: log at ERROR with exc_info,
   append to failed_fields, continue. New test covers the path.

3. Lazy-initialise _qdrant_init_lock. Constructing anyio.Lock() at
   module import time works for the asyncio backend but anyio's docs
   advise instantiating synchronization primitives within an async
   context, and pyproject.toml's anyio_mode = "auto" means tests can
   run under trio. Moved the construction into get_qdrant_client; safe
   under cooperative multitasking because there is no await between the
   None-check and the assignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-09 17:07:15 +02:00
co-authored by Claude Opus 4.7
parent d60348e77b
commit 47c531969f
2 changed files with 104 additions and 20 deletions
+44
View File
@@ -221,6 +221,50 @@ async def test_ensure_payload_indexes_logs_non_400_as_error(mocker, caplog):
assert "internal server error" in msg
@pytest.mark.unit
async def test_ensure_payload_indexes_continues_past_raw_network_error(mocker, caplog):
"""A raw network error (e.g. ConnectError, TimeoutError) must not skip the rest.
UnexpectedResponse covers HTTP-shaped failures, but transport-level
failures (httpx.ConnectError, asyncio.TimeoutError) reach the loop as
bare Exceptions. Without a broad catch, the first network blip
propagates, leaves _qdrant_client assigned, and silently skips every
remaining field. The fix is per-field containment matching the 5xx
behaviour: log at ERROR with exc_info, append to failed_fields, and
continue.
"""
client = mocker.AsyncMock()
client.get_collection.return_value = _empty_collection_info()
# First field hits a connection failure; remaining fields succeed.
client.create_payload_index.side_effect = [
ConnectionError("Connection refused"),
*([None] * (len(_PAYLOAD_INDEX_FIELDS) - 1)),
]
with caplog.at_level("WARNING", logger="nextcloud_mcp_server.vector.qdrant_client"):
await _ensure_payload_indexes(client, "test-collection")
# Loop continued past the failing field; every field was attempted.
assert client.create_payload_index.await_count == len(_PAYLOAD_INDEX_FIELDS)
errors = [r for r in caplog.records if r.levelname == "ERROR"]
assert len(errors) == 1
assert "Network error creating payload index" in errors[0].getMessage()
# exc_info is preserved so operators can see the underlying cause.
assert errors[0].exc_info is not None
assert errors[0].exc_info[0] is ConnectionError
# The partial-failure summary surfaces the field as missing.
summary_warnings = [
r
for r in caplog.records
if r.levelname == "WARNING"
and "Payload index creation incomplete" in r.getMessage()
]
assert len(summary_warnings) == 1
# The first field in _PAYLOAD_INDEX_FIELDS is the one that raised.
failing_field = next(iter(_PAYLOAD_INDEX_FIELDS))
assert failing_field in summary_warnings[0].getMessage()
@pytest.mark.unit
async def test_ensure_payload_indexes_logs_and_returns_when_get_collection_raises(
mocker, caplog