From fa933b08ff318c2933aa8a748416d0a11cfeb85a Mon Sep 17 00:00:00 2001 From: Ali Zarinkolah Date: Thu, 20 Aug 2026 18:17:56 +0330 Subject: [PATCH] fix(qdrant): verify collection existence in the readiness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - The chunks collection is now created by an explicit deployment step (qdrant_bootstrap), not at startup, which means a process can boot against a healthy Qdrant that has no collection at all. /readyz's previous check only called get_collections(), so it reported ready in that state — the misconfiguration stayed invisible until the first upload failed with a 502 after already paying for the MinIO write and embedding round trips. Changes: - ping_qdrant() now checks collection_exists(collection) instead of just reachability. --- src/api/routers/health.py | 4 +- src/infrastructure/qdrant/client.py | 18 +++++++-- tests/integration/qdrant/test_readiness.py | 45 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tests/integration/qdrant/test_readiness.py diff --git a/src/api/routers/health.py b/src/api/routers/health.py index e7ab59d..6813eb7 100644 --- a/src/api/routers/health.py +++ b/src/api/routers/health.py @@ -23,7 +23,9 @@ async def readyz(request: Request, response: Response) -> dict[str, bool]: postgres_ready, minio_ready, qdrant_ready = await asyncio.gather( ping_postgres(resources.db_engine, timeout), ping_minio(resources.minio_client, timeout), - ping_qdrant(resources.qdrant_client, timeout), + ping_qdrant( + resources.qdrant_client, timeout, collection=resources.settings.qdrant.collection + ), ) result = { diff --git a/src/infrastructure/qdrant/client.py b/src/infrastructure/qdrant/client.py index 9803f93..dd8fb31 100644 --- a/src/infrastructure/qdrant/client.py +++ b/src/infrastructure/qdrant/client.py @@ -9,10 +9,22 @@ def create_client(settings: QdrantSettings) -> AsyncQdrantClient: return AsyncQdrantClient(url=settings.url, api_key=settings.api_key) -async def ping(client: AsyncQdrantClient, timeout: float) -> bool: +async def ping(client: AsyncQdrantClient, timeout: float, *, collection: str) -> bool: + """Whether Qdrant is reachable **and** the `chunks` collection exists. + + Reachability alone is not readiness here. The collection is created by a + deployment step (`python -m src.cli.qdrant_bootstrap`, see ADR-0001 + "Collection provisioning"), so a process can boot against a healthy Qdrant + that has no collection at all. Without this check that misconfiguration + stays invisible until the first upload fails with a `502` — after the + request has already paid for the MinIO write and the embedding round trips. + + This is the Qdrant analogue of an unapplied Alembic migration, and it + belongs in `/readyz` for the same reason: it is a dependency-readiness + condition, not a process-health one. + """ try: async with asyncio.timeout(timeout): - await client.get_collections() + return await client.collection_exists(collection) except Exception: return False - return True diff --git a/tests/integration/qdrant/test_readiness.py b/tests/integration/qdrant/test_readiness.py new file mode 100644 index 0000000..d4ad27e --- /dev/null +++ b/tests/integration/qdrant/test_readiness.py @@ -0,0 +1,45 @@ +"""`/readyz`'s Qdrant probe against a real Qdrant. + +The case worth an integration test is the one a fake cannot produce +convincingly: Qdrant is up and answering, but the collection the deployment +step was supposed to create is not there. +""" + +import uuid + +import pytest +from qdrant_client import AsyncQdrantClient + +from src.config import QdrantSettings +from src.infrastructure.qdrant.client import ping +from src.infrastructure.qdrant.collection import ensure_chunks_collection + +pytestmark = [ + pytest.mark.integration, + pytest.mark.qdrant, + pytest.mark.asyncio(loop_scope="session"), +] + + +async def test_ping_is_not_ready_when_the_collection_was_never_bootstrapped( + qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings +) -> None: + """A healthy Qdrant with no collection is *not* ready: uploads would 502.""" + assert await ping(qdrant_client, 5.0, collection=qdrant_settings.collection) is False + + +async def test_ping_is_ready_after_bootstrap( + qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings +) -> None: + await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection) + + assert await ping(qdrant_client, 5.0, collection=qdrant_settings.collection) is True + + +async def test_ping_is_not_ready_for_a_different_collection_name( + qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings +) -> None: + """A misconfigured QDRANT_COLLECTION is as unready as a missing one.""" + await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection) + + assert await ping(qdrant_client, 5.0, collection=f"absent_{uuid.uuid4().hex}") is False