-
Notifications
You must be signed in to change notification settings - Fork 260
fix(core): remove entity read locks from materialization publish #1227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,7 @@ | |
| RuntimeFilePath, | ||
| ) | ||
| from basic_memory.models import Entity, NoteContent | ||
| from basic_memory.repository.entity_repository import EntityRepository | ||
| from basic_memory.repository.note_file_vacate_repository import NoteFileVacateRepository | ||
|
|
||
| type NoteMaterializationPreflightOutcome = ( | ||
|
|
@@ -427,25 +428,31 @@ async def publish_written_file_state( | |
| entity_id=request.entity_id, | ||
| ) | ||
|
|
||
| # Materialization publishes NoteContent and Entity in one transaction. | ||
| # The canonical order lives in current_relation_generation_statement: | ||
| # claim NoteContent before any Entity lock so publication, accepted | ||
| # mutation, deletion, and materialization cannot form a lock cycle. | ||
| # These are plain snapshots for planning. This transaction's first row | ||
| # lock is the NoteContent CAS UPDATE, preserving the canonical order in | ||
| # current_relation_generation_statement. With no read locks held, the | ||
| # publisher cannot anchor the lock cycle reported in #1224. | ||
| # | ||
| # Everything this publisher writes — the file on disk, Entity | ||
| # mtime/size, NoteContent file lineage — is derived, eventually | ||
| # consistent state. A concurrent accepted write or move may | ||
| # invalidate these snapshots at any point; when it does, the CAS | ||
| # below no-ops and the newer generation's own publish converges the | ||
| # projections. Do not "fix" an observed race here by reintroducing | ||
| # SELECT-time locks: every such lock rebuilds a #1224-class | ||
| # deadlock, while the drift it would prevent is transient and | ||
| # repaired by the next write's index pass. | ||
| note_content = await session.scalar( | ||
| select(NoteContent) | ||
| .where( | ||
| select(NoteContent).where( | ||
| NoteContent.entity_id == request.entity_id, | ||
| NoteContent.project_id == request.project_id, | ||
| ) | ||
|
Comment on lines
445
to
449
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a project-index deletion was planned from a missing-file scan, it can delete both rows after this unlocked AGENTS.md reference: AGENTS.md:L251-L251 Useful? React with 👍 / 👎. |
||
| .with_for_update() | ||
| ) | ||
| entity = await session.scalar( | ||
| select(Entity) | ||
| .where( | ||
| select(Entity).where( | ||
| Entity.id == request.entity_id, | ||
| Entity.project_id == request.project_id, | ||
| ) | ||
| .with_for_update() | ||
| ) | ||
| publish_plan = plan_written_note_materialization_publish( | ||
| request=request, | ||
|
|
@@ -509,9 +516,20 @@ async def publish_written_file_state( | |
| expected_db_version=expected_db_version, | ||
| ) | ||
| if not applied: | ||
| # A newer accepted write (and its own materialization) superseded | ||
| # this one between our read and write; skip the stale file_version | ||
| # publish and the entity metadata update rather than reverting them. | ||
| # Trigger: a move can commit after the plain planning reads but | ||
| # before the CAS observes the newer NoteContent row. | ||
| # Why: that observation guarantees this later plain read sees the | ||
| # move's committed Entity path. | ||
| # Outcome: clean the just-written vacated path while keeping a | ||
| # same-path superseded write in place for its newer materialization. | ||
| current_entity = await session.scalar( | ||
| select(Entity) | ||
| .where( | ||
| Entity.id == request.entity_id, | ||
| Entity.project_id == request.project_id, | ||
| ) | ||
| .execution_options(populate_existing=True) | ||
| ) | ||
| return RuntimeNoteMaterializationResult( | ||
| entity_id=request.entity_id, | ||
| status=RuntimeNoteMaterializationStatus.stale, | ||
|
|
@@ -521,6 +539,31 @@ async def publish_written_file_state( | |
| ), | ||
| file_path=written_file.file_path, | ||
| file_checksum=written_file.file_checksum, | ||
| written_file_orphaned=( | ||
| current_entity is None or current_entity.file_path != written_file.file_path | ||
| ), | ||
| ) | ||
|
|
||
| updated = await EntityRepository(request.project_id).update_fields( | ||
| session, | ||
| request.entity_id, | ||
| { | ||
| "mtime": written_file.file_updated_at.timestamp(), | ||
| "size": len(prepared_write.markdown_content.encode("utf-8")), | ||
| }, | ||
| ) | ||
| if not updated: | ||
| # Trigger: the guarded Entity update found no row after the CAS. | ||
| # Why: this is a portable fail-safe; on PostgreSQL the successful | ||
| # CAS holds NoteContent while every Entity metadata producer crosses | ||
| # that row first, so the miss is unreachable under row locking. | ||
| # Outcome: report the missing Entity without clearing its vacate path. | ||
| return RuntimeNoteMaterializationResult( | ||
| entity_id=request.entity_id, | ||
| status=RuntimeNoteMaterializationStatus.missing, | ||
| reason=f"entity disappeared after file write: {request.entity_id}", | ||
| file_path=written_file.file_path, | ||
| file_checksum=written_file.file_checksum, | ||
| ) | ||
|
|
||
| # Trigger: this current materialization made its destination path live. | ||
|
|
@@ -530,9 +573,6 @@ async def publish_written_file_state( | |
| session, | ||
| file_path=written_file.file_path, | ||
| ) | ||
| entity.mtime = written_file.file_updated_at.timestamp() | ||
| entity.size = len(prepared_write.markdown_content.encode("utf-8")) | ||
| await session.flush() | ||
| return publish_plan.result | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,13 +80,9 @@ async def _load_entity_identity( | |
| self, | ||
| session: AsyncSession, | ||
| entity_id: int, | ||
| *, | ||
| lock_for_update: bool = False, | ||
| ) -> Entity: | ||
| """Load the owning entity so duplicated identity fields stay aligned.""" | ||
| query = select(Entity).where(Entity.id == entity_id) | ||
| if lock_for_update: | ||
| query = query.with_for_update() | ||
| result = await session.execute(query) | ||
| entity = result.scalar_one_or_none() | ||
| if entity is None: | ||
|
|
@@ -304,14 +300,18 @@ async def update_state_fields( | |
| # from the entity (rather than mutating the ORM row) so the whole | ||
| # write is the single conditional UPDATE whose rowcount decides the | ||
| # race, portably across SQLite and Postgres. | ||
| # Materialization publishes NoteContent state and then updates Entity | ||
| # file metadata in the same transaction. Lock Entity first so that | ||
| # path cannot invert an Entity -> NoteContent indexing transaction. | ||
| entity = await self._load_entity_identity( | ||
| session, | ||
| entity_id, | ||
| lock_for_update=True, | ||
| ) | ||
| # This identity read is deliberately unlocked. Reconciler and | ||
| # materialization callers hold no prior NoteContent claim, so an | ||
| # Entity lock here would invert the NoteContent-first order documented | ||
| # by current_relation_generation_statement and recreate the #1224 | ||
| # deadlock. The conditional UPDATE rowcount is the only guard needed. | ||
| # A project-index move can repoint entity and note_content paths | ||
| # without advancing db_version, so this copy can lose that race and | ||
| # go briefly stale. That is accepted: the identity columns are a | ||
| # denormalized convenience, planning always prefers Entity.file_path, | ||
| # and every subsequent write refreshes the copy. Serializing here | ||
| # would trade a self-healing drift for a deadlock class. | ||
| entity = await self._load_entity_identity(session, entity_id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a project-index move overlaps this CAS, the unlocked identity read can capture the old Useful? React with 👍 / 👎. |
||
| result = cast( | ||
| CursorResult[Any], | ||
| await session.execute( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With these planning reads now unlocked, if the request is already stale at the snapshot and an accepted move advances the row again before publication, the
stale_db_versionbranch ignores the failedapply_note_content_update_planresult and returns its original non-orphaned result.run_note_materializationtherefore does not enqueue cleanup even though the entity now owns a different path, leaving the just-written old-path file available for duplicate re-indexing; handle CAS loss here with the same current-Entity recheck used by thecurrentbranch.Useful? React with 👍 / 👎.