Skip to content

Commit 1a94bdd

Browse files
authored
Merge branch 'main' into fix/cascade-sync-single-writer
2 parents 4dd913d + 732e0f4 commit 1a94bdd

3 files changed

Lines changed: 147 additions & 6 deletions

File tree

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

Lines changed: 34 additions & 6 deletions
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
@@ -152,15 +165,30 @@ async def _enqueue_async(
152165
def _relative_to_root(root: Path, raw: str) -> str | None:
153166
"""Return ``raw`` relative to ``root`` using POSIX separators.
154167
155-
``None`` when the path is outside the memory root (defensive — the
156-
watcher only watches inside ``root``, but external symlinks could
157-
surface).
168+
watchdog reports absolute paths inside the watched root (``root`` is
169+
already resolved by ``MemoryRoot``), so the common case is a pure string
170+
operation and matches the scanner's key byte for byte. ``resolve()`` on
171+
Windows is two ``GetFinalPathNameByHandle`` opens plus a ``stat()`` per
172+
event, and a per-component walk when the path is already gone — about
173+
40 % of the worker threads' CPU under write load (py-spy on the 2 h
174+
soak). It is kept for the defensive case only: a textual path that is
175+
not under ``root`` (an external symlink surfacing inside the tree) or
176+
one carrying ``..`` segments. Known residue: if ``ReadDirectoryChangesW``
177+
reports a file by its 8.3 short name, the key differs from the
178+
scanner's long-name key until the next sweep reconciles it.
179+
180+
``None`` when the path is outside the memory root even after resolving.
158181
"""
182+
path = Path(raw)
183+
if ".." not in path.parts:
184+
try:
185+
return path.relative_to(root).as_posix()
186+
except ValueError:
187+
pass
159188
try:
160-
rel = Path(raw).resolve().relative_to(root)
189+
return path.resolve().relative_to(root).as_posix()
161190
except ValueError:
162191
return None
163-
return rel.as_posix()
164192

165193

166194
def _safe_mtime(raw: str) -> float:

‎tests/unit/test_memory/test_cascade/test_watcher_helpers.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77

88
from __future__ import annotations
99

10+
import sys
1011
from pathlib import Path
1112

13+
import pytest
14+
1215
from everos.memory.cascade.watcher import _relative_to_root, _safe_mtime
1316

1417

@@ -34,3 +37,43 @@ def test_safe_mtime_existing_path_returns_positive(tmp_path: Path) -> None:
3437
f = tmp_path / "f.md"
3538
f.write_text("ok")
3639
assert _safe_mtime(str(f)) > 0
40+
41+
42+
def test_relative_to_root_within_does_not_touch_the_filesystem(
43+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
44+
) -> None:
45+
"""An in-root path is relativised textually — no ``resolve()``.
46+
47+
``resolve()`` is a filesystem round trip per component (through
48+
Defender on Windows) and the watcher calls this on every event.
49+
"""
50+
51+
def _boom(self: Path, strict: bool = False) -> Path:
52+
raise AssertionError("resolve() must not run for an in-root path")
53+
54+
monkeypatch.setattr(Path, "resolve", _boom)
55+
target = tmp_path / "users" / "u1" / "x.md" # need not exist
56+
assert _relative_to_root(tmp_path, str(target)) == "users/u1/x.md"
57+
58+
59+
@pytest.mark.skipif(sys.platform == "win32", reason="symlinks need a privilege")
60+
def test_relative_to_root_via_symlink_still_resolves(
61+
tmp_path: Path, tmp_path_factory: pytest.TempPathFactory
62+
) -> None:
63+
"""A path that reaches the root through an external symlink still maps."""
64+
root = tmp_path.resolve()
65+
(root / "users" / "u1").mkdir(parents=True)
66+
(root / "users" / "u1" / "x.md").write_text("x", encoding="utf-8")
67+
link = tmp_path_factory.mktemp("elsewhere") / "link_to_root"
68+
link.symlink_to(root, target_is_directory=True)
69+
assert (
70+
_relative_to_root(root, str(link / "users" / "u1" / "x.md")) == "users/u1/x.md"
71+
)
72+
73+
74+
def test_relative_to_root_normalises_dotdot_segments(tmp_path: Path) -> None:
75+
"""A ``..`` segment takes the resolving path so the key stays canonical."""
76+
root = tmp_path.resolve()
77+
(root / "users" / "u1").mkdir(parents=True)
78+
raw = str(root / "users" / "u2" / ".." / "u1" / "x.md")
79+
assert _relative_to_root(root, raw) == "users/u1/x.md"
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)