Skip to content

Commit 732e0f4

Browse files
gloryfromcazhanghuiclaude0xKT
authored
fix(cascade): commit watcher upserts for a path in delivery order (#462)
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: zhanghui <zhanghui@shanda.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: KT <74288668+0xKT@users.noreply.github.com>
1 parent 46b63c3 commit 732e0f4

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

‎src/everos/memory/cascade/watcher.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from __future__ import annotations
1515

1616
import asyncio
17+
from collections.abc import Awaitable
1718
from pathlib import Path
1819

1920
from watchdog.events import FileMovedEvent, FileSystemEvent, FileSystemEventHandler
@@ -78,6 +79,14 @@ def __init__(
7879
) -> None:
7980
self._memory_root = memory_root
8081
self._loop = loop
82+
# Upserts for one path must commit in delivery order. Each one awaits
83+
# the database, so left concurrent the last committer wins and a stale
84+
# duplicate overwrites a newer row. Windows synthesises a ``created``
85+
# for every file under a freshly created parent directory, handing the
86+
# same file to this handler four or five times; on the soak box one of
87+
# those duplicates landed after an atomic save's ``added`` and put the
88+
# first write's mtime back on the row (1 run in 4).
89+
self._in_order = asyncio.Lock()
8190

8291
def on_created(self, event: FileSystemEvent) -> None:
8392
self._enqueue(event.src_path, "added")
@@ -124,10 +133,14 @@ def _enqueue(self, raw_path: str, change_type: str) -> None:
124133
return
125134
mtime = _safe_mtime(raw_path)
126135
asyncio.run_coroutine_threadsafe(
127-
_enqueue_async(spec, rel, change_type, mtime),
136+
self._serialised(_enqueue_async(spec, rel, change_type, mtime)),
128137
self._loop,
129138
)
130139

140+
async def _serialised(self, upsert: Awaitable[None]) -> None:
141+
async with self._in_order:
142+
await upsert
143+
131144

132145
async def _enqueue_async(
133146
spec: KindSpec, rel: str, change_type: str, mtime: float
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Upserts for one path commit in delivery order.
2+
3+
Windows synthesises a ``created`` for every file under a freshly created
4+
parent directory, so one ``mkdir -p`` + write hands the handler the same path
5+
four or five times. Each upsert awaits the database; run concurrently, the
6+
last committer wins, and on the Windows soak box a stale duplicate carrying
7+
the first write's mtime overwrote the row of an atomic save (1 run in 4).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import asyncio
13+
import os
14+
from pathlib import Path
15+
16+
import pytest
17+
from watchdog.events import FileCreatedEvent
18+
19+
from everos.core.persistence import MemoryRoot
20+
from everos.memory.cascade import watcher as watcher_mod
21+
from everos.memory.cascade.watcher import _Handler
22+
23+
_M1 = 1_700_000_000
24+
_M2 = 1_700_000_060
25+
26+
27+
class _SlowFirstRepo:
28+
"""The first upsert commits 50 ms late; the row is whatever committed last."""
29+
30+
def __init__(self) -> None:
31+
self.calls = 0
32+
self.committed: list[float] = []
33+
34+
async def upsert(
35+
self, md_path: str, *, kind: str, change_type: str, mtime: float
36+
) -> int:
37+
self.calls += 1
38+
if self.calls == 1:
39+
await asyncio.sleep(0.05)
40+
self.committed.append(mtime)
41+
return self.calls
42+
43+
44+
async def test_duplicate_events_commit_in_delivery_order(
45+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
46+
) -> None:
47+
repo = _SlowFirstRepo()
48+
monkeypatch.setattr(watcher_mod, "md_change_state_repo", repo)
49+
md = tmp_path.joinpath(
50+
"default_app",
51+
"default_project",
52+
"users",
53+
"u1",
54+
"episodes",
55+
"episode-2026-01-01.md",
56+
)
57+
md.parent.mkdir(parents=True)
58+
md.write_text("v1", encoding="utf-8")
59+
handler = _Handler(MemoryRoot(tmp_path), asyncio.get_running_loop())
60+
61+
os.utime(md, (_M1, _M1))
62+
handler.on_created(FileCreatedEvent(str(md))) # the synthetic duplicate
63+
os.utime(md, (_M2, _M2))
64+
handler.on_created(FileCreatedEvent(str(md))) # the save that must win
65+
await asyncio.sleep(0.2)
66+
67+
assert repo.committed == [_M1, _M2], (
68+
"the stale duplicate committed after the newer event; the row now "
69+
"carries the old mtime"
70+
)

0 commit comments

Comments
 (0)