|
5 | 5 | from unittest.mock import AsyncMock |
6 | 6 |
|
7 | 7 | import pytest |
| 8 | +from sqlalchemy import text |
8 | 9 |
|
| 10 | +from basic_memory import db |
9 | 11 | from basic_memory.repository import EntityRepository |
10 | 12 | from basic_memory.repository.search_repository_base import VectorSyncBatchResult |
11 | 13 | from basic_memory.repository.semantic_errors import ( |
@@ -256,6 +258,98 @@ async def test_reindex_vectors_respects_embed_opt_out(search_service, monkeypatc |
256 | 258 | } |
257 | 259 |
|
258 | 260 |
|
| 261 | +@pytest.mark.asyncio |
| 262 | +async def test_reindex_vectors_purges_sqlite_vectors_before_sync(search_service, monkeypatch): |
| 263 | + """Regression for #829: stale vec0 rows must be purged before batch sync.""" |
| 264 | + repository = _sqlite_repo(search_service) |
| 265 | + repository._semantic_enabled = True |
| 266 | + calls: list[str] = [] |
| 267 | + |
| 268 | + monkeypatch.setattr( |
| 269 | + search_service.entity_repository, |
| 270 | + "find_all", |
| 271 | + AsyncMock(return_value=[SimpleNamespace(id=42, entity_metadata={})]), |
| 272 | + ) |
| 273 | + |
| 274 | + async def delete_stale_vector_rows(): |
| 275 | + calls.append("purge") |
| 276 | + |
| 277 | + async def sync_entity_vectors_batch(entity_ids, progress_callback=None): |
| 278 | + assert entity_ids == [42] |
| 279 | + assert progress_callback is None |
| 280 | + calls.append("sync") |
| 281 | + return VectorSyncBatchResult(entities_total=1, entities_synced=1, entities_failed=0) |
| 282 | + |
| 283 | + monkeypatch.setattr(repository, "delete_stale_vector_rows", delete_stale_vector_rows) |
| 284 | + monkeypatch.setattr(search_service, "sync_entity_vectors_batch", sync_entity_vectors_batch) |
| 285 | + |
| 286 | + stats = await search_service.reindex_vectors() |
| 287 | + |
| 288 | + assert calls == ["purge", "sync"] |
| 289 | + assert stats == { |
| 290 | + "total_entities": 1, |
| 291 | + "embedded": 1, |
| 292 | + "skipped": 0, |
| 293 | + "errors": 0, |
| 294 | + } |
| 295 | + |
| 296 | + |
| 297 | +@pytest.mark.asyncio |
| 298 | +async def test_reindex_all_uses_sqlite_vec_aware_drop(search_service, monkeypatch): |
| 299 | + """Full service reindex should not drop vec0 tables through a raw connection.""" |
| 300 | + repository = _sqlite_repo(search_service) |
| 301 | + executed_sql: list[str] = [] |
| 302 | + calls: list[str] = [] |
| 303 | + |
| 304 | + async def execute_query(query, params=None): |
| 305 | + executed_sql.append(str(query)) |
| 306 | + |
| 307 | + async def drop_vector_tables(): |
| 308 | + calls.append("drop_vector_tables") |
| 309 | + |
| 310 | + monkeypatch.setattr(repository, "execute_query", execute_query) |
| 311 | + monkeypatch.setattr(repository, "drop_vector_tables", drop_vector_tables) |
| 312 | + monkeypatch.setattr(search_service, "init_search_index", AsyncMock()) |
| 313 | + monkeypatch.setattr(search_service.entity_repository, "find_all", AsyncMock(return_value=[])) |
| 314 | + |
| 315 | + await search_service.reindex_all() |
| 316 | + |
| 317 | + assert calls == ["drop_vector_tables"] |
| 318 | + assert all("search_vector_embeddings" not in sql for sql in executed_sql) |
| 319 | + |
| 320 | + |
| 321 | +@pytest.mark.asyncio |
| 322 | +async def test_drop_vector_tables_skips_sqlite_vec_load_for_plain_table( |
| 323 | + search_service, |
| 324 | + monkeypatch, |
| 325 | +): |
| 326 | + """Plain compatibility tables should not require sqlite-vec to be loaded.""" |
| 327 | + repository = _sqlite_repo(search_service) |
| 328 | + await repository.drop_vector_tables() |
| 329 | + |
| 330 | + async with db.scoped_session(repository.session_maker) as session: |
| 331 | + await session.execute( |
| 332 | + text("CREATE TABLE search_vector_embeddings (rowid INTEGER PRIMARY KEY)") |
| 333 | + ) |
| 334 | + await session.commit() |
| 335 | + |
| 336 | + async def fail_if_loaded(_session): |
| 337 | + raise AssertionError("plain tables should not load sqlite-vec") |
| 338 | + |
| 339 | + monkeypatch.setattr(repository, "_ensure_sqlite_vec_loaded", fail_if_loaded) |
| 340 | + |
| 341 | + await repository.drop_vector_tables() |
| 342 | + |
| 343 | + async with db.scoped_session(repository.session_maker) as session: |
| 344 | + result = await session.execute( |
| 345 | + text( |
| 346 | + "SELECT name FROM sqlite_master " |
| 347 | + "WHERE type = 'table' AND name = 'search_vector_embeddings'" |
| 348 | + ) |
| 349 | + ) |
| 350 | + assert result.scalar() is None |
| 351 | + |
| 352 | + |
259 | 353 | @pytest.mark.asyncio |
260 | 354 | async def test_reindex_vectors_force_full_clears_project_vectors_before_resync( |
261 | 355 | search_service, monkeypatch |
|
0 commit comments