Skip to content

Commit 729a5a3

Browse files
phernandezclaude
andauthored
fix: Terminate sync immediately when project is deleted (#366)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 434cdf2 commit 729a5a3

File tree

5 files changed

+143
-1
lines changed

5 files changed

+143
-1
lines changed

src/basic_memory/repository/entity_repository.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,20 @@ async def upsert_entity(self, entity: Entity) -> Entity:
135135
)
136136
return found
137137

138-
except IntegrityError:
138+
except IntegrityError as e:
139+
# Check if this is a FOREIGN KEY constraint failure
140+
error_str = str(e)
141+
if "FOREIGN KEY constraint failed" in error_str:
142+
# Import locally to avoid circular dependency (repository -> services -> repository)
143+
from basic_memory.services.exceptions import SyncFatalError
144+
145+
# Project doesn't exist in database - this is a fatal sync error
146+
raise SyncFatalError(
147+
f"Cannot sync file '{entity.file_path}': "
148+
f"project_id={entity.project_id} does not exist in database. "
149+
f"The project may have been deleted. This sync will be terminated."
150+
) from e
151+
139152
await session.rollback()
140153

141154
# Re-query after rollback to get a fresh, attached entity

src/basic_memory/services/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,18 @@ class DirectoryOperationError(Exception):
2020
"""Raised when directory operations fail"""
2121

2222
pass
23+
24+
25+
class SyncFatalError(Exception):
26+
"""Raised when sync encounters a fatal error that prevents continuation.
27+
28+
Fatal errors include:
29+
- Project deleted during sync (FOREIGN KEY constraint)
30+
- Database corruption
31+
- Critical system failures
32+
33+
When this exception is raised, the entire sync operation should be terminated
34+
immediately rather than attempting to continue with remaining files.
35+
"""
36+
37+
pass

src/basic_memory/sync/sync_service.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from basic_memory.repository import EntityRepository, RelationRepository, ObservationRepository
2323
from basic_memory.repository.search_repository import SearchRepository
2424
from basic_memory.services import EntityService, FileService
25+
from basic_memory.services.exceptions import SyncFatalError
2526
from basic_memory.services.link_resolver import LinkResolver
2627
from basic_memory.services.search_service import SearchService
2728
from basic_memory.services.sync_status_service import sync_status_tracker, SyncStatus
@@ -514,6 +515,13 @@ async def sync_file(
514515
return entity, checksum
515516

516517
except Exception as e:
518+
# Check if this is a fatal error (or caused by one)
519+
# Fatal errors like project deletion should terminate sync immediately
520+
if isinstance(e, SyncFatalError) or isinstance(e.__cause__, SyncFatalError):
521+
logger.error(f"Fatal sync error encountered, terminating sync: path={path}")
522+
raise
523+
524+
# Otherwise treat as recoverable file-level error
517525
error_msg = str(e)
518526
logger.error(f"Failed to sync file: path={path}, error={error_msg}")
519527

tests/repository/test_entity_repository_upsert.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from basic_memory.models.knowledge import Entity
77
from basic_memory.repository.entity_repository import EntityRepository
88
from basic_memory.repository.project_repository import ProjectRepository
9+
from basic_memory.services.exceptions import SyncFatalError
910

1011

1112
@pytest.mark.asyncio
@@ -436,3 +437,32 @@ async def test_upsert_entity_permalink_conflict_within_project_only(session_make
436437
assert result1.project_id == project1.id
437438
assert result2.project_id == project2.id
438439
assert result3.project_id == project1.id
440+
441+
442+
@pytest.mark.asyncio
443+
async def test_upsert_entity_with_invalid_project_id(entity_repository: EntityRepository):
444+
"""Test that upserting with non-existent project_id raises clear error.
445+
446+
This tests the fix for issue #188 where sync fails with FOREIGN KEY constraint
447+
violations when a project is deleted during sync operations.
448+
"""
449+
# Create entity with non-existent project_id
450+
entity = Entity(
451+
title="Test Entity",
452+
entity_type="note",
453+
file_path="test.md",
454+
permalink="test",
455+
project_id=99999, # This project doesn't exist
456+
content_type="text/markdown",
457+
created_at=datetime.now(timezone.utc),
458+
updated_at=datetime.now(timezone.utc),
459+
)
460+
461+
# Should raise SyncFatalError with clear message about missing project
462+
with pytest.raises(SyncFatalError) as exc_info:
463+
await entity_repository.upsert_entity(entity)
464+
465+
error_msg = str(exc_info.value)
466+
assert "project_id=99999 does not exist" in error_msg
467+
assert "project may have been deleted" in error_msg.lower()
468+
assert "sync will be terminated" in error_msg.lower()

tests/sync/test_sync_service.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,3 +1565,79 @@ async def mock_compute_checksum(path):
15651565
failure_info = sync_service._file_failures["checksum_fail.md"]
15661566
assert failure_info.count == 1
15671567
assert failure_info.last_checksum == "" # Empty when checksum fails
1568+
1569+
1570+
@pytest.mark.asyncio
1571+
async def test_sync_fatal_error_terminates_sync_immediately(
1572+
sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
1573+
):
1574+
"""Test that SyncFatalError terminates sync immediately without circuit breaker retry.
1575+
1576+
This tests the fix for issue #188 where project deletion during sync should
1577+
terminate immediately rather than retrying each file 3 times.
1578+
"""
1579+
from unittest.mock import patch
1580+
from basic_memory.services.exceptions import SyncFatalError
1581+
1582+
project_dir = project_config.home
1583+
1584+
# Create multiple test files
1585+
await create_test_file(
1586+
project_dir / "file1.md",
1587+
dedent(
1588+
"""
1589+
---
1590+
type: knowledge
1591+
---
1592+
# File 1
1593+
Content 1
1594+
"""
1595+
),
1596+
)
1597+
await create_test_file(
1598+
project_dir / "file2.md",
1599+
dedent(
1600+
"""
1601+
---
1602+
type: knowledge
1603+
---
1604+
# File 2
1605+
Content 2
1606+
"""
1607+
),
1608+
)
1609+
await create_test_file(
1610+
project_dir / "file3.md",
1611+
dedent(
1612+
"""
1613+
---
1614+
type: knowledge
1615+
---
1616+
# File 3
1617+
Content 3
1618+
"""
1619+
),
1620+
)
1621+
1622+
# Mock entity_service.create_entity_from_markdown to raise SyncFatalError on first file
1623+
# This simulates project being deleted during sync
1624+
async def mock_create_entity_from_markdown(*args, **kwargs):
1625+
raise SyncFatalError(
1626+
"Cannot sync file 'file1.md': project_id=99999 does not exist in database. "
1627+
"The project may have been deleted. This sync will be terminated."
1628+
)
1629+
1630+
with patch.object(
1631+
entity_service, "create_entity_from_markdown", side_effect=mock_create_entity_from_markdown
1632+
):
1633+
# Sync should raise SyncFatalError and terminate immediately
1634+
with pytest.raises(SyncFatalError, match="project_id=99999 does not exist"):
1635+
await sync_service.sync(project_dir)
1636+
1637+
# Verify that circuit breaker did NOT record this as a file-level failure
1638+
# (SyncFatalError should bypass circuit breaker and re-raise immediately)
1639+
assert "file1.md" not in sync_service._file_failures
1640+
1641+
# Verify that no other files were attempted (sync terminated on first error)
1642+
# If circuit breaker was used, we'd see file1 in failures
1643+
# If sync continued, we'd see attempts for file2 and file3

0 commit comments

Comments
 (0)