feat: add Qdrant local mode support with in-memory and persistent storage
Adds flexible Qdrant deployment modes to reduce infrastructure requirements
for local development and smaller deployments:
**Configuration Changes:**
- Add QDRANT_LOCATION environment variable (mutually exclusive with QDRANT_URL)
- Three modes: network (URL), in-memory (:memory:, default), persistent (file path)
- Settings dataclass validation via __post_init__ ensures mutual exclusivity
- API key warning when set in local mode (ignored, only for network mode)
**Client Initialization:**
- Auto-detect mode: network (url + api_key) vs local (:memory: or path=)
- In-memory: AsyncQdrantClient(":memory:") - zero config default
- Persistent: AsyncQdrantClient(path="/app/data/qdrant") - file storage
- Network: AsyncQdrantClient(url, api_key) - production mode
**Docker Compose Updates:**
- Qdrant service moved to optional profile (--profile qdrant)
- MCP service uses QDRANT_LOCATION=:memory: by default
- Added mcp-data volume for persistent storage (/app/data)
- No hard dependency on qdrant service
**Documentation:**
- Comprehensive configuration guide in docs/configuration.md
- All three modes documented with pros/cons
- Docker Compose examples for each mode
- Environment variable reference table
**Tests:**
- 13 new config validation tests (mutual exclusivity, defaults, warnings)
- Persistent mode integration test (create, close, reopen, verify persistence)
- All 82 unit tests + 5 smoke tests pass
**Breaking Change:**
- Default changed from QDRANT_URL=http://qdrant:6333 to QDRANT_LOCATION=:memory:
- Simplifies local development (no external service needed)
- Production deployments: explicitly set QDRANT_URL or QDRANT_LOCATION
Related: ADR-007 background vector sync implementation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,9 @@ Uses SimpleEmbeddingProvider for deterministic, in-process embeddings
|
||||
without requiring external services like Ollama.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||
@@ -342,3 +345,88 @@ async def test_batch_embedding(simple_embedding_provider: SimpleEmbeddingProvide
|
||||
for emb in embeddings:
|
||||
norm = math.sqrt(sum(x * x for x in emb))
|
||||
assert abs(norm - 1.0) < 1e-6
|
||||
|
||||
|
||||
async def test_qdrant_persistent_mode(
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
sample_notes: list[dict],
|
||||
):
|
||||
"""Test Qdrant in persistent local mode with file storage."""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
storage_path = Path(tmpdir) / "qdrant_data"
|
||||
|
||||
# Create first client with persistent storage using path parameter
|
||||
client1 = AsyncQdrantClient(path=str(storage_path))
|
||||
|
||||
try:
|
||||
collection_name = "test_persistent"
|
||||
|
||||
# Create collection and index notes
|
||||
await client1.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
# Index sample notes
|
||||
points = []
|
||||
for note in sample_notes:
|
||||
content = f"{note['title']}\n\n{note['content']}"
|
||||
embedding = await simple_embedding_provider.embed(content)
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=note["id"],
|
||||
vector=embedding,
|
||||
payload={
|
||||
"note_id": note["id"],
|
||||
"title": note["title"],
|
||||
"category": note["category"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await client1.upsert(
|
||||
collection_name=collection_name, points=points, wait=True
|
||||
)
|
||||
|
||||
# Verify data was written
|
||||
count_result = await client1.count(collection_name=collection_name)
|
||||
assert count_result.count == len(sample_notes)
|
||||
|
||||
# Close first client
|
||||
await client1.close()
|
||||
|
||||
# Create new client with same storage path
|
||||
client2 = AsyncQdrantClient(path=str(storage_path))
|
||||
|
||||
try:
|
||||
# Data should persist - verify collection exists
|
||||
collections = await client2.get_collections()
|
||||
collection_names = [c.name for c in collections.collections]
|
||||
assert collection_name in collection_names
|
||||
|
||||
# Verify indexed data persisted
|
||||
count_result = await client2.count(collection_name=collection_name)
|
||||
assert count_result.count == len(sample_notes)
|
||||
|
||||
# Verify search still works
|
||||
query = "Python programming"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await client2.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
)
|
||||
|
||||
# Should find Python note as top result
|
||||
assert len(response.points) > 0
|
||||
assert response.points[0].payload["note_id"] == 1
|
||||
|
||||
finally:
|
||||
await client2.close()
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await client1.close()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for configuration validation."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.config import Settings, get_settings
|
||||
|
||||
|
||||
class TestQdrantConfigValidation:
|
||||
"""Test Qdrant configuration validation."""
|
||||
|
||||
def test_mutually_exclusive_url_and_location(self):
|
||||
"""Test that setting both QDRANT_URL and QDRANT_LOCATION raises ValueError."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
|
||||
):
|
||||
Settings(
|
||||
qdrant_url="http://qdrant:6333",
|
||||
qdrant_location="/app/data/qdrant",
|
||||
)
|
||||
|
||||
def test_default_to_memory_mode(self):
|
||||
"""Test that :memory: is used when neither URL nor location is set."""
|
||||
settings = Settings()
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
def test_network_mode_only(self):
|
||||
"""Test network mode with only URL set."""
|
||||
settings = Settings(qdrant_url="http://qdrant:6333")
|
||||
assert settings.qdrant_url == "http://qdrant:6333"
|
||||
assert settings.qdrant_location is None
|
||||
|
||||
def test_local_mode_only(self):
|
||||
"""Test local mode with only location set."""
|
||||
settings = Settings(qdrant_location="/app/data/qdrant")
|
||||
assert settings.qdrant_location == "/app/data/qdrant"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
def test_in_memory_mode_explicit(self):
|
||||
"""Test explicit in-memory mode."""
|
||||
settings = Settings(qdrant_location=":memory:")
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
def test_api_key_warning_in_local_mode(self, caplog):
|
||||
"""Test that API key in local mode triggers warning."""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||
Settings(
|
||||
qdrant_location=":memory:",
|
||||
qdrant_api_key="test-api-key",
|
||||
)
|
||||
assert "API key is only relevant for network mode" in caplog.text
|
||||
|
||||
def test_api_key_no_warning_in_network_mode(self, caplog):
|
||||
"""Test that API key in network mode doesn't trigger warning."""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||
Settings(
|
||||
qdrant_url="http://qdrant:6333",
|
||||
qdrant_api_key="test-api-key",
|
||||
)
|
||||
assert "API key is only relevant for network mode" not in caplog.text
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Test get_settings() function with environment variables."""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_settings_defaults_to_memory(self):
|
||||
"""Test get_settings() defaults to :memory: when no env vars set."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"QDRANT_URL": "http://qdrant:6333",
|
||||
"QDRANT_API_KEY": "test-key",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_network_mode(self):
|
||||
"""Test get_settings() with network mode env vars."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_url == "http://qdrant:6333"
|
||||
assert settings.qdrant_api_key == "test-key"
|
||||
assert settings.qdrant_location is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"QDRANT_LOCATION": "/app/data/qdrant"},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_persistent_mode(self):
|
||||
"""Test get_settings() with persistent local mode env vars."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_location == "/app/data/qdrant"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"QDRANT_LOCATION": ":memory:"},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_explicit_memory(self):
|
||||
"""Test get_settings() with explicit :memory: env var."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"QDRANT_URL": "http://qdrant:6333",
|
||||
"QDRANT_LOCATION": "/app/data/qdrant",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_mutual_exclusion_error(self):
|
||||
"""Test get_settings() raises error when both URL and location set."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
|
||||
):
|
||||
get_settings()
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"QDRANT_COLLECTION": "test_collection",
|
||||
"VECTOR_SYNC_ENABLED": "true",
|
||||
"VECTOR_SYNC_SCAN_INTERVAL": "600",
|
||||
"VECTOR_SYNC_PROCESSOR_WORKERS": "5",
|
||||
"VECTOR_SYNC_QUEUE_MAX_SIZE": "5000",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_vector_sync_config(self):
|
||||
"""Test get_settings() with vector sync configuration."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_collection == "test_collection"
|
||||
assert settings.vector_sync_enabled is True
|
||||
assert settings.vector_sync_scan_interval == 600
|
||||
assert settings.vector_sync_processor_workers == 5
|
||||
assert settings.vector_sync_queue_max_size == 5000
|
||||
Reference in New Issue
Block a user