Skip to content

fix(core): serialize search-index rewrites of one entity on Postgres - #5

Closed
sammywachtel wants to merge 31 commits into
mainfrom
fix/search-projection-overlap
Closed

sammywachtel wants to merge 31 commits into
mainfrom
fix/search-projection-overlap

Conversation

@sammywachtel

Copy link
Copy Markdown
Owner

Review copy. On the personal fork for review before it goes to basicmachines-co/basic-memory. Merging it here is not the goal.

Proposed upstream title: fix(core): serialize search-index rewrites of one entity on Postgres. The body below is the draft upstream description.

This fork's main is behind upstream, so the Files tab here also shows upstream commits. The change under review is the one commit on top: 03ef4ba

Why

On Postgres, saving a long note twice in quick succession can fail. The second save answers 500, and the log shows one of:

  • asyncpg.exceptions.UniqueViolationError: duplicate key value violates unique constraint "search_index_pkey", DETAIL: Key (id, type, project_id)=(<entity id>, entity, <project>) already exists.;
  • the same on a relation row, Key (<relation id>, relation, <project>);
  • asyncpg.exceptions.DeadlockDetectedError: deadlock detected, with both processes on DELETE FROM search_index WHERE entity_id = $1 AND project_id = $2 (while rechecking updated tuple ... in relation "search_index").

Every refresh of an entity's search rows is delete-then-insert in one transaction (since the fix for basicmachines-co#1621): delete every search_index row the entity owns, then insert the entity row, its observation rows and its relation rows again. Under READ COMMITTED, two such transactions for the same entity collide:

  1. The second DELETE waits on the first's row locks, then re-checks rows the first has replaced, and with more than two writers they wait on each other: a deadlock.
  2. When the second DELETE finds nothing to wait on (the old rows were already gone), both transactions reach their INSERTs together. The upsert absorbs a conflict only on its arbiter, the partial index on (permalink, type, project_id). If the second insert passes the arbiter check before the first commits, its insert into search_index_pkey then conflicts with the first's row, and Postgres raises the violation, because a conflict on a non-arbiter unique index is never resolved by ON CONFLICT.

A long note makes the window wide: re-indexing tens of KB takes longer than the gap between two saves. One save already runs more than one refresh of the same entity (the accepted write's refresh_entity, the freshen-before-write reindex, and the follow-up relation resolution), so two saves a few seconds apart overlap even when the client waits for each answer.

Evidence

Reproduced locally with synthetic notes, on Postgres (pgvector pg17), through PUT /v2/projects/{project}/knowledge/entities/{entity} on a note of about 80 KB with 12 relations and 12 observations, file watcher on:

  • 4 concurrent saves per round, 3 rounds: 2 of 12 saves answered 500 (deadlock).
  • 12 saves one after another, each waiting for the previous answer: 1 answered 500 (deadlock), because a save's own follow-up reindex was still running when the next save arrived.
  • The Postgres log over these runs held 14 deadlock detected / duplicate key value violates unique constraint "search_index_pkey" errors, including Key (id, type, project_id)=(1, relation, 2) already exists.

With this change, the same harness: 4×3, 12 sequential, 6×4, 8×3 and 3×5 rounds, every save answered 202, the Postgres log held no errors, and the file, the accepted note content and the entity's search row all named the same last version, with exactly one relation row per relation.

What changed

  • New repository/search_projection_lock.py: lock_entity_search_projection(session, project_id=, entity_id=) takes pg_advisory_xact_lock(hashtextextended('basic-memory:search-projection:<project>:<entity>', 0)). The lock is released by the commit or rollback that ends the transaction. It is a no-op on SQLite, which already allows one writer at a time. Refreshes of different entities do not wait on each other.
  • SearchService.index_entity_data takes the lock first in its transaction, before the delete. When the refresh reads its text from storage (content is None), it reads after taking the lock, so the last refresh to run indexes the file as it is now, not as it was when it started waiting. A read failure still raises before any row is deleted, and the rollback keeps the previous projection.
  • AcceptedNoteSearchRepository.refresh_entity and delete_entity take the same lock before their delete, inside the accepted-note transaction.

Lock order: a transaction takes this lock before it touches any of the entity's search_index rows. Every writer that rewrites an entity's projection does, so a holder of the lock never waits on another writer of those rows. Neither caller holds another session's lock on these rows when it takes this one.

Testing

New tests/services/test_search_refresh_concurrency.py (Postgres; skipped on SQLite):

  • test_concurrent_saves_of_a_long_note_all_succeed_and_the_last_one_wins: three rounds of 6 concurrent "write the 80 KB file, then refresh from storage". Every refresh succeeds, the entity keeps exactly its one entity row, two observation rows and two relation rows, and the entity row's content equals the file on disk at the end.
  • test_negative_control_without_the_lock_overlapping_saves_fail: the same with the lock patched out; the refreshes fail with search_index_pkey or deadlock detected.
  • test_concurrent_refreshes_keep_relation_rows_single and its negative control: 6 concurrent refreshes with content in hand, as the batch indexer calls it. Without the lock they fail with search_index_pkey on relation rows.
  • test_an_accepted_write_and_an_index_refresh_take_turns: accepted-note writes and index refreshes of one entity interleaved, all succeed, one entity row.

To make the race certain rather than likely, every refresh in these tests pauses for 50 ms between its delete and its insert, and the collision rounds start from an entity whose rows were just deleted. Both changes are applied equally to the tests and their negative controls. With the change to search_service.py reverted, the first and third tests fail.

tests/repository/test_accepted_note_search_repository.py: the recorded statements on Postgres now start with the lock.

Runs:

  • tests/services, tests/repository, tests/indexing, tests/index, tests/api/v2, SQLite (macOS): 3004 passed, 50 skipped.
  • tests/services/test_search_refresh_concurrency.py, tests/services/test_search_refresh_atomicity.py, tests/repository/test_accepted_note_search_repository.py, Postgres 17: 15 passed.
  • ruff check, ruff format --check, ty check src tests test-int: clean.

Risks / follow-ups

  • Rewrites of one entity's search rows now queue. A long note re-indexed by several writers at once is indexed once per writer, one after another, rather than failing; the total work is unchanged.
  • The lock covers the two rewrite paths above. SearchRepository.delete_by_entity_id called on its own (note delete) and the bulk project reindex do not take it; they did not show up in the failures.
  • A refresh handed its text by the caller (content given) indexes that text even if a later save has since changed the file; the later save's own refresh follows it and wins. Only refreshes that read from storage read the newest text by construction.

phernandez and others added 30 commits September 29, 2026 21:33
Startup reconciliation rewrites config keys to permalinks while the
database keeps each project's display name, and config was then looked up
by that name with an exact dict get. A project named `Gamma-Mixed` (key
`gamma-mixed`) failed is_locally_syncable, so the startup index and the
watcher skipped it, and get_project_mode treated it as cloud.

BasicMemoryConfig.project_entry matches by name or permalink, and
get_project_mode, is_locally_syncable and the workspace and routing
lookups use it.

An unknown name still defaults to cloud mode so cloud-only projects stay
reachable, but with no cloud route available the local API now answers,
so a typo or a deleted project reports "not found" instead of asking for
cloud credentials.

Fixes basicmachines-co#1624
Fixes basicmachines-co#1632

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
man fell back to a project named "manual" for unbundled pages and query
mode. A local install has no such project, and routing an unknown name to
cloud turned every such call into a cloud-credentials error. When no
manual project is reachable (none named, none in config, no factory and no
cloud credentials), a page miss is "No manual entry" and query mode says
what it needs.

Fixes basicmachines-co#1628

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
build_context answered a memory:// URL that did not name a permalink with
the top fuzzy search hit. Routing makes the path project-qualified
(main/Cache Layer Design), and the title and file-path lookups saw that
prefixed text, so a title URL could only resolve through fuzzy search and
often landed on a neighbour that links to it. A miss returned whatever the
search ranked first.

The link resolver now runs its exact lookups on the own-project remainder
before any fuzzy match, with the fuzzy step extracted so it runs last.
build_context resolves with use_search=False: a memory:// URL is an
address, and a miss returns no primary result.

Fixes basicmachines-co#1626

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…tles

The wiki projector defers until every accepted change it replays is
materialized. Cloud stamps materialized_at from its materialization job,
but the local runtime never called mark_accepted_note_change_materialized,
so one API or MCP write left bm wiki partial forever.

The local provider now settles the journal row after a write reaches a
terminal status other than conflict (a disk edit the user must resolve),
and after an inline file delete, matching the cloud rule.

Fixes basicmachines-co#1625

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Permalink candidates drop the extension, so notes/foo.txt became notes/foo
and matched the same-stem Markdown note before any file-path lookup ran.
read_content, cat and read_note returned foo.md's bytes for foo.txt. An
identifier with a non-Markdown extension now tries its exact file path
first; a miss falls through to the usual order.

Fixes basicmachines-co#1629

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Project routing matches names by permalink (basicmachines-co#1388), but search scoping
compared exact strings. Under BASIC_MEMORY_MCP_PROJECT=ALPHA, a project
named alpha was filtered out of the scoped project list, so
search_all_projects and projects= saw no projects; projects=["BETA"] was
refused as unknown. Both now compare permalinks.

Fixes basicmachines-co#1630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Error guidance, docstrings and tool descriptions still showed legacy
project-first positional calls (read_note("qa", "notes/x") reads a note
named qa from project notes/x), a retired list_projects() tool, and a
search_notes kind= parameter that does not exist. The basicmachines-co#1603 audit was a
manual pass with no test behind it.

Every example now names a registered tool and passes everything after its
first argument by keyword. A new test scans the string literals in the
MCP tools, prompts and resources and fails on a positional example, an
unknown keyword, or a retired tool name. The search-notes(3) CLI synopsis
listed --search-type, which bm tool search-notes does not accept.

Fixes basicmachines-co#1633

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
The module docstring claimed created_by/updated_by cannot be set through
note content. That holds only where the runtime supplies an identity and
stamps over them; on local runtimes the keys pass through as written,
which is the intended behavior (basicmachines-co#1634).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
… fallback

The underscore relation test used a relation-shaped URL without the
target's project prefix, which resolved only through the fuzzy search
removed for basicmachines-co#1626. Assert the real relation address resolves and the
prefix-less form is a miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…lookup

file_path is project-relative, so a project-qualified
<project>/components/foo.txt missed the exact lookup and fell through to
the extension-less permalink candidates that match foo.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…retry

Workspace-scoped routes qualify identifiers as <workspace>/<project>/Title.
The own-project retry only split the first segment and compared the
workspace slug with the project permalink, so it was skipped, and with
build_context no longer fuzzy matching, the title returned nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Startup recovery rebuilds its materialization request from note_content,
so it carries no journal position and never settled the accepted row; a
note recovered on restart and not written again kept the wiki partial.
Recovery now settles the note's journal rows up to the recovered
db_version when the write ends in any status but conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Resource entities (text, images, PDFs) are indexed with permalink NULL, so
a permalink candidate built by dropping .txt could only ever match the
same-stem Markdown note. Rather than special-casing an exact file-path
probe ahead of the candidates in LinkResolver, the shared candidate
builder now keeps a real non-Markdown extension, and the ordinary lookup
order reaches the file: exact file path, then the own-project remainder
retry, whose prefix match normalizes by permalink (so TEST-PROJECT/... and
workspace prefixes work too). The builder is shared, so the bulk link
resolver and OKF rendering no longer turn [[notes/foo.txt]] into foo.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
resolve_entity, the path API entity reads use, ran one exact pass on the
raw identifier and never retried the own-project remainder, so a routed
<project>/components/foo.txt (resource, permalink NULL) or
<project>/<Title> missed under strict resolution. Both entrypoints now
share _resolve_in_current_project: exact lookups, then the remainder,
then fuzzy search only for non-strict callers that ask for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
The bulk resolver declined its parsed-prefix fallback when the prefix
named the current project, so [[<project>/assets/a.txt]] (a resource with
no permalink) and [[<project>/Title]] stayed unresolved on every reindex.
It now runs the same strict lookups on the project-relative remainder,
matching LinkResolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…al fallback

A caller that names a workspace asked for cloud explicitly. The unknown
project fallback to local ASGI now applies only when no workspace is
given, so a cloud request without credentials still fails instead of
reading a same-named local project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
LinkResolver and the bulk resolver each grew their own own-project prefix
retry, and the bulk one handled only <project>/..., so a workspace route's
[[<workspace>/<project>/assets/image.png]] stayed unresolved. utils
own_project_remainder now strips <workspace>/<project>/ or <project>/ by
permalink, and both resolvers call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
basicmachines-co#1613

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Deletes were the only note mutation that skipped the actor resolver, so
their accepted-change journal row always had empty actor columns, even
when the same caller's creates and edits were attributed.

Add "delete" to NoteContentMutationKind, give delete_note the optional
actor inputs the other mutations take, resolve them, and carry the
result on AcceptedNoteDeleteMutation into the journal row. The DELETE
route passes user_profile_id=None and source="api" like its neighbors.
With no actor and no resolver the row is written exactly as before, and
deletes keep their "delete_note" journal source.

Closes basicmachines-co#1636

Signed-off-by: sammywachtel <subp@wachtel.us>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…icmachines-co#1641)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…basicmachines-co#1642)

Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
…asicmachines-co#1643)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ll pass (basicmachines-co#1645)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…asicmachines-co#1654)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cmachines-co#1655)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…asicmachines-co#1656)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…asicmachines-co#1658)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… cannot race (basicmachines-co#1659)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every refresh of an entity's search rows is delete-then-insert in one
transaction. Two of them for the same entity at once either deadlocked on
the DELETE or hit search_index_pkey on the entity row or a relation row:
the upsert's conflict target is the permalink index, and a violation on
any other unique index is raised. Two quick saves of a long note are
enough, and the second save answers 500.

Each rewrite of an entity's projection now takes a transaction-scoped
advisory lock keyed on (project, entity) before it touches any row: the
indexer's refresh (SearchService.index_entity_data) and the accepted-note
write (AcceptedNoteSearchRepository.refresh_entity, delete_entity). A
refresh that reads its text from storage reads it after taking the lock,
so the last refresh indexes the file as it is now. SQLite is unchanged.

Signed-off-by: sammywachtel <subp@wachtel.us>
@sammywachtel

Copy link
Copy Markdown
Owner Author

Opened upstream as basicmachines-co#1665; this review copy is no longer needed. The branch stays, because the upstream PR is opened from it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants