Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions src/mcp_server_qdrant/qdrant.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pydantic import BaseModel
from qdrant_client import AsyncQdrantClient, models
from qdrant_client.http.exceptions import UnexpectedResponse

from mcp_server_qdrant.embeddings.base import EmbeddingProvider
from mcp_server_qdrant.settings import METADATA_PATH
Expand Down Expand Up @@ -140,15 +141,22 @@ async def search(
async def _ensure_collection_exists(self, collection_name: str):
"""
Ensure that the collection exists, creating it if necessary.

Safe against concurrent callers: the check-then-create window is not
atomic against the server, so two callers can both see the collection
as missing and both attempt to create it. The loser gets a "collection
already exists" error, which we swallow after confirming the collection
is now present.

:param collection_name: The name of the collection to ensure exists.
"""
collection_exists = await self._client.collection_exists(collection_name)
if not collection_exists:
# Create the collection with the appropriate vector size
vector_size = self._embedding_provider.get_vector_size()
if await self._client.collection_exists(collection_name):
return

# Use the vector name as defined in the embedding provider
vector_name = self._embedding_provider.get_vector_name()
vector_size = self._embedding_provider.get_vector_size()
vector_name = self._embedding_provider.get_vector_name()

try:
await self._client.create_collection(
collection_name=collection_name,
vectors_config={
Expand All @@ -158,13 +166,28 @@ async def _ensure_collection_exists(self, collection_name: str):
)
},
)

# Create payload indexes if configured

if self._field_indexes:
for field_name, field_type in self._field_indexes.items():
except ValueError as e:
# Local mode raises ValueError("Collection ... already exists")
# when another caller wins the race between us. Treat as success.
if "already exists" not in str(e).lower():
raise
except UnexpectedResponse as e:
# Remote mode: server returns 409 Conflict on duplicate create.
if e.status_code != 409:
raise

if self._field_indexes:
for field_name, field_type in self._field_indexes.items():
try:
await self._client.create_payload_index(
collection_name=collection_name,
field_name=field_name,
field_schema=field_type,
)
except (ValueError, UnexpectedResponse):
# Payload index already exists (races or repeated calls).
logger.debug(
"payload index %r on %r already present, skipping",
field_name,
collection_name,
)
46 changes: 46 additions & 0 deletions tests/test_concurrent_collection_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import asyncio
import uuid
from unittest.mock import AsyncMock, patch

import pytest

from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider
from mcp_server_qdrant.qdrant import QdrantConnector


@pytest.fixture
async def embedding_provider():
return FastEmbedProvider(model_name="sentence-transformers/all-MiniLM-L6-v2")


@pytest.mark.asyncio
async def test_ensure_collection_exists_survives_toctou_race(embedding_provider):
"""
Regression test for the TOCTOU race in _ensure_collection_exists.

Against a real Qdrant server (REST or gRPC), two concurrent store() calls
into a not-yet-existing collection can both see collection_exists=False at
the check step, and then both call create_collection. The second one is
rejected by the server with an "already exists" error.

Local ":memory:" mode does not reproduce this because its awaits do not
yield inside the check-create window, so we force the interleaving with a
mocked collection_exists that always returns False, mimicking the network
race window a real server exposes.
"""
collection_name = f"race_{uuid.uuid4().hex}"
connector = QdrantConnector(
qdrant_url=":memory:",
qdrant_api_key=None,
collection_name=collection_name,
embedding_provider=embedding_provider,
)

with patch.object(
connector._client, "collection_exists", new=AsyncMock(return_value=False)
):
await asyncio.gather(
connector._ensure_collection_exists(collection_name),
connector._ensure_collection_exists(collection_name),
connector._ensure_collection_exists(collection_name),
)