1414from __future__ import annotations
1515
1616import asyncio
17+ from collections .abc import Awaitable
1718from pathlib import Path
1819
1920from 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
132145async def _enqueue_async (
133146 spec : KindSpec , rel : str , change_type : str , mtime : float
@@ -152,15 +165,30 @@ async def _enqueue_async(
152165def _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
166194def _safe_mtime (raw : str ) -> float :
0 commit comments