Skip to content

Commit 7ecb672

Browse files
phernandezclaude
andcommitted
perf(core): coalesce per-write relation resolution into one offline pass
Scheduling a whole-project resolve_project_relations on every write (#1015) made the accept path heavier and piled up under concurrency. A write-load benchmark showed this branch ~30-70% slower per accept than main on local SQLite, with the per-write full-project relation scan as the dominant cost. Coalesce instead: the first write of a burst schedules one debounced background pass and every other write for that project coalesces onto it (at most one pending pass per project, tracked in process-global state). The accept path only enqueues; reconciliation runs offline. Test mode early-returns before marking pending so the coalescing set cannot leak. Refs benchmarks/docs/write-load-benchmark.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent cf81acd commit 7ecb672

2 files changed

Lines changed: 68 additions & 9 deletions

File tree

src/basic_memory/deps/services.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -947,29 +947,56 @@ class RelationResolutionScheduler(Protocol):
947947
def schedule_relation_resolution(self, *, project_id: int) -> None: ...
948948

949949

950+
# Process-lifetime coalescing state: project ids with a relation-resolution
951+
# pass already pending or in flight. A burst of writes collapses to a single
952+
# offline pass instead of one whole-project relation scan per write — running a
953+
# scan per write made the write path heavier and piled up under concurrency
954+
# (see benchmarks/docs/write-load-benchmark.md).
955+
_pending_relation_resolution: set[int] = set()
956+
957+
950958
@dataclass(frozen=True, slots=True)
951959
class LocalRelationResolutionScheduler:
952-
"""Back-resolve dangling forward references off the request path.
960+
"""Back-resolve dangling forward references off the request path, coalesced.
953961
954962
The MCP/API write path inline-indexes the materialized note but never
955963
back-resolves inbound `[[wikilinks]]` whose target the new note now
956-
satisfies. The watcher's relation repair does this, but only for files it is
957-
the first to index, so MCP writes (which pre-index the file) never trigger
958-
it. Scheduling a project relation pass here gives MCP writes the same
959-
back-resolution the watcher path already provides (#1015). No-op in test
960-
mode, consistent with the other local schedulers.
964+
satisfies (#1015). Resolution is a whole-project scan, so running it per
965+
write is both wasteful and a real write-load cost. Instead each write only
966+
enqueues: the first write of a burst schedules one debounced background pass
967+
and every other write coalesces onto it (at most one pending pass per
968+
project). The accept path stays light; reconciliation runs offline. No-op in
969+
test mode, consistent with the other local schedulers.
961970
"""
962971

963972
relation_runtime: RelationResolutionRuntime
964973
test_mode: bool
974+
debounce_seconds: float = 0.5
965975

966976
def schedule_relation_resolution(self, *, project_id: int) -> None:
967-
_ = project_id # runtime is already bound to the request's project
977+
# Early-return in test mode BEFORE touching the pending set: the
978+
# background coroutine (which clears the set) never runs under test mode,
979+
# so adding here would leak the project id forever.
980+
if self.test_mode:
981+
return
982+
# Coalesce: a pass is already pending/running for this project, so this
983+
# write is already covered — do not schedule another scan.
984+
if project_id in _pending_relation_resolution:
985+
return
986+
_pending_relation_resolution.add(project_id)
968987
_schedule_background_coroutine(
969-
resolve_project_relations(self.relation_runtime),
988+
self._resolve_after_debounce(project_id),
970989
test_mode=self.test_mode,
971990
)
972991

992+
async def _resolve_after_debounce(self, project_id: int) -> None:
993+
try:
994+
# Debounce: let the burst settle so one pass covers all of it.
995+
await asyncio.sleep(self.debounce_seconds)
996+
await resolve_project_relations(self.relation_runtime)
997+
finally:
998+
_pending_relation_resolution.discard(project_id)
999+
9731000

9741001
async def get_relation_resolution_scheduler(
9751002
session_maker: SessionMakerDep,

tests/services/test_task_scheduler_semantic.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,22 +99,52 @@ async def resolve_relations(self, entity_id: int | None = None) -> set[int]:
9999

100100
@pytest.mark.asyncio
101101
async def test_relation_resolution_scheduler_runs_project_resolution():
102-
"""Relation resolution scheduling should run a project resolution pass."""
102+
"""A single write schedules one debounced project resolution pass."""
103+
from basic_memory.deps.services import _pending_relation_resolution
104+
105+
_pending_relation_resolution.clear()
103106
runtime = StubRelationResolutionRuntime()
104107

105108
scheduler = LocalRelationResolutionScheduler(
106109
relation_runtime=runtime,
107110
test_mode=False,
111+
debounce_seconds=0.0,
108112
)
109113
scheduler.schedule_relation_resolution(project_id=13)
110114
await asyncio.sleep(0.05)
111115

112116
assert runtime.resolve_calls == 1
117+
# The pending marker is cleared after the pass so later writes can schedule.
118+
assert 13 not in _pending_relation_resolution
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_relation_resolution_scheduler_coalesces_a_burst():
123+
"""A burst of writes collapses to a single project resolution pass."""
124+
from basic_memory.deps.services import _pending_relation_resolution
125+
126+
_pending_relation_resolution.clear()
127+
runtime = StubRelationResolutionRuntime()
128+
129+
scheduler = LocalRelationResolutionScheduler(
130+
relation_runtime=runtime,
131+
test_mode=False,
132+
debounce_seconds=0.02,
133+
)
134+
for _ in range(10):
135+
scheduler.schedule_relation_resolution(project_id=7)
136+
await asyncio.sleep(0.1)
137+
138+
# Ten writes, one offline pass — not one whole-project scan per write.
139+
assert runtime.resolve_calls == 1
113140

114141

115142
@pytest.mark.asyncio
116143
async def test_relation_resolution_scheduler_is_noop_in_test_mode():
117144
"""Test mode should suppress the background resolution pass entirely."""
145+
from basic_memory.deps.services import _pending_relation_resolution
146+
147+
_pending_relation_resolution.clear()
118148
runtime = StubRelationResolutionRuntime()
119149

120150
scheduler = LocalRelationResolutionScheduler(
@@ -125,3 +155,5 @@ async def test_relation_resolution_scheduler_is_noop_in_test_mode():
125155
await asyncio.sleep(0.05)
126156

127157
assert runtime.resolve_calls == 0
158+
# Test mode must not leak a pending marker (it never runs the clearer).
159+
assert 13 not in _pending_relation_resolution

0 commit comments

Comments
 (0)