Problem
publish_written_file_state in src/basic_memory/indexing/note_materialization_runner.py acquires FOR UPDATE row locks on NoteContent and Entity and holds them across the whole plan-compute-write span. In Basic Memory Cloud this anchors real Postgres deadlocks: a three-transaction wait cycle on the entity row lock, recurring on the pgq worker (basicmachines-co/basic-memory-cloud#1727, Logfire #2384, 4 occurrences 2026-08-07 → 2026-08-10).
Why the locks are removable
The path already has the mechanisms that make pessimistic locking redundant:
- Every NoteContent write goes through
apply_note_content_update_plan(..., expected_db_version=...) — a compare-and-swap with an explicit stale-skip path. The code comments state this exists precisely because concurrent publishers are expected.
with_for_update is a documented no-op on SQLite (note_content_repository.py), so core semantics must already be correct without row locks; the CAS is load-bearing by design.
- Hosted runtimes can serialize same-entity materialization externally via
NoteMaterializationSessionLock (Cloud passes a pg_advisory_xact_lock(project_id, entity_id) taken first in the transaction, which is deadlock-free by construction). The default Noop lock is exactly the concurrent case the CAS guards.
Fix
In publish_written_file_state:
- Read NoteContent and Entity plainly — drop both
with_for_update() calls.
- NoteContent writes: unchanged; the CAS row lock is taken only at UPDATE time.
- Entity write: replace the blind ORM attribute writes (
entity.mtime, entity.size) with one guarded single-statement UPDATE (WHERE id AND project_id; rowcount 0 → the existing "entity disappeared" result). It already runs only after a successful CAS in the same transaction, so the canonical NoteContent-before-Entity order is preserved with a millisecond lock window.
- Same treatment for
NoteFileVacateRepository.clear_vacate_path, which takes with_for_update on Entity inside this transaction.
Out of scope: the canonical lock-order protocol in relation_repository.py (current_relation_generation_statement and friends) stays as is — this change only stops the materialization publisher from holding SELECT-time locks that other protocols (including FK KEY SHARE from relation writes) can deadlock against.
Acceptance criteria
Problem
publish_written_file_stateinsrc/basic_memory/indexing/note_materialization_runner.pyacquiresFOR UPDATErow locks on NoteContent and Entity and holds them across the whole plan-compute-write span. In Basic Memory Cloud this anchors real Postgres deadlocks: a three-transaction wait cycle on the entity row lock, recurring on the pgq worker (basicmachines-co/basic-memory-cloud#1727, Logfire #2384, 4 occurrences 2026-08-07 → 2026-08-10).Why the locks are removable
The path already has the mechanisms that make pessimistic locking redundant:
apply_note_content_update_plan(..., expected_db_version=...)— a compare-and-swap with an explicit stale-skip path. The code comments state this exists precisely because concurrent publishers are expected.with_for_updateis a documented no-op on SQLite (note_content_repository.py), so core semantics must already be correct without row locks; the CAS is load-bearing by design.NoteMaterializationSessionLock(Cloud passes apg_advisory_xact_lock(project_id, entity_id)taken first in the transaction, which is deadlock-free by construction). The default Noop lock is exactly the concurrent case the CAS guards.Fix
In
publish_written_file_state:with_for_update()calls.entity.mtime,entity.size) with one guarded single-statement UPDATE (WHERE id AND project_id; rowcount 0 → the existing "entity disappeared" result). It already runs only after a successful CAS in the same transaction, so the canonical NoteContent-before-Entity order is preserved with a millisecond lock window.NoteFileVacateRepository.clear_vacate_path, which takeswith_for_updateon Entity inside this transaction.Out of scope: the canonical lock-order protocol in
relation_repository.py(current_relation_generation_statementand friends) stays as is — this change only stops the materialization publisher from holding SELECT-time locks that other protocols (including FK KEY SHARE from relation writes) can deadlock against.Acceptance criteria
publish_written_file_stateand the vacate-marker path hold no SELECT-time row locks; all writes are guarded single-statement updates with rowcount checks.db_version/file_versionis never reverted, and the stale publisher reportsstalerather than erroring.