fix: Resolve UNIQUE constraint violation in entity upsert with observations (#187) by phernandez · Pull Request #367 · basicmachines-co/basic-memory · GitHub
Skip to content

fix: Resolve UNIQUE constraint violation in entity upsert with observations (#187) - #367

Merged
phernandez merged 3 commits into
mainfrom
fix/issue-187-unique-constraint-upsert
Oct 16, 2025
Merged

fix: Resolve UNIQUE constraint violation in entity upsert with observations (#187)#367
phernandez merged 3 commits into
mainfrom
fix/issue-187-unique-constraint-upsert

Conversation

@phernandez

Copy link
Copy Markdown
Member

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 from production (basic-memory-cloud #187):

Failed to sync file: error=Failed to create entity: (sqlite3.IntegrityError)
UNIQUE constraint failed: entity.file_path, entity.project_id

Affected files (9 errors in last 8 hours):

  • debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.md
  • processes/Complete Process for Uploading New Training Videos.md
  • Several other markdown files with observations

Root Cause

The upsert_entity method'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:

  1. Set attributes on the existing entity
  2. Clear and re-add observations directly

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.py to use SQLAlchemy's merge() method properly:

  1. Set the new entity's ID to match the existing entity (makes it an update)
  2. Clear observation IDs to force new INSERT operations (prevents duplicate key errors)
  3. Use session.merge(entity) 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

New Tests (test_entity_upsert_issue_187.py)

  • test_upsert_entity_with_observations_conflict - Reproduces the exact scenario from production
  • test_upsert_entity_repeated_sync_same_file - Tests infinite retry loop scenario

Regression Testing

  • ✅ All existing upsert tests pass (9/9)
  • ✅ All repository tests pass (108/108)

Impact

Related Issues

Checklist

  • Tests added for bug reproduction
  • All existing tests pass
  • Commit signed off
  • Issue link added

🤖 Generated with Claude Code

…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>
@github-actions

github-actions Bot commented Oct 16, 2025

Copy link
Copy Markdown
Contributor

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>
@github-actions

github-actions Bot commented Oct 16, 2025

Copy link
Copy Markdown
Contributor

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 & Standards

Follows Basic Memory's coding conventions

  • Line length: All lines within 100 character limit
  • Naming conventions: snake_case used correctly throughout
  • Import order: Proper organization (standard lib → third-party → local)
  • Python 3.12+ type hints: Type annotations present in method signatures
  • Async patterns: Uses SQLAlchemy 2.0 async patterns correctly (async with, await)
  • Repository pattern: Changes properly scoped to repository layer

Code style observations

  • ✅ No commented-out code blocks
  • ✅ Clean, readable implementation
  • ✅ Proper use of context managers for session handling
  • ✅ Good debug logging added in follow-up commit (193c596)

✅ Testing & Documentation

Test coverage

  • Bug reproduction test (test_entity_upsert_issue_187.py:11-83): Directly reproduces the production error scenario with observations
  • Regression test (test_entity_upsert_issue_187.py:86-133): Tests the infinite retry loop scenario with 5 sync iterations
  • Integration with existing tests: All 108 repository tests pass
  • Edge cases covered:
    • Entity ID preservation (line 74)
    • Observation replacement (lines 80-82)
    • Multiple sync attempts with different checksums (lines 112-132)
  • Test structure: Follows existing patterns from test_entity_repository_upsert.py
  • Realistic test data: Uses actual file paths from production error logs

Documentation

  • PR description: Excellent with clear problem statement, root cause analysis, solution explanation, and impact assessment
  • Docstrings: Test functions have clear docstrings explaining what they test
  • Commit message: Well-structured with problem/solution/testing/impact sections
  • Code comments: Inline comments explain the merge approach (lines 171-179)
  • CLAUDE.md: No updates needed (architectural patterns unchanged)

✅ Basic Memory Architecture

Repository pattern adherence

  • Scoped to data layer: Changes properly contained in entity_repository.py
  • Session management: Uses db.scoped_session() context manager correctly (line 116)
  • Relationship loading: Properly uses get_load_options() for eager loading after merge (lines 187-191)
  • Error handling: Maintains existing SyncFatalError for FOREIGN KEY violations (lines 141-151)

SQLAlchemy 2.0 best practices

  • Session lifecycle: Correctly handles rollback before re-querying (line 153)
  • Merge operation: Uses session.merge() instead of manual attribute setting (line 182)
  • Detached entities: Re-queries after commit to get properly attached entities with relationships (lines 187-192)
  • Cascade behavior: Leverages existing cascade rules - clearing obs.id = None forces new INSERTs while old observations are deleted by cascade (lines 178-179)

Local-first architecture

  • File integrity: Fix ensures files can be re-synced without constraint violations
  • Idempotent operations: Multiple syncs of same file produce correct results (tested in lines 112-132)
  • Knowledge graph consistency: Entity relationships properly maintained through eager loading

MCP tools and database changes

  • No schema changes: No Alembic migrations needed (fix is logic-only)
  • No MCP tool changes: Repository layer fix doesn't affect tool interface

✅ Security & Performance

Security

  • ✅ No hardcoded secrets or credentials
  • ✅ Proper error messages without sensitive data leakage
  • ✅ Input validation inherited from existing repository patterns
  • ✅ FOREIGN KEY validation still enforced (lines 142-151)

Performance

  • Minimal overhead: Only adds work when IntegrityError occurs (exceptional path)
  • Single query pattern: Re-query after merge is necessary and efficient (lines 187-192)
  • No N+1 queries: Uses selectinload() for eager loading relationships
  • Debug logging: Added to track conflict resolution frequency in production (lines 167-170)

Error handling

  • Proper exception handling: Catches IntegrityError specifically (line 139)
  • Maintains FOREIGN KEY error handling: Preserves existing SyncFatalError logic (lines 141-151)
  • Clear error context: Production errors will now succeed instead of failing with infinite retries

🔍 Detailed Code Analysis

The 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:

  • SQLAlchemy tracked both the new entity (from failed INSERT) and existing entity in same session
  • Direct manipulation of observation collections caused session state conflicts
  • Relationship synchronization issues during commit

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:

  1. ✅ Setting entity.id = existing_entity.id makes it an update operation for SQLAlchemy
  2. ✅ Clearing obs.id = None forces new INSERTs (existing observations deleted by cascade)
  3. session.merge() properly handles session state and object lifecycle
  4. ✅ Re-query after commit ensures relationships are properly loaded and entity is attached to session

Test Quality (test_entity_upsert_issue_187.py)

Strong points:

  • ✅ Tests are well-named and self-documenting
  • ✅ Direct reproduction of production scenario with actual file paths from error logs
  • ✅ Comprehensive assertions verify entity ID preservation, title updates, observation replacement
  • ✅ Loop test (5 iterations) validates infinite retry scenario prevention
  • ✅ Uses realistic data (actual file paths from production: debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.md)

Test 1: test_upsert_entity_with_observations_conflict

  • Reproduces exact production error: entity with observations, then upsert with different observations
  • Verifies entity ID preservation (line 74)
  • Verifies title update (line 75)
  • Verifies observation count and content (lines 80-82)

Test 2: test_upsert_entity_repeated_sync_same_file

  • Tests infinite retry loop scenario from issue description
  • 5 iterations with different checksums (line 120)
  • Verifies idempotent behavior (lines 129-132)
  • Each iteration confirms same entity ID returned

💡 Suggestions for Future Improvements

While this PR is production-ready, here are some optional enhancements for future consideration:

1. Consider explicit observation deletion strategy

Currently, setting obs.id = None relies on cascade delete to remove old observations. This works but could be more explicit:

# 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 sets

Current 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 test-int/test_sync_performance_benchmark.py with the @pytest.mark.benchmark marker.

3. Monitor debug logs in production

The debug logging added in commit 193c596 is excellent. Consider monitoring these logs to track:

  • How often the conflict resolution path is hit
  • Whether certain files repeatedly hit this path
  • If observation counts correlate with sync issues

📊 Checklist Summary

Code Quality & Standards

  • ✅ Follows Basic Memory's coding conventions in CLAUDE.md
  • ✅ Python 3.12+ type annotations and async patterns
  • ✅ SQLAlchemy 2.0 best practices
  • ✅ FastAPI and Typer conventions followed (N/A - repository layer only)
  • ✅ 100-character line length limit maintained
  • ✅ No commented-out code blocks

Testing & Documentation

  • ✅ Unit tests for new functions/methods (2 comprehensive tests added)
  • ✅ Integration tests for new MCP tools (N/A - repository fix)
  • ✅ Test coverage for edge cases (multiple sync attempts, observation replacement)
  • ✅ Documentation updated (PR description excellent, inline comments clear)
  • ✅ CLAUDE.md updated if conventions change (N/A - no convention changes)

Basic Memory Architecture

  • ✅ MCP tools follow atomic, composable design (N/A - repository layer)
  • ✅ Database changes include Alembic migrations (N/A - no schema changes)
  • ✅ Preserves local-first architecture principles
  • ✅ Knowledge graph operations maintain consistency
  • ✅ Markdown file handling preserves integrity
  • ✅ AI-human collaboration patterns followed (co-authored commits)

Security & Performance

  • ✅ No hardcoded secrets or credentials
  • ✅ Input validation for MCP tools (N/A - repository layer)
  • ✅ Proper error handling and logging
  • ✅ Performance considerations addressed
  • ✅ No sensitive data in logs or commits

🎯 Recommendation

APPROVE ✅ - This PR is ready to merge.

Impact Assessment:

  • ✅ Fixes critical production bug affecting 9 files with infinite retry loops
  • ✅ No breaking changes to API or behavior
  • ✅ Works seamlessly with existing circuit breaker (feat: Add circuit breaker for file sync failures #364)
  • ✅ Excellent test coverage prevents regressions
  • ✅ Clean, maintainable implementation following SQLAlchemy best practices
  • ✅ Debug logging enables production monitoring

Risk Level: LOW

  • Changes isolated to conflict resolution path (exceptional case only)
  • All existing tests pass (108/108 repository tests)
  • New tests prove fix works for reported scenarios
  • Proper rollback and error handling maintained
  • No schema changes required

Production Readiness:

  • ✅ Tested with realistic production data
  • ✅ Handles edge cases (repeated syncs, observation updates)
  • ✅ Logging in place for monitoring
  • ✅ No deployment risks identified

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>
@github-actions

github-actions Bot commented Oct 16, 2025

Copy link
Copy Markdown
Contributor

@phernandez
phernandez merged commit 171bef7 into main Oct 16, 2025
16 checks passed
@phernandez
phernandez deleted the fix/issue-187-unique-constraint-upsert branch October 16, 2025 17:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant