Skip to content

[BUG] API/MCP note delete leaves linking notes' relation rows stale in search_index #1351

Description

@phernandez

What happens

Deleting a single note through the API/MCP path (DELETE /v2/.../entities/{id}run_accepted_note_deletedelete_accepted_note) removes the deleted note's own search_index rows and vectors, but never touches the relation rows owned by notes that linked to it. Those rows keep to_id pointing at the deleted entity and a title like Alpha -> Beta, and search still returns them with a permalink to a note that no longer exists.

Found while investigating #1344; this is a separate gap and stays a gap after #1344 is fixed (see below).

Reproduction (current main, SQLite; same code path on Postgres)

write_note Alpha  (body: "- links_to [[Beta]]")
write_note Beta
delete_note Beta   # API / MCP single-note delete

search_index afterwards (sqlite3 memory.db):

id type     entity_id to_id title          permalink
1  entity   1         NULL  Alpha          demo/notes/alpha
1  relation 1         2     Alpha -> Beta  demo/notes/alpha/links-to/demo/notes/beta   <- entity 2 no longer exists

User-visible:

search_notes("Beta", entity_types=["relation"])
→ [{"type": "relation", "title": "Alpha -> Beta", "permalink": "demo/notes/alpha/links-to/demo/notes/beta", "file_path": "notes/Alpha.md"}]

Nothing schedules a refresh of Alpha, so the row stays until Alpha is edited or bm reindex runs. Under the consistency model that's a derived-state drift with no repair mechanism on the path that produced it.

Why only this path

Every other delete path already handles it. They snapshot the surviving relation sources before the entity delete and refresh those sources' search rows afterwards:

  • watcher / external file delete: relation_cleanup_sources_for_deleted_entity in indexing/external_file_delete_runner.py, refreshed by index/local_runtime.py (refresh_moved_entities(relation_cleanup_entity_ids))
  • directory delete: indexing/directory_delete_runner.pyservices/directory_deletes.py (refresh_relation_sources)
  • project scan deletes: indexing/project_index_maintenance.pyproject_index_runtime.py

delete_accepted_note (indexing/accepted_note_write_runner.py) is the one that just does delete_accepted_note_search_index + delete_accepted_note_vectors + session.delete(entity).

Relationship to #1344

Independent. #1344 changes relation.to_id from CASCADE to SET NULL so the source's relation row survives as unresolved. The source's search row would still say Alpha -> Beta with the old to_id until the source is refreshed. Recreating the target repairs it (forward-reference resolution refreshes the sources it resolves); deleting without recreating does not. So the API delete path needs the same refresh either way.

Proposed fix

In run_accepted_note_delete / delete_accepted_note, capture relation_cleanup_sources_for_deleted_entity(session, project_id=..., entity_id=...) before session.delete(entity) (same transaction, after the NoteContent lock — the same order the watcher path uses), carry the ids on the accepted change, and refresh them after commit through the existing refresh_moved_entities / index_entity hook, exactly as local_runtime.py does for external deletes. No new locking: it reads rows the delete already touches.

Regression test: API delete of a linked target → the source's relation search rows are gone (today) or show the unresolved title (after #1344), and search_notes(..., entity_types=["relation"]) no longer returns a permalink to the deleted note.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions