fix(cascade): commit watcher upserts for a path in delivery order - #462
Conversation
Each watcher event schedules its own upsert coroutine on the loop, and each upsert awaits the database, so two events for the same path could commit in either order and the last committer won. Windows synthesises a 'created' for every file under a freshly created parent directory, which hands the handler the same file four or five times; on the Windows soak box one of those stale duplicates committed after an atomic save's 'added' and put the first write's mtime and lsn back on the row (test_atomic_replace_over_existing_target_keeps _the_row_alive failed 1 run in 4, only there). Serialise the upserts behind one asyncio.Lock per handler; tasks are created in delivery order and the lock is FIFO, so the row ends with the last event. The scanner's sweep still writes on its own path. Verification: the new test fails against the previous watcher with committed == [m2, m1]; passes with the lock. The six existing watcher tests pass; lint-imports 4/4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Windows evidence, same box (ThinkPad, Windows 11, soak load running throughout):
The same race showed its other face on the GitHub Windows runner today, on #454 which does not carry this fix: Throughput check on macOS (real SQLite, N |
Summary
Two watcher events for the same file could reach
md_change_statein either order: each event schedules its own upsert coroutine and each upsert awaits the database, so the last committer won. Windows synthesises acreatedfor every file under a freshly created parent directory, which hands the handler the same file four or five times. On the Windows soak box (integration branch of the open PRs, 630de2d) one of those stale duplicates committed after an atomic save'saddedand put the first write's mtime and lsn back on the row:test_atomic_replace_over_existing_target_keeps_the_row_alive(from #454) failed 1 run in 4 there and nowhere else.Fix: one
asyncio.Lockper handler around the upsert. Tasks are created in delivery order and the lock is FIFO, so the row ends with the last event. Pre-existing onmain; Windows only makes it visible. The scanner's sweep still writes on its own path (disk is the truth there).Area
Verification
tests/unit/test_memory/test_cascade/test_watcher_upsert_order.py: a repo double whose first upsert commits 50 ms late, twocreatedevents with distinct mtimes. Against the previouswatcher.pyit fails withcommitted == [m2, m1]; with the lock it passes.uv run pytest tests/unit/test_memory/test_cascade -k watcher: 6 passed;lint-imports: 4 kept.deleted+movedwithin 3–13 ms in 30/30 replaces, and the product path updated the row in 22–112 ms in 15/15 probes outside pytest — the failure only reproduced with the synthetic duplicatecreatedevents of a fresh directory tree, which is what the ordering fix addresses. The Windows re-run of the flaky test on the fixed code follows with the next soak launch (the box's checkout is the running soak's code).Checklist
make lintclean for the touched files (ruff + import-linter)Notes for Reviewers
Why a lock and not a monotonic
WHERE excluded.mtime >= mtimeon the upsert: adeletedevent carries mtime 0.0 (the file is gone) and must still win over an earlieradded; order, not mtime, is the invariant.🤖 Generated with Claude Code