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
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
3540from __future__ import annotations
3641
37- import fcntl
3842import json
3943import os
4044import re
4145from dataclasses import dataclass , field
4246from pathlib import Path
4347from typing import TYPE_CHECKING , Any , Generator
4448
49+ from filelock import FileLock
4550from google .protobuf import json_format
4651
4752if 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
181187def 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 :
0 commit comments