Skip to content

Commit 5406f22

Browse files
authored
bugfix(python): Use a cross platform file locking package instead of … (#574)
1 parent ab47756 commit 5406f22

3 files changed

Lines changed: 48 additions & 33 deletions

File tree

.github/workflows/python_ci.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ jobs:
7272
run: |
7373
mypy lib
7474
75+
# Re-run mypy with --platform=win32 so typeshed evaluates platform-gated
76+
# stubs (e.g. fcntl) as if we were on Windows. Catches imports that
77+
# would only fail at runtime on Windows.
78+
- name: MyPy (Windows platform)
79+
run: |
80+
mypy --platform=win32 lib
81+
7582
- name: Pyright
7683
run: |
7784
pyright lib

python/lib/sift_client/_internal/low_level_wrappers/_test_results_log.py

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Internal log-format primitives for test-result simulation logs.
22
3-
Two files per run:
3+
Three files per run:
44
55
* **Log file** (e.g. ``foo.jsonl``) - append-only record of each logged API call,
66
one line per call. Written by :func:`log_request_to_file` in the test process
77
and read by :func:`iter_log_data_lines` / the replay subprocess. Has no header:
88
every line is a data line.
9+
* **Lock sidecar** (``foo.jsonl.lock``) - empty file used by :class:`filelock.FileLock`
10+
to coordinate appends and snapshot reads across processes. Created on demand;
11+
``filelock`` unlinks it on release on Unix, and on Windows it may linger but is
12+
harmless to leave or delete after the run.
913
* **Tracking sidecar** (``foo.jsonl.tracking``) - small JSON file holding the
1014
incremental replay cursor (``lastUploadedLine``) and the simulated-to-real ID
1115
map. Written only by the replay subprocess via :meth:`LogTracking.save` using
@@ -15,33 +19,34 @@
1519
1620
# Concurrency
1721
18-
With tracking moved out of the main log, the log file becomes strictly
19-
append-only and has exactly one in-place mutator (the writer) and one scanner
20-
(the replay subprocess). POSIX guarantees that an ``O_APPEND`` write atomically
21-
bumps the EOF, so parallel writers can't lose data. To keep a concurrent reader
22-
from observing a mid-append partial final line we still take ``LOCK_EX`` on the
23-
writer's single append and ``LOCK_SH`` on the reader's ``readlines()``; there
24-
is never any exclusive-vs-exclusive contention because nothing rewrites the
25-
file any more.
26-
27-
The sidecar has a single writer (the replay subprocess) and no live reader, so
28-
it needs no locking. Atomic rename is still used to keep the on-disk contents
29-
valid across crashes.
30-
31-
``flock`` is advisory, so this contract only holds for processes that use these
32-
helpers; ad-hoc writers are not protected.
22+
With tracking moved out of the main log, the log file is strictly append-only
23+
and has exactly one in-place mutator (the writer) and one scanner (the replay
24+
subprocess). Both serialize through a cross-platform exclusive ``FileLock`` on
25+
the lock sidecar: the writer holds it across a single append, the reader holds
26+
it across the snapshot ``readlines()``. That keeps a concurrent reader from
27+
observing a mid-append partial final line on any OS, including Windows where
28+
POSIX advisory ``flock`` is unavailable. The exclusive-only lock means a hot
29+
reader briefly blocks the writer (and vice versa), which is acceptable because
30+
writes are tiny and we only have one reader.
31+
32+
The tracking sidecar has a single writer (the replay subprocess) and no live
33+
reader, so it needs no locking. Atomic rename is still used to keep the on-disk
34+
contents valid across crashes.
35+
36+
The lock is advisory: it only protects callers that go through these helpers.
37+
Ad-hoc writers to the same path are not protected.
3338
"""
3439

3540
from __future__ import annotations
3641

37-
import fcntl
3842
import json
3943
import os
4044
import re
4145
from dataclasses import dataclass, field
4246
from pathlib import Path
4347
from typing import TYPE_CHECKING, Any, Generator
4448

49+
from filelock import FileLock
4550
from google.protobuf import json_format
4651

4752
if TYPE_CHECKING:
@@ -153,9 +158,10 @@ def log_request_to_file(
153158
) -> None:
154159
"""Append a request as a JSON-encoded line to ``log_file``.
155160
156-
Takes ``LOCK_EX`` across the append so a concurrent reader holding
157-
``LOCK_SH`` in :func:`iter_log_data_lines` can't see a mid-write partial
158-
final line. See the module docstring for the full concurrency model.
161+
Holds an exclusive :class:`filelock.FileLock` on the sidecar across the
162+
append so a concurrent reader in :func:`iter_log_data_lines` can't see a
163+
mid-write partial final line. See the module docstring for the full
164+
concurrency model.
159165
160166
Args:
161167
log_file: Path to the log file.
@@ -171,15 +177,15 @@ def log_request_to_file(
171177
request_dict = json_format.MessageToDict(request)
172178
request_json = json.dumps(request_dict, separators=(",", ":"))
173179
line = f"[{tag}] {request_json}\n"
174-
with open(log_path, "a") as f:
175-
fcntl.flock(f, fcntl.LOCK_EX)
176-
# Closing the file flushes and releases the flock atomically; no
177-
# explicit unlock needed here.
178-
f.write(line)
180+
with FileLock(str(log_path.with_name(log_path.name + ".lock"))):
181+
with open(log_path, "a") as f:
182+
# The inner ``with`` flushes and closes the file before the
183+
# FileLock is released, so no reader sees a partial line.
184+
f.write(line)
179185

180186

181187
def iter_log_data_lines(
182-
log_path: Path,
188+
log_path: str | Path,
183189
start_line: int = 0,
184190
) -> Generator[tuple[str, str | None, str], None, None]:
185191
"""Parse data lines from a log file.
@@ -191,15 +197,16 @@ def iter_log_data_lines(
191197
iterator skips the first ``start_line`` lines and yields the rest. Pass 0
192198
to read all data lines.
193199
194-
Acquires ``LOCK_SH`` only while snapshotting the file into memory, then
195-
releases before yielding. Lines appended by a concurrent
196-
:func:`log_request_to_file` after the snapshot are not visible this call --
197-
they will be picked up on the next invocation.
200+
Holds the sidecar :class:`filelock.FileLock` only while snapshotting the
201+
file into memory, then releases before yielding. Lines appended by a
202+
concurrent :func:`log_request_to_file` after the snapshot are not visible
203+
this call -- they will be picked up on the next invocation.
198204
"""
199205
line_pattern = re.compile(r"^\[(\w+)(?::([^\]]+))?\]\s*(.+)$")
200-
with open(log_path) as f:
201-
fcntl.flock(f, fcntl.LOCK_SH)
202-
raw_lines = f.readlines()
206+
log_path = Path(log_path)
207+
with FileLock(str(log_path.with_name(log_path.name + ".lock"))):
208+
with open(log_path) as f:
209+
raw_lines = f.readlines()
203210

204211
data_line_count = 0
205212
for raw_line in raw_lines:

python/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies = [
4444
"types-requests~=2.25",
4545
"googleapis-common-protos>=1.60",
4646
"protoc-gen-openapiv2>=0.0.1",
47+
"filelock~=3.13",
4748
]
4849

4950
[project.urls]

0 commit comments

Comments
 (0)