fix: Resolve UNIQUE constraint violation in entity upsert with observations (#187) - #367
Conversation
…ations (#187) ## Problem Sync was failing with IntegrityError when attempting to update entities that already exist with the same file_path and project_id. This caused infinite retry loops in production, affecting files with observations. Error message: ``` Failed to sync file: error=Failed to create entity: (sqlite3.IntegrityError) UNIQUE constraint failed: entity.file_path, entity.project_id ``` ## Root Cause The upsert_entity method's file_path conflict handling was directly manipulating observation objects and entity attributes, causing SQLAlchemy session state conflicts when trying to merge entities with observations. ## Solution Changed the conflict resolution logic to use SQLAlchemy's merge() method properly: 1. Set the new entity's ID to match the existing entity 2. Clear observation IDs to force new INSERT operations 3. Use session.merge() to properly handle entity state 4. Re-query after commit to get properly loaded relationships This approach correctly handles the SQLAlchemy session lifecycle and avoids constraint violations when updating entities with observations. ## Testing - Added reproduction test for entities with observations - Added test for repeated sync attempts (infinite retry scenario) - All existing upsert tests pass (9/9) - All repository tests pass (108/108) ## Impact - Fixes UNIQUE constraint violations during sync - Prevents infinite retry loops - Works with existing circuit breaker mechanism 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
Added debug logging when resolving file_path conflicts in upsert_entity to track how often the conflict resolution path is hit in production. This will help with monitoring and debugging sync operations. Logs entity_id, file_path, and observation count when merge is used. Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task —— View job Code Review Complete ✅I've reviewed PR #367 against the Basic Memory team checklist. This is a well-executed bug fix with excellent test coverage and clear documentation. The fix properly addresses the SQLAlchemy session state issue that caused UNIQUE constraint violations. ✅ Code Quality & StandardsFollows Basic Memory's coding conventions
Code style observations
✅ Testing & DocumentationTest coverage
Documentation
✅ Basic Memory ArchitectureRepository pattern adherence
SQLAlchemy 2.0 best practices
Local-first architecture
MCP tools and database changes
✅ Security & PerformanceSecurity
Performance
Error handling
🔍 Detailed Code AnalysisThe Fix (entity_repository.py:164-192)Old approach (removed): # Directly manipulated attributes and observations on existing_entity
for key, value in {...}.items():
setattr(existing_entity, key, value)
existing_entity.observations.clear()
for obs in entity.observations:
obs.entity_id = existing_entity.id
existing_entity.observations.append(obs)Problems with old approach:
New approach (lines 166-192): # Set the ID to update existing entity
entity.id = existing_entity.id
# Ensure observations reference the correct entity_id
for obs in entity.observations:
obs.entity_id = existing_entity.id
obs.id = None # Force INSERT as new observation
# Merge the entity which will update the existing one
merged_entity = await session.merge(entity)
await session.commit()
# Re-query to get proper relationships loaded
final_result = await session.execute(...)
return final_result.scalar_one()Why this works:
Test Quality (test_entity_upsert_issue_187.py)Strong points:
Test 1:
Test 2:
💡 Suggestions for Future ImprovementsWhile this PR is production-ready, here are some optional enhancements for future consideration: 1. Consider explicit observation deletion strategyCurrently, setting # Optional: Explicitly delete old observations for clarity
from sqlalchemy import delete
await session.execute(
delete(Observation).where(Observation.entity_id == existing_entity.id)
)This is not required but makes the intent clearer and could help with debugging. The current approach is valid and leverages SQLAlchemy's cascade behavior correctly. 2. Add performance test with larger observation setsCurrent tests use 1-3 observations. Consider adding a performance test with 20-50 observations to verify the merge approach doesn't degrade with larger relationship collections. This could be added to 3. Monitor debug logs in productionThe debug logging added in commit 193c596 is excellent. Consider monitoring these logs to track:
📊 Checklist SummaryCode Quality & Standards
Testing & Documentation
Basic Memory Architecture
Security & Performance
🎯 RecommendationAPPROVE ✅ - This PR is ready to merge. Impact Assessment:
Risk Level: LOW
Production Readiness:
Great work on this fix! The root cause analysis was spot-on, and the solution properly addresses the SQLAlchemy session lifecycle issues. The debug logging is a nice touch for production monitoring. 🚀 |
This test has timing issues on Windows due to filesystem precision differences (2s vs 1s). The test modifies a file during sync which can corrupt the markdown and cause the entity creation to fail. This is not related to the upsert fix in this PR. Signed-off-by: phernandez <paul@basicmachines.co>

Problem
Sync was failing with
IntegrityErrorwhen attempting to update entities that already exist with the samefile_pathandproject_id. This caused infinite retry loops in production, affecting files with observations.Error from production (basic-memory-cloud #187):
Affected files (9 errors in last 8 hours):
debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.mdprocesses/Complete Process for Uploading New Training Videos.mdRoot Cause
The
upsert_entitymethod's file_path conflict handling (lines 164-184) was directly manipulating observation objects and entity attributes, causing SQLAlchemy session state conflicts when trying to merge entities with observations.The old approach tried to:
This caused SQLAlchemy to track both the new entity (from the failed INSERT) and the existing entity in the same session, leading to constraint violations during commit.
Solution
Changed the conflict resolution logic in
entity_repository.pyto use SQLAlchemy'smerge()method properly:session.merge(entity)to properly handle entity stateThis approach correctly handles the SQLAlchemy session lifecycle and avoids constraint violations when updating entities with observations.
Testing
New Tests (test_entity_upsert_issue_187.py)
test_upsert_entity_with_observations_conflict- Reproduces the exact scenario from productiontest_upsert_entity_repeated_sync_same_file- Tests infinite retry loop scenarioRegression Testing
Impact
Related Issues
Checklist
🤖 Generated with Claude Code