Skip to content

Commit cda4a9c

Browse files
committed
Keep concurrent note deletes idempotent
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0c0d418 commit cda4a9c

2 files changed

Lines changed: 52 additions & 2 deletions

File tree

src/basic_memory/indexing/accepted_note_mutation_runner.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -625,13 +625,28 @@ async def run_accepted_note_delete(
625625

626626
# The entity lookup above is intentionally unlocked so an already-missing
627627
# delete stays idempotent. Once the note exists, claim its mutation lock and
628-
# refresh both evidence rows before deleting: a concurrent update or move may
629-
# have committed between the initial lookup and this lock acquisition.
628+
# reload it before deleting: another delete may have removed the row while we
629+
# waited, while an update or move may have changed its accepted evidence.
630630
await lock_accepted_note_content_for_entity_mutation(
631631
session,
632632
project_id=project.id,
633633
entity_id=entity.id,
634634
)
635+
entity = await entity_repository.get_by_external_id(
636+
session,
637+
request.entity_external_id,
638+
load_relations=False,
639+
)
640+
if entity is None:
641+
return AcceptedNoteMutationResult(
642+
change=await delete_accepted_note(
643+
session,
644+
project_id=project.id,
645+
entity=None,
646+
repositories=dependencies.write_repositories,
647+
)
648+
)
649+
635650
await session.refresh(entity)
636651
note_content = await load_accepted_note_content(
637652
session,

tests/indexing/test_accepted_note_mutation_runner.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,6 +2075,41 @@ async def test_run_accepted_note_delete_removes_entity_and_returns_cleanup() ->
20752075
assert result.relation_publication is None
20762076

20772077

2078+
@pytest.mark.asyncio
2079+
async def test_run_accepted_note_delete_stays_idempotent_after_concurrent_delete() -> None:
2080+
session = _MutationSession()
2081+
project = _project()
2082+
entity = _entity(file_path="notes/accepted.md")
2083+
entity_lookup_repository = _EntityLookupRepository(by_external_id=entity)
2084+
entity_lookup_repository.get_by_external_id = AsyncMock(side_effect=[entity, None])
2085+
search_repository = _SearchRepository()
2086+
2087+
result = await run_accepted_note_delete(
2088+
cast(AsyncSession, session),
2089+
request=AcceptedNoteDeleteMutation(
2090+
project_external_id="project-123",
2091+
entity_external_id="note-123",
2092+
),
2093+
dependencies=_dependencies(
2094+
project_repository=_ProjectRepository(project),
2095+
entity_lookup_repository=entity_lookup_repository,
2096+
note_content_lookup_repository=_NoteContentLookupRepository(),
2097+
preparer_factory=_PreparerFactory(_CreatePreparer(_prepared())),
2098+
pending_entity_repository=_PendingEntityRepository(entity),
2099+
note_content_accept_repository=_NoteContentAcceptRepository(_note_content(entity)),
2100+
search_repository=search_repository,
2101+
),
2102+
)
2103+
2104+
assert result.change.status_code == 200
2105+
assert result.change.payload == {"deleted": False}
2106+
assert session.scalar_count == 1
2107+
assert session.refreshed == []
2108+
assert session.deleted == []
2109+
assert search_repository.deleted_entity_ids == []
2110+
assert search_repository.deleted_vector_entity_ids == []
2111+
2112+
20782113
def _prepared_with_graph(
20792114
*,
20802115
observations: Sequence[AcceptedObservationWrite],

0 commit comments

Comments
 (0)