Complete maintenance subsystem for index health and cleanup: Endpoints: - GET /maintenance/health - lightweight (or detailed) health check - POST /maintenance/cleanup/all - full orphan cleanup - POST /maintenance/cleanup/vectors - purge orphan vector chunks - POST /maintenance/cleanup/graph - purge orphan graph nodes - POST /maintenance/reconcile-index - cleanup + reindex missing pages Bidirectional orphan detection: - find_documents_without_vectors() in GraphService - find_chunks_without_graph_nodes() in VectorService Redis integration: - Tracks last_cleanup timestamp for scheduler visibility Config additions: - Document store, volatile cache, and maintenance settings - VectorServiceDep and GraphServiceDep type aliases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
489 lines
17 KiB
Python
489 lines
17 KiB
Python
"""
|
|
Tests for maintenance router and cleanup functionality.
|
|
|
|
Tests cleanup of:
|
|
- Orphan vector chunks
|
|
- Orphan entities in graph
|
|
- Stale document nodes
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from src.routers.maintenance import (
|
|
cleanup_vectors,
|
|
cleanup_graph,
|
|
cleanup_all,
|
|
maintenance_health,
|
|
reindex_page,
|
|
CleanupResult,
|
|
VectorCleanupResponse,
|
|
GraphCleanupResponse,
|
|
FullCleanupResponse,
|
|
HealthCheckResponse,
|
|
ReindexResponse
|
|
)
|
|
|
|
|
|
class TestCleanupResult:
|
|
"""Test CleanupResult model."""
|
|
|
|
def test_cleanup_result_defaults(self):
|
|
"""Test CleanupResult with default values."""
|
|
result = CleanupResult(duration_ms=100.0)
|
|
assert result.orphans_found == 0
|
|
assert result.orphans_purged == 0
|
|
assert result.duration_ms == 100.0
|
|
|
|
def test_cleanup_result_with_values(self):
|
|
"""Test CleanupResult with actual values."""
|
|
result = CleanupResult(
|
|
orphans_found=10,
|
|
orphans_purged=8,
|
|
duration_ms=250.5
|
|
)
|
|
assert result.orphans_found == 10
|
|
assert result.orphans_purged == 8
|
|
assert result.duration_ms == 250.5
|
|
|
|
|
|
class TestVectorCleanupResponse:
|
|
"""Test VectorCleanupResponse model."""
|
|
|
|
def test_vector_cleanup_response(self):
|
|
"""Test VectorCleanupResponse structure."""
|
|
response = VectorCleanupResponse(
|
|
success=True,
|
|
wiki_chunks=CleanupResult(orphans_found=5, orphans_purged=5, duration_ms=50),
|
|
document_chunks=CleanupResult(orphans_found=3, orphans_purged=3, duration_ms=50),
|
|
chunks_without_graph=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=50),
|
|
total_chunks_scanned=100,
|
|
total_orphans_purged=10,
|
|
duration_ms=100
|
|
)
|
|
assert response.success is True
|
|
assert response.wiki_chunks.orphans_found == 5
|
|
assert response.document_chunks.orphans_found == 3
|
|
assert response.chunks_without_graph.orphans_found == 2
|
|
assert response.total_orphans_purged == 10
|
|
|
|
|
|
class TestGraphCleanupResponse:
|
|
"""Test GraphCleanupResponse model."""
|
|
|
|
def test_graph_cleanup_response(self):
|
|
"""Test GraphCleanupResponse structure."""
|
|
response = GraphCleanupResponse(
|
|
success=True,
|
|
orphan_entities=CleanupResult(orphans_found=10, orphans_purged=10, duration_ms=25),
|
|
stale_wiki_documents=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=25),
|
|
stale_store_documents=CleanupResult(orphans_found=0, orphans_purged=0, duration_ms=25),
|
|
docs_without_vectors=CleanupResult(orphans_found=1, orphans_purged=1, duration_ms=25),
|
|
broken_relationships_cleaned=5,
|
|
duration_ms=100
|
|
)
|
|
assert response.success is True
|
|
assert response.orphan_entities.orphans_found == 10
|
|
assert response.docs_without_vectors.orphans_found == 1
|
|
assert response.broken_relationships_cleaned == 5
|
|
|
|
|
|
class TestHealthCheckResponse:
|
|
"""Test HealthCheckResponse model."""
|
|
|
|
def test_health_check_healthy(self):
|
|
"""Test healthy status."""
|
|
response = HealthCheckResponse(
|
|
status="healthy",
|
|
orphan_vector_count=0,
|
|
orphan_entity_count=0,
|
|
stale_document_count=0
|
|
)
|
|
assert response.status == "healthy"
|
|
assert response.recommendations == []
|
|
|
|
def test_health_check_degraded(self):
|
|
"""Test degraded status with recommendations."""
|
|
response = HealthCheckResponse(
|
|
status="degraded",
|
|
orphan_vector_count=15,
|
|
orphan_entity_count=3,
|
|
stale_document_count=0,
|
|
recommendations=[
|
|
"Found 15 orphan vector chunks. Consider running POST /maintenance/cleanup/vectors"
|
|
]
|
|
)
|
|
assert response.status == "degraded"
|
|
assert len(response.recommendations) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestVectorCleanup:
|
|
"""Test vector cleanup endpoint."""
|
|
|
|
async def test_cleanup_vectors_no_orphans(self):
|
|
"""Test cleanup when no orphans exist."""
|
|
# Mock services
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = [
|
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
|
]
|
|
# find_chunks_without_graph_nodes is not async
|
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.get_all_document_references.return_value = [
|
|
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
|
]
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
|
|
|
# Call cleanup
|
|
result = await cleanup_vectors(
|
|
user="testuser",
|
|
dry_run=False,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.wiki_chunks.orphans_found == 0
|
|
assert result.chunks_without_graph.orphans_found == 0
|
|
assert result.total_orphans_purged == 0
|
|
|
|
async def test_cleanup_vectors_with_orphans(self):
|
|
"""Test cleanup when orphans exist."""
|
|
# Mock services
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = [
|
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"},
|
|
{"chunk_id": "c2", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
|
{"chunk_id": "c3", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
|
]
|
|
vector_service.purge_chunks_by_ids.return_value = 2
|
|
# find_chunks_without_graph_nodes is not async
|
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.get_all_document_references.return_value = [
|
|
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
|
]
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
|
|
|
# Call cleanup
|
|
result = await cleanup_vectors(
|
|
user="testuser",
|
|
dry_run=False,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.wiki_chunks.orphans_found == 2
|
|
assert result.wiki_chunks.orphans_purged == 2
|
|
assert result.total_orphans_purged == 2
|
|
|
|
async def test_cleanup_vectors_dry_run(self):
|
|
"""Test cleanup dry run doesn't purge."""
|
|
# Mock services
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = [
|
|
{"chunk_id": "c1", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
|
]
|
|
# find_chunks_without_graph_nodes is not async
|
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.get_all_document_references.return_value = []
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = []
|
|
|
|
# Call cleanup in dry run mode
|
|
result = await cleanup_vectors(
|
|
user="testuser",
|
|
dry_run=True,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.wiki_chunks.orphans_found == 1
|
|
assert result.wiki_chunks.orphans_purged == 0 # Not purged due to dry run
|
|
vector_service.purge_chunks_by_ids.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGraphCleanup:
|
|
"""Test graph cleanup endpoint."""
|
|
|
|
async def test_cleanup_graph_no_orphans(self):
|
|
"""Test cleanup when no orphans exist."""
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = [
|
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
|
]
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.find_orphan_entities.return_value = []
|
|
graph_service.get_all_document_references.return_value = [
|
|
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
|
]
|
|
graph_service.find_documents_without_vectors.return_value = []
|
|
graph_service.cleanup_broken_relationships.return_value = 0
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
|
|
|
result = await cleanup_graph(
|
|
user="testuser",
|
|
dry_run=False,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.orphan_entities.orphans_found == 0
|
|
assert result.stale_wiki_documents.orphans_found == 0
|
|
assert result.docs_without_vectors.orphans_found == 0
|
|
|
|
async def test_cleanup_graph_with_orphan_entities(self):
|
|
"""Test cleanup of orphan entities."""
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = []
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.find_orphan_entities.return_value = [
|
|
{"id": "e1", "name": "Orphan1", "type": "Person"},
|
|
{"id": "e2", "name": "Orphan2", "type": "Technology"},
|
|
]
|
|
graph_service.purge_orphan_entities.return_value = 2
|
|
graph_service.get_all_document_references.return_value = []
|
|
graph_service.find_documents_without_vectors.return_value = []
|
|
graph_service.cleanup_broken_relationships.return_value = 0
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = []
|
|
|
|
result = await cleanup_graph(
|
|
user="testuser",
|
|
dry_run=False,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.orphan_entities.orphans_found == 2
|
|
assert result.orphan_entities.orphans_purged == 2
|
|
|
|
async def test_cleanup_graph_with_stale_documents(self):
|
|
"""Test cleanup of stale document nodes."""
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = [
|
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
|
]
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.find_orphan_entities.return_value = []
|
|
graph_service.get_all_document_references.return_value = [
|
|
{"page_id": 1, "doc_type": "wiki", "title": "Exists"},
|
|
{"page_id": 999, "doc_type": "wiki", "title": "Deleted"}, # Stale
|
|
]
|
|
graph_service.find_documents_without_vectors.return_value = []
|
|
graph_service.purge_stale_documents_by_ids.return_value = 1
|
|
graph_service.cleanup_broken_relationships.return_value = 0
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
|
|
|
result = await cleanup_graph(
|
|
user="testuser",
|
|
dry_run=False,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.stale_wiki_documents.orphans_found == 1
|
|
assert result.stale_wiki_documents.orphans_purged == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestFullCleanup:
|
|
"""Test full cleanup endpoint."""
|
|
|
|
async def test_full_cleanup(self):
|
|
"""Test full cleanup runs both vector and graph cleanup."""
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = []
|
|
# find_chunks_without_graph_nodes is not async
|
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.find_orphan_entities.return_value = []
|
|
graph_service.get_all_document_references.return_value = []
|
|
graph_service.find_documents_without_vectors.return_value = []
|
|
graph_service.cleanup_broken_relationships.return_value = 0
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = []
|
|
|
|
result = await cleanup_all(
|
|
user="testuser",
|
|
dry_run=False,
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.vector_cleanup.success is True
|
|
assert result.graph_cleanup.success is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestMaintenanceHealth:
|
|
"""Test maintenance health endpoint."""
|
|
|
|
async def test_health_healthy(self):
|
|
"""Test healthy status when no orphans."""
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = []
|
|
# find_chunks_without_graph_nodes is not async
|
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.find_orphan_entities.return_value = []
|
|
graph_service.get_all_document_references.return_value = []
|
|
graph_service.find_documents_without_vectors.return_value = []
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = []
|
|
|
|
result = await maintenance_health(
|
|
user="testuser",
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.status == "healthy"
|
|
assert result.orphan_vector_count == 0
|
|
assert result.orphan_entity_count == 0
|
|
assert result.vectors_without_graph == 0
|
|
assert result.docs_without_vectors == 0
|
|
|
|
async def test_health_degraded(self):
|
|
"""Test degraded status with orphans."""
|
|
vector_service = AsyncMock()
|
|
vector_service.get_all_chunk_references.return_value = [
|
|
{"chunk_id": f"c{i}", "page_id": 999, "doc_type": "wiki"}
|
|
for i in range(15)
|
|
]
|
|
# find_chunks_without_graph_nodes is not async
|
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.find_orphan_entities.return_value = [
|
|
{"id": f"e{i}", "name": f"Entity{i}", "type": "Entity"}
|
|
for i in range(3)
|
|
]
|
|
graph_service.get_all_document_references.return_value = []
|
|
graph_service.find_documents_without_vectors.return_value = []
|
|
|
|
wiki_client = AsyncMock()
|
|
wiki_client.list_all_pages.return_value = []
|
|
|
|
result = await maintenance_health(
|
|
user="testuser",
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
wiki_client=wiki_client,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.status == "degraded"
|
|
assert result.orphan_vector_count == 15
|
|
assert result.orphan_entity_count == 3
|
|
assert len(result.recommendations) >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestReindexPage:
|
|
"""Test reindex page endpoint."""
|
|
|
|
async def test_reindex_success(self):
|
|
"""Test successful page reindex."""
|
|
vector_service = AsyncMock()
|
|
vector_service.delete_page_chunks.return_value = 5
|
|
vector_service.update_from_page.return_value = MagicMock(
|
|
success=True,
|
|
chunks_created=6,
|
|
error_message=None
|
|
)
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.delete_page.return_value = 1
|
|
graph_service.update_from_page.return_value = MagicMock(
|
|
success=True,
|
|
error_message=None
|
|
)
|
|
|
|
result = await reindex_page(
|
|
page_id=123,
|
|
user="testuser",
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.page_id == 123
|
|
assert result.vectors_deleted == 5
|
|
assert result.vectors_created == 6
|
|
assert result.graph_updated is True
|
|
|
|
async def test_reindex_failure(self):
|
|
"""Test reindex with failure."""
|
|
vector_service = AsyncMock()
|
|
vector_service.delete_page_chunks.return_value = 0
|
|
vector_service.update_from_page.return_value = MagicMock(
|
|
success=False,
|
|
chunks_created=0,
|
|
error_message="Page not found"
|
|
)
|
|
|
|
graph_service = AsyncMock()
|
|
graph_service.delete_page.return_value = 0
|
|
graph_service.update_from_page.return_value = MagicMock(
|
|
success=False,
|
|
error_message="Page not found"
|
|
)
|
|
|
|
result = await reindex_page(
|
|
page_id=999,
|
|
user="testuser",
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
api_key="test"
|
|
)
|
|
|
|
assert result.success is False
|
|
assert result.error == "Page not found"
|