Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions pageindex/local_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,35 @@ def _write_manifest(self, docs: dict) -> None:
@contextmanager
def lock(self):
"""Cross-process mutex for check-then-write sequences (name
uniquing before save). fcntl is absent on Windows, where the
pre-existing best-effort behavior stays."""
uniquing before save)."""
self._root.mkdir(parents=True, exist_ok=True)
lock_path = self._root / ".lock"
try:
import fcntl
except ImportError:
yield
import msvcrt
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
try:
# msvcrt locks a byte range, and an empty file has no byte
# to lock, so seed one exactly once. A concurrent seeder
# racing this write is harmless: same byte, and neither
# side holds the lock yet at this point.
if os.fstat(fd).st_size == 0:
try:
os.write(fd, b"\0")
except OSError:
pass
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
try:
yield
finally:
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
finally:
os.close(fd)
return
self._root.mkdir(parents=True, exist_ok=True)
with open(self._root / ".lock", "w") as handle:
with open(lock_path, "w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
Expand Down
44 changes: 44 additions & 0 deletions tests/test_local_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import threading
import time

from pageindex.local_store import DocStore


def test_lock_serializes_concurrent_critical_sections(tmp_path):
"""DocStore.lock() must be a real mutex on every platform, including
Windows, where fcntl is unavailable and the lock previously no-op'd
(see #concurrent_same_name_submits_store_unique_names)."""
store = DocStore(str(tmp_path / "store"))
order = []
lock_obj = threading.Lock()

def worker(name):
with store.lock():
# If DocStore.lock() is not a real mutex, both workers can be
# inside this block at once and their appends interleave.
with lock_obj:
order.append(f"{name}-enter")
time.sleep(0.2)
with lock_obj:
order.append(f"{name}-exit")

threads = [threading.Thread(target=worker, args=(n,)) for n in ("A", "B")]
for t in threads:
t.start()
for t in threads:
t.join()

# Whichever thread goes first, its enter/exit pair must be contiguous —
# a real mutex never lets the other thread's enter land in between.
assert order[0][-5:] == "enter"
assert order[1][-4:] == "exit"
assert order[0][0] == order[1][0]
Comment on lines +12 to +35


def test_lock_is_reentrant_safe_across_repeated_calls(tmp_path):
"""Sequential lock() calls on the same store must not deadlock or
error, including the msvcrt path's one-time lock-file initialization."""
store = DocStore(str(tmp_path / "store"))
for _ in range(5):
with store.lock():
pass