Files
basicmachines-co-basic-memory/tests/models/test_knowledge.py
T
claude[bot] e6d6094e7f fix(sync): constrain WatchService to --project scope to prevent concurrent-process races
When multiple `basic-memory mcp --project <name>` processes run concurrently,
each process previously spawned a file watcher over every active project instead
of just the constrained one. This caused N overlapping watchers racing on the same
files, producing cascade-delete crashes, atomic-rename collisions, and phantom
"entity not found" errors.

Changes:
- WatchService now accepts `constrained_project` and filters the per-cycle
  project reload to that value when set, so N concurrent MCP processes produce
  N disjoint watchers instead of N overlapping ones
- initialize_file_sync reads BASIC_MEMORY_MCP_PROJECT once and wires it to both
  the one-shot background sync filter and the new WatchService kwarg
- Relation.permalink guards from_entity for None (same pattern as to_entity) to
  defend against the AttributeError that surfaces when a concurrent watcher
  cascade-deletes an entity while another process is resolving relation permalinks
- write_file_atomic uses a PID-unique temp suffix ({stem}.{pid}.tmp) so
  concurrent processes writing the same file no longer collide on the temp name

Fixes #758

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-20 01:34:36 +00:00

71 lines
2.2 KiB
Python

"""Unit tests for knowledge model properties."""
from types import SimpleNamespace
from basic_memory.models.knowledge import Relation
def _entity(permalink: str | None, file_path: str) -> SimpleNamespace:
return SimpleNamespace(permalink=permalink, file_path=file_path)
def _relation(**kwargs) -> Relation:
"""Build a Relation with required fields; relationship attrs set to None by default."""
defaults = dict(
id=1,
project_id=1,
from_id=42,
to_id=None,
to_name="Target",
relation_type="implements",
context=None,
)
defaults.update(kwargs)
r = Relation(**defaults)
# Simulate unloaded lazy relationships (no active session)
r.from_entity = None
r.to_entity = None
return r
def test_relation_permalink_with_both_entities():
"""Normal case: both from_entity and to_entity are loaded."""
r = _relation()
r.from_entity = _entity(permalink="specs/source", file_path="specs/source.md")
r.to_entity = _entity(permalink="features/target", file_path="features/target.md")
assert r.permalink == "specs/source/implements/features/target"
def test_relation_permalink_no_to_entity():
"""to_entity is None — falls back to to_name."""
r = _relation()
r.from_entity = _entity(permalink="specs/source", file_path="specs/source.md")
assert r.permalink == "specs/source/implements/target"
def test_relation_permalink_from_entity_none():
"""from_entity is None (race condition: entity deleted mid-resolution).
Should fall back to str(from_id) rather than raising AttributeError.
This is the guard added to fix the concurrent-watcher crash described in issue #758.
"""
r = _relation(from_id=42)
# from_entity stays None
result = r.permalink
assert "42" in result
assert "implements" in result
def test_relation_permalink_from_entity_none_with_to_entity():
"""from_entity is None but to_entity is loaded — both guards compose correctly."""
r = _relation(from_id=7)
r.to_entity = _entity(permalink="features/resolved", file_path="features/resolved.md")
result = r.permalink
assert "7" in result
assert "implements" in result
assert "features/resolved" in result