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
+60 -20
View File
@@ -19,19 +19,25 @@ from nextcloud_mcp_server.embedding import get_embedding_service
logger = logging.getLogger(__name__)
# Payload fields filtered by exact-match in scanner/processor/placeholder/eviction.
# Qdrant requires a payload index for any field used in a FieldCondition; without
# one, queries fail with HTTP 400 ("Index required but not found") on instances
# that enforce strict-mode index-required filtering (Qdrant Cloud, network mode
# with strict settings). The three string fields (doc_id, user_id, doc_type)
# carry str values after producer normalization, so KEYWORD is the correct
# schema; is_placeholder is the bool used by ``get_placeholder_filter`` and
# ``delete_placeholder_point`` (see vector/placeholder.py), so it gets BOOL.
# Payload fields filtered by exact-match in scanner/processor/placeholder/eviction
# and the chunk-context lookup path. Qdrant requires a payload index for any
# field used in a FieldCondition; without one, queries fail with HTTP 400
# ("Index required but not found") on instances that enforce strict-mode
# index-required filtering (Qdrant Cloud, network mode with strict settings).
# The three string fields (doc_id, user_id, doc_type) carry str values after
# producer normalization, so KEYWORD is the correct schema. is_placeholder is
# the bool used by ``get_placeholder_filter`` and ``delete_placeholder_point``
# (see vector/placeholder.py), so it gets BOOL. chunk_index is the int used by
# ``_get_chunk_by_index_from_qdrant`` and ``get_chunk_bbox_and_page_from_qdrant``
# (see search/context.py) — the always-indexed fast path that the offset-based
# fallback exists to avoid; it has to actually be indexed for that promise to
# hold on Qdrant Cloud strict mode.
_PAYLOAD_INDEX_FIELDS: dict[str, PayloadSchemaType] = {
"doc_id": PayloadSchemaType.KEYWORD,
"user_id": PayloadSchemaType.KEYWORD,
"doc_type": PayloadSchemaType.KEYWORD,
"is_placeholder": PayloadSchemaType.BOOL,
"chunk_index": PayloadSchemaType.INTEGER,
}
# Sentinel point that records "this collection has been backfilled to str
@@ -48,9 +54,16 @@ _DOC_ID_BACKFILL_SENTINEL_PAYLOAD: dict[str, str] = {"_migration_marker": "doc_i
# 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.
# and never acquire it. The lock is lazy-initialised inside
# ``get_qdrant_client`` rather than constructed at module import time:
# anyio's docs are explicit that synchronization primitives should be
# instantiated within an async context, and ``anyio_mode = "auto"`` in
# pyproject.toml means tests can run under trio where eager construction
# would fail. Construction is safe under cooperative multitasking — there
# is no ``await`` between the None-check and the assignment, so two
# coroutines cannot both create a lock.
_qdrant_client: AsyncQdrantClient | None = None
_qdrant_init_lock: anyio.Lock = anyio.Lock()
_qdrant_init_lock: anyio.Lock | None = None
async def _ensure_payload_indexes(
@@ -61,12 +74,16 @@ async def _ensure_payload_indexes(
"""Create payload indexes for fields used in exact-match filters.
Each entry in ``_PAYLOAD_INDEX_FIELDS`` is created with its declared
schema type (KEYWORD for string fields, BOOL for ``is_placeholder``).
Skips fields that are already in ``existing_schema`` so routine
restarts make no Qdrant write round-trips and emit no INFO log lines.
Schema conflicts (a pre-existing index with a different type) still
surface as a 400 — log loudly so operators can intervene, but keep
going so the remaining fields still get indexed.
schema type (KEYWORD for string fields, BOOL for ``is_placeholder``,
INTEGER for ``chunk_index``). Skips fields that are already in
``existing_schema`` so routine restarts make no Qdrant write round-trips
and emit no INFO log lines. Schema conflicts (a pre-existing index with
a different type) still surface as a 400 — log loudly so operators can
intervene, but keep going so the remaining fields still get indexed.
The same per-field error containment applies to raw network errors
(e.g. ``httpx.ConnectError`` from a transient Qdrant unavailability):
log at ERROR with ``exc_info`` and continue, so a single transient
failure on one field does not skip the rest.
Args:
client: Qdrant client instance.
@@ -115,9 +132,9 @@ async def _ensure_payload_indexes(
body = getattr(e, "content", b"") or b""
body_text = body.decode("utf-8", errors="replace")
# 400 is the expected schema-conflict path (index already exists
# with a different type). 5xx / network-shaped errors should not
# be silently downgraded — keep the loop going so the remaining
# fields still get attempted, but log at error so operators see it.
# with a different type). 5xx is unexpected — keep the loop going
# so the remaining fields still get attempted, but log at error
# so operators see it.
if e.status_code == 400:
logger.warning(
"Schema conflict on payload index '%s': %s", field, body_text
@@ -130,6 +147,21 @@ async def _ensure_payload_indexes(
body_text,
)
failed_fields.append(field)
except Exception:
# Raw network / timeout failures (httpx.ConnectError,
# asyncio.TimeoutError, etc.) reach here — outside the HTTP-status
# taxonomy that UnexpectedResponse covers. Same containment
# rationale as above: one transient failure on one field must not
# skip the rest, and the singleton in get_qdrant_client is already
# assigned by this point so re-raising would leave the process
# holding a usable client with the migration silently incomplete.
logger.error(
"Network error creating payload index on '%s'; "
"field will remain unindexed until next successful restart",
field,
exc_info=True,
)
failed_fields.append(field)
# A single per-field ERROR line is easy to miss in startup noise. Surface
# the partial-failure summary at WARNING so operators auditing the log
@@ -396,13 +428,21 @@ async def get_qdrant_client() -> AsyncQdrantClient:
Raises:
Exception: If Qdrant connection fails or collection creation fails
"""
global _qdrant_client
global _qdrant_client, _qdrant_init_lock
# 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
# Lazy-create the init lock on first cold-start. Safe under cooperative
# multitasking: there is no ``await`` between the None-check and the
# assignment, so two coroutines cannot both reach the construction.
# See the rationale on _qdrant_init_lock for why eager construction
# would break under the trio backend.
if _qdrant_init_lock is None:
_qdrant_init_lock = anyio.Lock()
# 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,
+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