Skip to content

Commit 07a8882

Browse files
committed
fix(core): address project index review bugs
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent b320496 commit 07a8882

4 files changed

Lines changed: 285 additions & 4 deletions

File tree

src/basic_memory/index/local_watch.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Iterable, Mapping
66
from dataclasses import dataclass
77
from pathlib import Path
8-
from typing import Generic, Protocol, TypeVar
8+
from typing import Generic, Protocol, TypeVar, runtime_checkable
99

1010
from watchfiles.main import FileChange
1111

@@ -38,6 +38,17 @@ class LocalWatchProjectSource(Protocol):
3838
def path(self) -> object: ...
3939

4040

41+
@runtime_checkable
42+
class LocalWatchProjectIdentitySource(Protocol):
43+
"""Optional stable project identity for local watcher storage prefixes."""
44+
45+
@property
46+
def permalink(self) -> object | None: ...
47+
48+
@property
49+
def name(self) -> object | None: ...
50+
51+
4152
def local_project_root(project: LocalWatchProjectSource) -> Path:
4253
"""Return the resolved filesystem root for a local watcher project."""
4354
project_path = str(project.path).strip() if project.path else ""
@@ -48,6 +59,12 @@ def local_project_root(project: LocalWatchProjectSource) -> Path:
4859

4960
def local_project_prefix(project: LocalWatchProjectSource) -> ProjectPath:
5061
"""Return the storage-event prefix for a local watcher project."""
62+
if isinstance(project, LocalWatchProjectIdentitySource):
63+
for project_identity in (project.permalink, project.name):
64+
project_prefix = str(project_identity).strip() if project_identity else ""
65+
if project_prefix:
66+
return project_prefix
67+
5168
return local_project_root(project).name
5269

5370

src/basic_memory/indexing/project_index_workflow.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
should_emit_project_index_progress_event,
3131
)
3232
from basic_memory.models import Entity, NoteContent, Relation
33+
from basic_memory.repository.project_repository import _load_sqlite_vec_on_session
3334
from basic_memory.runtime import (
3435
ProjectExternalId,
3536
ProjectId,
@@ -739,6 +740,40 @@ def missing_paths(self) -> tuple[str, ...]:
739740
AND entity_id IN :deleted_entity_ids
740741
""").bindparams(bindparam("deleted_entity_ids", expanding=True))
741742

743+
SELECT_PROJECT_INDEX_SQLITE_VECTOR_TABLES_SQL = text("""
744+
SELECT name
745+
FROM sqlite_master
746+
WHERE type = 'table'
747+
AND name IN ('search_vector_chunks', 'search_vector_embeddings')
748+
""")
749+
750+
SELECT_PROJECT_INDEX_POSTGRES_VECTOR_TABLES_SQL = text("""
751+
SELECT table_name
752+
FROM information_schema.tables
753+
WHERE table_schema = ANY (current_schemas(false))
754+
AND table_name IN ('search_vector_chunks', 'search_vector_embeddings')
755+
""")
756+
757+
DELETE_PROJECT_INDEX_SQLITE_VECTOR_EMBEDDINGS_SQL = text("""
758+
DELETE FROM search_vector_embeddings
759+
WHERE rowid IN (
760+
SELECT id
761+
FROM search_vector_chunks
762+
WHERE project_id = :project_id
763+
AND entity_id IN :deleted_entity_ids
764+
)
765+
""").bindparams(bindparam("deleted_entity_ids", expanding=True))
766+
767+
DELETE_PROJECT_INDEX_POSTGRES_VECTOR_EMBEDDINGS_SQL = text("""
768+
DELETE FROM search_vector_embeddings
769+
WHERE chunk_id IN (
770+
SELECT id
771+
FROM search_vector_chunks
772+
WHERE project_id = :project_id
773+
AND entity_id IN :deleted_entity_ids
774+
)
775+
""").bindparams(bindparam("deleted_entity_ids", expanding=True))
776+
742777
PROJECT_INDEX_SEARCH_INDEX_TABLE = table(
743778
"search_index",
744779
column("project_id"),
@@ -749,6 +784,62 @@ def missing_paths(self) -> tuple[str, ...]:
749784
)
750785

751786

787+
def project_index_session_dialect_name(session: AsyncSession) -> str:
788+
"""Return the SQLAlchemy dialect name for project-index maintenance."""
789+
return session.get_bind().dialect.name
790+
791+
792+
async def project_index_vector_table_names(session: AsyncSession) -> frozenset[str]:
793+
"""Return available vector table names for the current database backend."""
794+
dialect_name = project_index_session_dialect_name(session)
795+
if dialect_name == "sqlite":
796+
result = await session.execute(SELECT_PROJECT_INDEX_SQLITE_VECTOR_TABLES_SQL)
797+
elif dialect_name == "postgresql":
798+
result = await session.execute(SELECT_PROJECT_INDEX_POSTGRES_VECTOR_TABLES_SQL)
799+
else:
800+
raise RuntimeError(f"Unsupported project-index database dialect: {dialect_name}")
801+
802+
return frozenset(str(table_name) for table_name in result.scalars())
803+
804+
805+
async def delete_project_index_vector_rows(
806+
session: AsyncSession,
807+
*,
808+
project_id: ProjectId,
809+
entity_ids: Sequence[int],
810+
) -> None:
811+
"""Delete backend vector rows for project-index entity deletes when tables exist."""
812+
deleted_entity_ids = tuple(entity_ids)
813+
if not deleted_entity_ids:
814+
return
815+
816+
vector_table_names = await project_index_vector_table_names(session)
817+
if "search_vector_chunks" not in vector_table_names:
818+
return
819+
820+
delete_params = {
821+
"project_id": project_id,
822+
"deleted_entity_ids": deleted_entity_ids,
823+
}
824+
if "search_vector_embeddings" in vector_table_names:
825+
dialect_name = project_index_session_dialect_name(session)
826+
if dialect_name == "sqlite":
827+
if await _load_sqlite_vec_on_session(session):
828+
await session.execute(
829+
DELETE_PROJECT_INDEX_SQLITE_VECTOR_EMBEDDINGS_SQL,
830+
delete_params,
831+
)
832+
elif dialect_name == "postgresql":
833+
await session.execute(
834+
DELETE_PROJECT_INDEX_POSTGRES_VECTOR_EMBEDDINGS_SQL,
835+
delete_params,
836+
)
837+
else:
838+
raise RuntimeError(f"Unsupported project-index database dialect: {dialect_name}")
839+
840+
await session.execute(DELETE_PROJECT_INDEX_VECTOR_CHUNKS_SQL, delete_params)
841+
842+
752843
async def delete_project_index_entities(
753844
session: AsyncSession,
754845
*,
@@ -779,7 +870,11 @@ async def delete_project_index_entities(
779870
"relation_row_type": "relation",
780871
}
781872
await session.execute(DELETE_PROJECT_INDEX_SEARCH_ROWS_SQL, delete_params)
782-
await session.execute(DELETE_PROJECT_INDEX_VECTOR_CHUNKS_SQL, delete_params)
873+
await delete_project_index_vector_rows(
874+
session,
875+
project_id=project_id,
876+
entity_ids=deleted_entity_ids,
877+
)
783878
await session.execute(
784879
delete(Entity).where(
785880
Entity.project_id == project_id,

tests/index/test_local_watch_orchestration.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,47 @@ def test_local_watch_request_builds_storage_prefix_from_project(tmp_path: Path)
159159
assert [event.object_key for event in source.events()] == ["configured-project-root/notes/a.md"]
160160

161161

162+
def test_local_watch_request_uses_project_permalink_for_duplicate_leaf_roots(
163+
tmp_path: Path,
164+
) -> None:
165+
alpha_root = tmp_path / "alpha" / "notes"
166+
beta_root = tmp_path / "beta" / "notes"
167+
alpha_note = alpha_root / "a.md"
168+
beta_note = beta_root / "b.md"
169+
alpha_note.parent.mkdir(parents=True)
170+
beta_note.parent.mkdir(parents=True)
171+
alpha_note.write_text("# A\n", encoding="utf-8")
172+
beta_note.write_text("# B\n", encoding="utf-8")
173+
alpha = SimpleNamespace(
174+
path=str(alpha_root),
175+
name="Alpha Notes",
176+
permalink="alpha-notes",
177+
)
178+
beta = SimpleNamespace(
179+
path=str(beta_root),
180+
name="Beta Notes",
181+
permalink="beta-notes",
182+
)
183+
184+
alpha_request = LocalWatchEventIndexRequest.from_project_changes(
185+
project=alpha,
186+
changes=((Change.added, str(alpha_note)),),
187+
)
188+
beta_request = LocalWatchEventIndexRequest.from_project_changes(
189+
project=beta,
190+
changes=((Change.added, str(beta_note)),),
191+
)
192+
193+
assert alpha_request.project_prefix == "alpha-notes"
194+
assert beta_request.project_prefix == "beta-notes"
195+
assert [event.object_key for event in LocalWatchStorageEventSource(alpha_request).events()] == [
196+
"alpha-notes/a.md"
197+
]
198+
assert [event.object_key for event in LocalWatchStorageEventSource(beta_request).events()] == [
199+
"beta-notes/b.md"
200+
]
201+
202+
162203
def test_local_watch_project_change_batches_route_changes_to_projects(tmp_path: Path) -> None:
163204
alpha_root = tmp_path / "alpha"
164205
beta_root = tmp_path / "beta"

tests/indexing/test_project_index_workflow.py

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import AsyncIterator, Iterator
44
from contextlib import asynccontextmanager
55
from dataclasses import dataclass, field
6+
from types import SimpleNamespace
67
from typing import cast
78
from uuid import UUID
89

@@ -154,9 +155,13 @@ class FakeProjectIndexSession:
154155
"""Record repository maintenance statements without a real database."""
155156

156157
results: list[FakeProjectIndexResult]
158+
dialect_name: str = "sqlite"
157159
statements: list[object] = field(default_factory=list)
158160
params: list[object | None] = field(default_factory=list)
159161

162+
def get_bind(self) -> SimpleNamespace:
163+
return SimpleNamespace(dialect=SimpleNamespace(name=self.dialect_name))
164+
160165
async def execute(
161166
self,
162167
statement: object,
@@ -776,7 +781,7 @@ async def fake_scoped_session(
776781
assert "SELECT entity.id, entity.file_path" in str(session.statements[1])
777782
assert "SELECT DISTINCT relation.from_id" in str(session.statements[2])
778783
assert "DELETE FROM search_index" in str(session.statements[3])
779-
assert "DELETE FROM search_vector_chunks" in str(session.statements[4])
784+
assert "sqlite_master" in str(session.statements[4])
780785
assert "DELETE FROM entity" in str(session.statements[5])
781786
assert "UPDATE entity" in str(session.statements[6])
782787
assert "UPDATE note_content" in str(session.statements[7])
@@ -907,10 +912,133 @@ async def fake_scoped_session(
907912
assert "SELECT entity.id, entity.file_path" in str(session.statements[0])
908913
assert "SELECT DISTINCT relation.from_id" in str(session.statements[1])
909914
assert "DELETE FROM search_index" in str(session.statements[2])
910-
assert "DELETE FROM search_vector_chunks" in str(session.statements[3])
915+
assert "sqlite_master" in str(session.statements[3])
911916
assert "DELETE FROM entity" in str(session.statements[4])
912917

913918

919+
@pytest.mark.asyncio
920+
async def test_repository_project_index_maintenance_store_deletes_vector_embeddings_before_chunks(
921+
monkeypatch: pytest.MonkeyPatch,
922+
) -> None:
923+
session_maker = cast(async_sessionmaker[AsyncSession], object())
924+
session = FakeProjectIndexSession(
925+
results=[
926+
FakeProjectIndexResult(
927+
mapping_rows=[
928+
{"id": 10, "file_path": "notes/a.md"},
929+
]
930+
),
931+
FakeProjectIndexResult(scalar_values=[]),
932+
FakeProjectIndexResult(),
933+
FakeProjectIndexResult(
934+
scalar_values=["search_vector_chunks", "search_vector_embeddings"]
935+
),
936+
]
937+
)
938+
939+
@asynccontextmanager
940+
async def fake_scoped_session(
941+
scoped_session_maker: async_sessionmaker[AsyncSession],
942+
) -> AsyncIterator[FakeProjectIndexSession]:
943+
assert scoped_session_maker is session_maker
944+
yield session
945+
946+
monkeypatch.setattr(
947+
project_index_workflow_module.db,
948+
"scoped_session",
949+
fake_scoped_session,
950+
)
951+
sqlite_vec_sessions: list[FakeProjectIndexSession] = []
952+
953+
async def fake_load_sqlite_vec_on_session(
954+
loaded_session: FakeProjectIndexSession,
955+
) -> bool:
956+
sqlite_vec_sessions.append(loaded_session)
957+
return True
958+
959+
monkeypatch.setattr(
960+
project_index_workflow_module,
961+
"_load_sqlite_vec_on_session",
962+
fake_load_sqlite_vec_on_session,
963+
)
964+
965+
store = RepositoryProjectIndexMaintenanceStore(
966+
session_maker=session_maker,
967+
project_id=42,
968+
)
969+
970+
result = await store.apply_project_index_delete_batch(
971+
ProjectIndexDeleteBatch(
972+
completed_batches=1,
973+
paths=("notes/a.md",),
974+
)
975+
)
976+
977+
assert result.deleted_entities == 1
978+
assert sqlite_vec_sessions == [session]
979+
statements = [str(statement) for statement in session.statements]
980+
embedding_delete_index = next(
981+
index
982+
for index, statement in enumerate(statements)
983+
if "DELETE FROM search_vector_embeddings" in statement
984+
)
985+
chunk_delete_index = next(
986+
index
987+
for index, statement in enumerate(statements)
988+
if "DELETE FROM search_vector_chunks" in statement
989+
)
990+
assert embedding_delete_index < chunk_delete_index
991+
992+
993+
@pytest.mark.asyncio
994+
async def test_repository_project_index_maintenance_store_skips_vector_cleanup_when_tables_missing(
995+
monkeypatch: pytest.MonkeyPatch,
996+
) -> None:
997+
session_maker = cast(async_sessionmaker[AsyncSession], object())
998+
session = FakeProjectIndexSession(
999+
results=[
1000+
FakeProjectIndexResult(
1001+
mapping_rows=[
1002+
{"id": 10, "file_path": "notes/a.md"},
1003+
]
1004+
),
1005+
FakeProjectIndexResult(scalar_values=[]),
1006+
FakeProjectIndexResult(),
1007+
FakeProjectIndexResult(scalar_values=[]),
1008+
]
1009+
)
1010+
1011+
@asynccontextmanager
1012+
async def fake_scoped_session(
1013+
scoped_session_maker: async_sessionmaker[AsyncSession],
1014+
) -> AsyncIterator[FakeProjectIndexSession]:
1015+
assert scoped_session_maker is session_maker
1016+
yield session
1017+
1018+
monkeypatch.setattr(
1019+
project_index_workflow_module.db,
1020+
"scoped_session",
1021+
fake_scoped_session,
1022+
)
1023+
1024+
store = RepositoryProjectIndexMaintenanceStore(
1025+
session_maker=session_maker,
1026+
project_id=42,
1027+
)
1028+
1029+
result = await store.apply_project_index_delete_batch(
1030+
ProjectIndexDeleteBatch(
1031+
completed_batches=1,
1032+
paths=("notes/a.md",),
1033+
)
1034+
)
1035+
1036+
assert result.deleted_entities == 1
1037+
statements = [str(statement) for statement in session.statements]
1038+
assert not any("DELETE FROM search_vector_chunks" in statement for statement in statements)
1039+
assert not any("DELETE FROM search_vector_embeddings" in statement for statement in statements)
1040+
1041+
9141042
def test_project_index_workflow_start_builds_existing_metadata_and_attempt_event() -> None:
9151043
tenant_id = UUID("11111111-1111-1111-1111-111111111111")
9161044
workflow_id = UUID("22222222-2222-2222-2222-222222222222")

0 commit comments

Comments
 (0)