Skip to content

Commit 9e3fe26

Browse files
authored
fix(core): purge SQLite search_index on project delete (#832)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 47ee982 commit 9e3fe26

5 files changed

Lines changed: 578 additions & 5 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Remove orphaned search rows whose project was already deleted.
2+
3+
Revision ID: n7i8j9k0l1m2
4+
Revises: m6h7i8j9k0l1
5+
Create Date: 2026-05-15 18:30:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
from sqlalchemy import inspect
13+
14+
15+
revision: str = "n7i8j9k0l1m2"
16+
down_revision: Union[str, None] = "m6h7i8j9k0l1"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def _table_exists(connection, table_name: str) -> bool:
22+
"""Inspector-based table check, dialect agnostic.
23+
24+
Trigger: SQLite creates search_index as an FTS5 virtual table at runtime
25+
via SearchRepository.init_search_index, not through Alembic, so fresh
26+
installs hit this migration before the table exists.
27+
Why: a blind DELETE against a missing table fails the whole upgrade.
28+
Outcome: callers skip the sweep when the table isn't present yet — the
29+
runtime-created table on a fresh DB has no orphans to clean.
30+
"""
31+
return table_name in inspect(connection).get_table_names()
32+
33+
34+
def upgrade() -> None:
35+
"""Purge orphaned search rows left over from prior project deletions.
36+
37+
Trigger: project deletion on SQLite never removed the derived FTS rows,
38+
because the FTS5 virtual table can't carry a foreign key. The leak shows
39+
up in two shapes:
40+
1. project_id no longer exists in `project` (deleted project, id never
41+
reused).
42+
2. project_id still exists but `entity_id` no longer exists in `entity`
43+
— auto-increment handed the id to a brand-new project and the FTS
44+
rows from the deleted predecessor masquerade as the new tenant's data.
45+
Why: search_index.project_id is the only scope predicate the search
46+
repository applies, so leftover rows surface under the wrong project on
47+
every search.
48+
Outcome: a one-time sweep deletes both shapes, from the FTS index and
49+
from search_vector_chunks. Postgres already cascaded on FK delete, so
50+
these statements are no-ops there.
51+
"""
52+
connection = op.get_bind()
53+
54+
if _table_exists(connection, "search_index"):
55+
op.execute(
56+
"""
57+
DELETE FROM search_index
58+
WHERE project_id NOT IN (SELECT id FROM project)
59+
"""
60+
)
61+
op.execute(
62+
"""
63+
DELETE FROM search_index
64+
WHERE entity_id IS NOT NULL
65+
AND entity_id NOT IN (SELECT id FROM entity)
66+
"""
67+
)
68+
69+
if _table_exists(connection, "search_vector_chunks"):
70+
op.execute(
71+
"""
72+
DELETE FROM search_vector_chunks
73+
WHERE project_id NOT IN (SELECT id FROM project)
74+
"""
75+
)
76+
op.execute(
77+
"""
78+
DELETE FROM search_vector_chunks
79+
WHERE entity_id NOT IN (SELECT id FROM entity)
80+
"""
81+
)
82+
83+
84+
def downgrade() -> None:
85+
"""No-op: orphan rows cannot be reconstructed."""
86+
pass

src/basic_memory/mcp/tools/search.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
from fastmcp import Context
1111
from pydantic import AliasChoices, BeforeValidator, Field
1212

13-
from basic_memory.config import ConfigManager
13+
from basic_memory.config import ConfigManager, has_cloud_credentials
1414
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list
15+
from basic_memory.mcp.async_client import (
16+
_explicit_routing,
17+
_force_local_mode,
18+
is_factory_mode,
19+
)
1520
from basic_memory.mcp.container import get_container
1621
from basic_memory.mcp.project_context import (
1722
detect_project_from_identifier_prefix,
@@ -479,12 +484,30 @@ async def _search_all_projects(
479484
total = 0
480485
any_project_has_more = False
481486

487+
# Trigger: caller asked for an account-wide search.
488+
# Why: project_id (external UUID) routes through the cloud v2 API path,
489+
# which 401s on local installs because there's no JWT to present.
490+
# Project names route through the local-ASGI path and work for both
491+
# backends — cloud disambiguates names via the workspace/project
492+
# qualified_name already baked into project_ref["project"].
493+
# Outcome: forward project_id only when the same signals get_project_client
494+
# uses to pick a cloud route are present. Mirrors the cloud_available
495+
# composite in project_context.get_project_client (single source of
496+
# truth for "can we route to cloud?").
497+
config = ConfigManager().config
498+
use_cloud_routing = (
499+
is_factory_mode()
500+
or (_explicit_routing() and not _force_local_mode())
501+
or has_cloud_credentials(config)
502+
)
503+
482504
for project_ref in project_refs:
505+
recursive_project_id = project_ref["project_id"] if use_cloud_routing else None
483506
try:
484507
results = await search_notes(
485508
query=query,
486509
project=project_ref["project"],
487-
project_id=project_ref["project_id"],
510+
project_id=recursive_project_id,
488511
page=1,
489512
page_size=per_project_page_size,
490513
search_type=search_type,

src/basic_memory/repository/project_repository.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,75 @@
44
from typing import Optional, Sequence, Union
55

66

7-
from sqlalchemy import text
7+
from loguru import logger
8+
from sqlalchemy import inspect as sa_inspect, select, text
9+
from sqlalchemy.exc import NoResultFound, OperationalError
810
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
911

1012
from basic_memory import db
1113
from basic_memory.models.project import Project
1214
from basic_memory.repository.repository import Repository
1315

1416

17+
async def _load_sqlite_vec_on_session(session) -> bool:
18+
"""Ensure the sqlite-vec extension is loaded on this session's connection.
19+
20+
Returns True when vec0 is available after the call. Returns False when the
21+
extension can't be loaded on this Python build (e.g., python.org macOS or
22+
Windows interpreters without `enable_load_extension`) — every connection in
23+
the pool shares the same interpreter, so a False here also means no
24+
embedding row could ever have been written, and skipping the embeddings
25+
purge is safe.
26+
27+
Mirrors SQLiteSearchRepository._ensure_sqlite_vec_loaded but as a free
28+
function: we don't have a SearchRepository instance during project delete,
29+
and the per-connection nature of extension loading means a pooled connection
30+
routed to this session might not have vec loaded even when other
31+
connections wrote embeddings.
32+
"""
33+
try:
34+
await session.execute(text("SELECT vec_version()"))
35+
return True
36+
except OperationalError:
37+
pass
38+
39+
try:
40+
import sqlite_vec # type: ignore[import-not-found]
41+
except ImportError:
42+
logger.debug("sqlite-vec package not installed; skipping vec purge")
43+
return False
44+
45+
async_connection = await session.connection()
46+
raw_connection = await async_connection.get_raw_connection()
47+
driver_connection = raw_connection.driver_connection
48+
49+
if not hasattr(driver_connection, "enable_load_extension"):
50+
# Trigger: CPython build without sqlite extension support (#711).
51+
# Why: load_extension is unavailable, so no connection in this pool
52+
# can host vec0. No embeddings exist anywhere.
53+
# Outcome: skip the embeddings purge entirely.
54+
logger.debug(
55+
"Skipping search_vector_embeddings purge: this Python build does "
56+
"not support SQLite extension loading"
57+
)
58+
return False
59+
60+
try:
61+
await driver_connection.enable_load_extension(True)
62+
await driver_connection.load_extension(sqlite_vec.loadable_path())
63+
await driver_connection.enable_load_extension(False)
64+
await session.execute(text("SELECT vec_version()"))
65+
except Exception as exc:
66+
logger.warning(
67+
"Failed to load sqlite-vec for project delete cleanup; "
68+
"skipping embeddings purge: {}",
69+
exc,
70+
)
71+
return False
72+
73+
return True
74+
75+
1576
class ProjectRepository(Repository[Project]):
1677
"""Repository for Project model.
1778
@@ -121,6 +182,83 @@ async def set_as_default(self, project_id: int) -> Optional[Project]:
121182
return target_project
122183
return None # pragma: no cover
123184

185+
async def delete(self, entity_id: int) -> bool:
186+
"""Delete a project and its derived search rows in one transaction.
187+
188+
The cascade picture differs by backend:
189+
190+
- search_index → project: Postgres has ON DELETE CASCADE FK; SQLite
191+
stores search_index as an FTS5 virtual table and can't carry FKs,
192+
so it needs explicit cleanup.
193+
- search_vector_chunks → project: neither backend has an FK here, so
194+
both need an explicit DELETE.
195+
- search_vector_embeddings → search_vector_chunks: Postgres has an FK
196+
(chunk_id REFERENCES … ON DELETE CASCADE); SQLite stores embeddings
197+
in a vec0 virtual table keyed by rowid with no cascade. On SQLite
198+
the embeddings must be purged before the chunk rows, otherwise
199+
`_run_vector_query` keeps returning stale vectors that crowd out
200+
live results.
201+
202+
Each derived table is created lazily (search_index by
203+
SearchRepository.init_search_index, the vector tables once semantic
204+
search initializes), so any of them may be absent on minimal test DBs.
205+
Inspect the connection once and skip whichever is missing.
206+
"""
207+
logger.debug(f"Deleting Project and search rows for project_id: {entity_id}")
208+
async with db.scoped_session(self.session_maker) as session:
209+
try:
210+
result = await session.execute(
211+
select(self.Model).filter(self.primary_key == entity_id)
212+
)
213+
project = result.scalars().one()
214+
except NoResultFound:
215+
logger.debug(f"No Project found to delete: {entity_id}")
216+
return False
217+
218+
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
219+
is_sqlite = dialect_name == "sqlite"
220+
221+
existing_tables = await session.run_sync(
222+
lambda sync_session: set(sa_inspect(sync_session.connection()).get_table_names())
223+
)
224+
225+
# search_index: SQLite has no FK on the FTS5 virtual table; Postgres
226+
# cascades from the project FK, so the explicit DELETE is redundant.
227+
if is_sqlite and "search_index" in existing_tables:
228+
await session.execute(
229+
text("DELETE FROM search_index WHERE project_id = :project_id"),
230+
{"project_id": entity_id},
231+
)
232+
233+
# search_vector_chunks: no FK to project on either backend, so both
234+
# backends need this. SQLite must purge vec0 embeddings first
235+
# (rowid pseudocolumn — Postgres uses chunk_id and would 500 here);
236+
# Postgres' chunk_id FK CASCADE handles its embeddings cleanup when
237+
# we delete the chunk rows below.
238+
if "search_vector_chunks" in existing_tables:
239+
if is_sqlite and "search_vector_embeddings" in existing_tables:
240+
# Extension loading is per-connection. We must load vec0 on
241+
# *this* session before the DELETE; otherwise a different
242+
# pooled connection might have written embeddings that we'd
243+
# silently leave behind.
244+
if await _load_sqlite_vec_on_session(session):
245+
await session.execute(
246+
text(
247+
"DELETE FROM search_vector_embeddings WHERE rowid IN ("
248+
"SELECT id FROM search_vector_chunks "
249+
"WHERE project_id = :project_id)"
250+
),
251+
{"project_id": entity_id},
252+
)
253+
await session.execute(
254+
text("DELETE FROM search_vector_chunks WHERE project_id = :project_id"),
255+
{"project_id": entity_id},
256+
)
257+
258+
await session.delete(project)
259+
logger.debug(f"Deleted Project and search rows for project_id: {entity_id}")
260+
return True
261+
124262
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
125263
"""Update project path.
126264

0 commit comments

Comments
 (0)