fix(vector): offload embedded Qdrant ops to a worker thread (#926)

qdrant-client's embedded backends (:memory: and path= local mode) run
every operation synchronously on the calling thread despite the
AsyncQdrantClient surface — AsyncQdrantLocal contains no thread offload,
and this is unchanged through the latest v1.18.x (the async surface is
autogenerated from the synchronous QdrantLocal).

On a CPU-constrained host a background scan of thousands of tagged files
issues ~3 Qdrant queries per file, all on the event loop thread, pinning
one core at 100% and stalling /health/live, /health/ready, and the
outbound Nextcloud-reachability probe for minutes — the failure mode in
issue #926 (health-check timeouts, "nextcloud_reachable: error").

Wrap the embedded client in a transparent proxy that offloads every
coroutine-returning call to a worker thread via anyio.to_thread.run_sync,
keeping the event loop responsive. A dedicated CapacityLimiter(1)
serialises those offloads to preserve QdrantLocal's single-access
invariant (today guaranteed implicitly by the single-threaded loop).
Network mode (QDRANT_URL) is left untouched — it already does
non-blocking I/O. Centralised at get_qdrant_client() so all call sites
benefit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-19 10:11:28 +02:00
co-authored by Claude Opus 4.8
parent a587b2113f
commit 1701131017
2 changed files with 240 additions and 3 deletions
+97 -1
View File
@@ -1,9 +1,14 @@
"""Qdrant client wrapper."""
import functools
import inspect
import logging
from typing import Any
from collections.abc import Coroutine
from typing import Any, cast
import anyio
import anyio.to_thread
from anyio import CapacityLimiter
from qdrant_client import AsyncQdrantClient, models
from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.models import (
@@ -111,6 +116,84 @@ _qdrant_client: AsyncQdrantClient | None = None
_qdrant_init_lock: anyio.Lock | None = None
def _drive_local_coroutine(coro: Coroutine[Any, Any, Any]) -> Any:
"""Run an embedded-Qdrant coroutine to completion on the current thread.
qdrant-client's local/in-memory backend (``AsyncQdrantLocal``) exposes an
``async def`` surface over purely synchronous, CPU-bound work: its
coroutines never await real I/O, so they complete on the first ``send``
(verified against qdrant-client 1.17). Driving one here — inside the worker
thread dispatched by :func:`anyio.to_thread.run_sync` — is what moves that
CPU work off the event loop.
A genuine suspension (a non-``None`` yield) would mean the backend started
doing real async I/O, which a plain thread cannot drive correctly. Fail
loudly in that case rather than spin or silently mis-drive the coroutine.
"""
try:
while True:
yielded = coro.send(None)
if yielded is not None:
coro.close()
raise RuntimeError(
"embedded Qdrant coroutine suspended unexpectedly "
f"(yielded {yielded!r}); the thread-offload shim in "
"vector/qdrant_client.py assumes the local backend is "
"synchronous"
)
except StopIteration as exc:
return exc.value
class _ThreadOffloadingQdrantClient:
"""Run embedded-Qdrant operations on a worker thread to free the event loop.
qdrant-client's embedded backends — ``:memory:`` and ``path=`` local mode —
execute every operation synchronously on the calling thread despite the
``AsyncQdrantClient`` surface (``AsyncQdrantLocal`` contains no
``to_thread``/executor offload). A single background scan of thousands of
tagged files therefore pins the event loop on a CPU-constrained host, so
``/health/live`` / ``/health/ready`` probes and in-flight MCP requests stall
until the scan finishes — the failure mode reported in issue #926.
This proxy forwards attribute access to the wrapped client and offloads
every coroutine-returning call to a worker thread, keeping the event loop
responsive. A private ``CapacityLimiter(1)`` serialises those offloads:
``AsyncQdrantLocal`` is not safe for concurrent access, and today the
single-threaded event loop is what implicitly serialises it — the limiter
preserves that invariant once the work runs off-loop, while still letting
the loop interleave health checks and other requests between operations.
Network mode (``QDRANT_URL``) is never wrapped: there the client performs
real non-blocking I/O and already yields to the event loop.
"""
def __init__(self, inner: AsyncQdrantClient) -> None:
self._inner = inner
self._limiter = CapacityLimiter(1)
def __getattr__(self, name: str) -> Any:
# Only reached for names absent from the instance __dict__; ``_inner``
# and ``_limiter`` resolve normally and never recurse through here.
attr = getattr(self._inner, name)
if not callable(attr):
return attr
@functools.wraps(attr)
def _maybe_offload(*args: Any, **kwargs: Any) -> Any:
result = attr(*args, **kwargs)
if inspect.iscoroutine(result):
return self._offload(result)
return result
return _maybe_offload
async def _offload(self, coro: Coroutine[Any, Any, Any]) -> Any:
return await anyio.to_thread.run_sync(
_drive_local_coroutine, coro, limiter=self._limiter
)
async def _create_one_payload_index(
client: AsyncQdrantClient,
collection_name: str,
@@ -596,6 +679,19 @@ async def get_qdrant_client() -> AsyncQdrantClient:
logger.warning("No Qdrant mode configured, defaulting to :memory:")
provisional = AsyncQdrantClient(":memory:")
# Embedded Qdrant (``:memory:`` / ``path=``) runs every operation
# synchronously on the calling thread, so without this each call —
# including the O(N) startup backfill scroll below and every later
# scan/search query — would block the event loop and stall health
# checks on a CPU-constrained host (issue #926). Offload local-mode
# work to a worker thread. Network mode (mirrors the ``if
# settings.qdrant_url`` branch above) already does non-blocking I/O
# and is left untouched.
if not settings.qdrant_url:
provisional = cast(
AsyncQdrantClient, _ThreadOffloadingQdrantClient(provisional)
)
# Get collection name (auto-generated from deployment ID + model)
collection_name = settings.get_collection_name()