Skip to content

Commit c9980a6

Browse files
phernandezclaude
andcommitted
fix(api): rerun coalesced index request even when the in-flight run fails
The trailing rerun sat after the try/finally, so an exception from index_project skipped it and the request that coalesced behind the failed run was silently dropped — exactly when it most needed a retry. Reschedule inside finally, after the pending marker clears. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 7d37f9e commit c9980a6

2 files changed

Lines changed: 50 additions & 4 deletions

File tree

src/basic_memory/deps/services.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -964,10 +964,12 @@ async def _run_project_index(self, project_id: int, *, force_full: bool) -> None
964964
finally:
965965
rerun_force_full = _dirty_project_index.pop(project_id, None)
966966
_pending_project_index.discard(project_id)
967-
# Re-arm outside the in-flight window (pending now cleared) so a request
968-
# that raced the run gets its own pass. Bounded to one extra run per burst.
969-
if rerun_force_full is not None:
970-
self.schedule_project_index(project_id=project_id, force_full=rerun_force_full)
967+
# Re-arm inside finally, outside the in-flight window (pending now
968+
# cleared), so a request that raced the run gets its own pass even
969+
# when this run raised — a failed run is exactly when the coalesced
970+
# request most needs its retry. Bounded to one extra run per burst.
971+
if rerun_force_full is not None:
972+
self.schedule_project_index(project_id=project_id, force_full=rerun_force_full)
971973

972974

973975
@dataclass(frozen=True, slots=True)

tests/services/test_task_scheduler_semantic.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,50 @@ async def test_project_index_scheduler_coalesces_requests_during_in_flight_run()
128128
assert 13 not in _dirty_project_index
129129

130130

131+
class FailingThenGatedProjectIndexRunner:
132+
"""Runner whose first run blocks until released, then raises."""
133+
134+
def __init__(self) -> None:
135+
self.calls: list[tuple[int, bool]] = []
136+
self.first_run_started = asyncio.Event()
137+
self.release_first_run = asyncio.Event()
138+
139+
async def index_project(
140+
self,
141+
project_id: int,
142+
*,
143+
force_full: bool = False,
144+
) -> ProjectIndexCoordinatorResult:
145+
self.calls.append((project_id, force_full))
146+
if len(self.calls) == 1:
147+
self.first_run_started.set()
148+
await self.release_first_run.wait()
149+
raise RuntimeError("scan blew up mid-run")
150+
return cast(ProjectIndexCoordinatorResult, object())
151+
152+
153+
@pytest.mark.asyncio
154+
async def test_project_index_scheduler_reruns_coalesced_request_after_failed_run():
155+
"""A request coalesced behind a run that raises must still get its rerun —
156+
a failed run is exactly when the coalesced request most needs its retry."""
157+
from basic_memory.deps.services import _dirty_project_index, _pending_project_index
158+
159+
_clear_project_index_scheduler_state()
160+
runner = FailingThenGatedProjectIndexRunner()
161+
scheduler = LocalProjectIndexScheduler(project_index_runner=runner, test_mode=False)
162+
163+
scheduler.schedule_project_index(project_id=13)
164+
await asyncio.wait_for(runner.first_run_started.wait(), timeout=1)
165+
scheduler.schedule_project_index(project_id=13, force_full=True)
166+
167+
runner.release_first_run.set()
168+
await drain_background_tasks()
169+
170+
assert runner.calls == [(13, False), (13, True)]
171+
assert 13 not in _pending_project_index
172+
assert 13 not in _dirty_project_index
173+
174+
131175
@pytest.mark.asyncio
132176
async def test_project_index_scheduler_single_flight_is_per_project():
133177
"""One project's in-flight run must not block another project's run."""

0 commit comments

Comments
 (0)