Skip to content

Commit c6215fd

Browse files
groksrcclaude[bot]Copilotphernandezclaude
authored
fix: UNIQUE constraint failed: entity.permalink issue #139 (#140)
Signed-off-by: Drew Cain <groksrc@users.noreply.github.com> Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
1 parent b4c26a6 commit c6215fd

File tree

6 files changed

+499
-83
lines changed

6 files changed

+499
-83
lines changed

src/basic_memory/alembic/env.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88

99
from alembic import context
1010

11-
from basic_memory.models import Base
12-
1311
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
1412
os.environ["BASIC_MEMORY_ENV"] = "test"
1513

16-
from basic_memory.config import app_config
14+
# Import after setting environment variable # noqa: E402
15+
from basic_memory.config import app_config # noqa: E402
16+
from basic_memory.models import Base # noqa: E402
1717

1818
# this is the Alembic Config object, which provides
1919
# access to the values within the .ini file in use.

src/basic_memory/repository/entity_repository.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
from pathlib import Path
44
from typing import List, Optional, Sequence, Union
55

6+
from sqlalchemy import select
7+
from sqlalchemy.exc import IntegrityError
68
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
79
from sqlalchemy.orm import selectinload
810
from sqlalchemy.orm.interfaces import LoaderOption
911

12+
from basic_memory import db
1013
from basic_memory.models.knowledge import Entity, Observation, Relation
1114
from basic_memory.repository.repository import Repository
1215

@@ -96,3 +99,153 @@ async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
9699

97100
result = await self.execute_query(query)
98101
return list(result.scalars().all())
102+
103+
async def upsert_entity(self, entity: Entity) -> Entity:
104+
"""Insert or update entity using a hybrid approach.
105+
106+
This method provides a cleaner alternative to the try/catch approach
107+
for handling permalink and file_path conflicts. It first tries direct
108+
insertion, then handles conflicts intelligently.
109+
110+
Args:
111+
entity: The entity to insert or update
112+
113+
Returns:
114+
The inserted or updated entity
115+
"""
116+
117+
async with db.scoped_session(self.session_maker) as session:
118+
# Set project_id if applicable and not already set
119+
self._set_project_id_if_needed(entity)
120+
121+
# Check for existing entity with same file_path first
122+
existing_by_path = await session.execute(
123+
select(Entity).where(
124+
Entity.file_path == entity.file_path,
125+
Entity.project_id == entity.project_id
126+
)
127+
)
128+
existing_path_entity = existing_by_path.scalar_one_or_none()
129+
130+
if existing_path_entity:
131+
# Update existing entity with same file path
132+
for key, value in {
133+
'title': entity.title,
134+
'entity_type': entity.entity_type,
135+
'entity_metadata': entity.entity_metadata,
136+
'content_type': entity.content_type,
137+
'permalink': entity.permalink,
138+
'checksum': entity.checksum,
139+
'updated_at': entity.updated_at,
140+
}.items():
141+
setattr(existing_path_entity, key, value)
142+
143+
await session.flush()
144+
# Return with relationships loaded
145+
query = (
146+
select(Entity)
147+
.where(Entity.file_path == entity.file_path)
148+
.options(*self.get_load_options())
149+
)
150+
result = await session.execute(query)
151+
found = result.scalar_one_or_none()
152+
if not found: # pragma: no cover
153+
raise RuntimeError(f"Failed to retrieve entity after update: {entity.file_path}")
154+
return found
155+
156+
# No existing entity with same file_path, try insert
157+
try:
158+
# Simple insert for new entity
159+
session.add(entity)
160+
await session.flush()
161+
162+
# Return with relationships loaded
163+
query = (
164+
select(Entity)
165+
.where(Entity.file_path == entity.file_path)
166+
.options(*self.get_load_options())
167+
)
168+
result = await session.execute(query)
169+
found = result.scalar_one_or_none()
170+
if not found: # pragma: no cover
171+
raise RuntimeError(f"Failed to retrieve entity after insert: {entity.file_path}")
172+
return found
173+
174+
except IntegrityError:
175+
# Could be either file_path or permalink conflict
176+
await session.rollback()
177+
178+
# Check if it's a file_path conflict (race condition)
179+
existing_by_path_check = await session.execute(
180+
select(Entity).where(
181+
Entity.file_path == entity.file_path,
182+
Entity.project_id == entity.project_id
183+
)
184+
)
185+
race_condition_entity = existing_by_path_check.scalar_one_or_none()
186+
187+
if race_condition_entity:
188+
# Race condition: file_path conflict detected after our initial check
189+
# Update the existing entity instead
190+
for key, value in {
191+
'title': entity.title,
192+
'entity_type': entity.entity_type,
193+
'entity_metadata': entity.entity_metadata,
194+
'content_type': entity.content_type,
195+
'permalink': entity.permalink,
196+
'checksum': entity.checksum,
197+
'updated_at': entity.updated_at,
198+
}.items():
199+
setattr(race_condition_entity, key, value)
200+
201+
await session.flush()
202+
# Return the updated entity with relationships loaded
203+
query = (
204+
select(Entity)
205+
.where(Entity.file_path == entity.file_path)
206+
.options(*self.get_load_options())
207+
)
208+
result = await session.execute(query)
209+
found = result.scalar_one_or_none()
210+
if not found: # pragma: no cover
211+
raise RuntimeError(f"Failed to retrieve entity after race condition update: {entity.file_path}")
212+
return found
213+
else:
214+
# Must be permalink conflict - generate unique permalink
215+
return await self._handle_permalink_conflict(entity, session)
216+
217+
async def _handle_permalink_conflict(self, entity: Entity, session: AsyncSession) -> Entity:
218+
"""Handle permalink conflicts by generating a unique permalink."""
219+
base_permalink = entity.permalink
220+
suffix = 1
221+
222+
# Find a unique permalink
223+
while True:
224+
test_permalink = f"{base_permalink}-{suffix}"
225+
existing = await session.execute(
226+
select(Entity).where(
227+
Entity.permalink == test_permalink,
228+
Entity.project_id == entity.project_id
229+
)
230+
)
231+
if existing.scalar_one_or_none() is None:
232+
# Found unique permalink
233+
entity.permalink = test_permalink
234+
break
235+
suffix += 1
236+
237+
# Insert with unique permalink (no conflict possible now)
238+
session.add(entity)
239+
await session.flush()
240+
241+
# Return the inserted entity with relationships loaded
242+
query = (
243+
select(Entity)
244+
.where(Entity.file_path == entity.file_path)
245+
.options(*self.get_load_options())
246+
)
247+
result = await session.execute(query)
248+
found = result.scalar_one_or_none()
249+
if not found: # pragma: no cover
250+
raise RuntimeError(f"Failed to retrieve entity after insert: {entity.file_path}")
251+
return found

src/basic_memory/services/entity_service.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -292,27 +292,21 @@ async def create_entity_from_markdown(
292292
293293
Creates the entity with null checksum to indicate sync not complete.
294294
Relations will be added in second pass.
295+
296+
Uses UPSERT approach to handle permalink/file_path conflicts cleanly.
295297
"""
296298
logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
297299
model = entity_model_from_markdown(file_path, markdown)
298300

299301
# Mark as incomplete because we still need to add relations
300302
model.checksum = None
301-
# Repository will set project_id automatically
303+
304+
# Use UPSERT to handle conflicts cleanly
302305
try:
303-
return await self.repository.add(model)
304-
except IntegrityError as e:
305-
# Handle race condition where entity was created by another process
306-
if "UNIQUE constraint failed: entity.file_path" in str(
307-
e
308-
) or "UNIQUE constraint failed: entity.permalink" in str(e):
309-
logger.info(
310-
f"Entity already exists for file_path={file_path} (file_path or permalink conflict), updating instead of creating"
311-
)
312-
return await self.update_entity_and_observations(file_path, markdown)
313-
else:
314-
# Re-raise if it's a different integrity error
315-
raise
306+
return await self.repository.upsert_entity(model)
307+
except Exception as e:
308+
logger.error(f"Failed to upsert entity for {file_path}: {e}")
309+
raise EntityCreationError(f"Failed to create entity: {str(e)}") from e
316310

317311
async def update_entity_and_observations(
318312
self, file_path: Path, markdown: EntityMarkdown

tests/mcp/test_tool_write_note.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,66 @@ async def test_write_note_preserves_content_frontmatter(app):
413413
).strip()
414414
in content
415415
)
416+
417+
418+
@pytest.mark.asyncio
419+
async def test_write_note_permalink_collision_fix_issue_139(app):
420+
"""Test fix for GitHub Issue #139: UNIQUE constraint failed: entity.permalink.
421+
422+
This reproduces the exact scenario described in the issue:
423+
1. Create a note with title "Note 1"
424+
2. Create another note with title "Note 2"
425+
3. Try to create/replace first note again with same title "Note 1"
426+
427+
Before the fix, step 3 would fail with UNIQUE constraint error.
428+
After the fix, it should either update the existing note or create with unique permalink.
429+
"""
430+
# Step 1: Create first note
431+
result1 = await write_note.fn(
432+
title="Note 1",
433+
folder="test",
434+
content="Original content for note 1"
435+
)
436+
assert "# Created note" in result1
437+
assert "permalink: test/note-1" in result1
438+
439+
# Step 2: Create second note with different title
440+
result2 = await write_note.fn(
441+
title="Note 2",
442+
folder="test",
443+
content="Content for note 2"
444+
)
445+
assert "# Created note" in result2
446+
assert "permalink: test/note-2" in result2
447+
448+
# Step 3: Try to create/replace first note again
449+
# This scenario would trigger the UNIQUE constraint failure before the fix
450+
result3 = await write_note.fn(
451+
title="Note 1", # Same title as first note
452+
folder="test", # Same folder as first note
453+
content="Replacement content for note 1" # Different content
454+
)
455+
456+
# This should not raise a UNIQUE constraint failure error
457+
# It should succeed and either:
458+
# 1. Update the existing note (preferred behavior)
459+
# 2. Create a new note with unique permalink (fallback behavior)
460+
461+
assert result3 is not None
462+
assert ("Updated note" in result3 or "Created note" in result3)
463+
464+
# The result should contain either the original permalink or a unique one
465+
assert ("permalink: test/note-1" in result3 or "permalink: test/note-1-1" in result3)
466+
467+
# Verify we can read back the content
468+
if "permalink: test/note-1" in result3:
469+
# Updated existing note case
470+
content = await read_note.fn("test/note-1")
471+
assert "Replacement content for note 1" in content
472+
else:
473+
# Created new note with unique permalink case
474+
content = await read_note.fn("test/note-1-1")
475+
assert "Replacement content for note 1" in content
476+
# Original note should still exist
477+
original_content = await read_note.fn("test/note-1")
478+
assert "Original content for note 1" in original_content

0 commit comments

Comments
 (0)