Skip to content

Commit 22bebe2

Browse files
authored
perf: reuse logging locks and cache source paths (#416)
Persistent logging now reuses its private sidecar descriptor and caches human-log source paths per invocation. Repeated source paths avoid filesystem resolution, forked children reopen their lock descriptor, and sidecar failures use logging's error handler so commands can complete. Fixes #381. ## Branch maintenance Refs #426. Targets the branch for #415. Retarget and refresh after that parent is squash-merged; preserve the ordered stack. The branch was refreshed without rewriting history to include `main` at `a576cc279739eae5e4cfc33ffab2a7fb56de24de`. ## Current-head validation At `66ce2689a0b5859ad25927aaa9274c7a4ff5ab19`: uv lock freshness and baseline, runtime, strict typing, style, and contracts passed locally with all declared extras. Runtime result: 616 passed, 1 warning, 262 subtests passed in 8.57s. Hosted checks: 7/7 required checks passed; 0 checks pending; 0 unsuccessful checks at 2026-10-04T14:19:40.760978+00:00. See the PR Checks tab and #426 for subsequent results.
1 parent 6e0a39d commit 22bebe2

4 files changed

Lines changed: 266 additions & 44 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and versions are tracked in the repo-root `VERSION` file.
1818

1919
### Changed
2020

21+
- Reuse secure log lock descriptors and cache source paths per invocation; logging I/O failures stay inside logging (#381).
2122
- Align the Typer support floor with the tested matrix and cover representative
2223
minimum/maximum Typer and Click version pairings.
2324
- Prefix formula-leading CSV/TSV cells with an apostrophe by default to protect

‎docs/performance.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,11 @@ extension discovery caches, and run-bundle retention. Ctrl+C is tested through
129129
both the lifecycle boundary and a real POSIX subprocess signal. Windows keeps
130130
the portable lifecycle and persistence checks while skipping only assertions
131131
that require POSIX signal or descriptor semantics.
132+
133+
### Logging hot path
134+
135+
Secure log handlers keep their private sidecar descriptor open until cleanup,
136+
with an advisory lock around each append and a fresh descriptor after fork.
137+
Human formatters cache up to 256 source paths for the current invocation and
138+
project binding. Repeated paths require no filesystem resolution. Sidecar I/O
139+
errors are routed through `logging.Handler.handleError` and do not fail commands.

‎lib/python/base_cli/logging.py‎

Lines changed: 118 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ def secure_log_file_permissions(log_file: Path) -> None:
167167
class SecureLogFileHandler(logging.FileHandler):
168168
def __init__(self, filename: str | os.PathLike[str], *args: object, **kwargs: object) -> None:
169169
self._lock_path = Path(filename).with_name(f".{Path(filename).name}.lock")
170+
self._lock_stream: BinaryIO | None = None
171+
self._lock_identity: tuple[int, int] | None = None
172+
self._lock_pid = os.getpid()
170173
super().__init__(filename, *args, **kwargs) # type: ignore[arg-type]
171174

172175
def _open(self) -> TextIOWrapper:
@@ -184,22 +187,79 @@ def _open(self) -> TextIOWrapper:
184187
raise
185188

186189
def emit(self, record: logging.LogRecord) -> None:
187-
lock_stream = _open_log_lock(self._lock_path)
188190
try:
189-
_lock_log_stream(lock_stream)
190-
super().emit(record)
191+
# An inherited flock descriptor would share ownership with the parent.
192+
if self._lock_pid != os.getpid():
193+
inherited = self._lock_stream
194+
self._lock_stream = None
195+
self._lock_identity = None
196+
self._lock_pid = os.getpid()
197+
if inherited is not None:
198+
try:
199+
inherited.close()
200+
except OSError:
201+
pass
202+
if self._lock_stream is not None:
203+
try:
204+
current = os.stat(self._lock_path, follow_symlinks=False)
205+
stream_stat = os.fstat(self._lock_stream.fileno())
206+
except FileNotFoundError:
207+
current = None
208+
stream_stat = None
209+
if (
210+
current is None
211+
or stream_stat is None
212+
or (
213+
self._lock_identity != (current.st_dev, current.st_ino)
214+
or (stream_stat.st_dev, stream_stat.st_ino) != self._lock_identity
215+
)
216+
):
217+
stale = self._lock_stream
218+
self._lock_stream = None
219+
self._lock_identity = None
220+
stale.close()
221+
else:
222+
try:
223+
restrict_file(self._lock_path)
224+
except FileNotFoundError:
225+
stale = self._lock_stream
226+
self._lock_stream = None
227+
self._lock_identity = None
228+
stale.close()
229+
if self._lock_stream is None:
230+
self._lock_stream = _open_log_lock(self._lock_path)
231+
lock_stat = os.fstat(self._lock_stream.fileno())
232+
self._lock_identity = (lock_stat.st_dev, lock_stat.st_ino)
233+
_lock_log_stream(self._lock_stream)
234+
try:
235+
super().emit(record)
236+
finally:
237+
_unlock_log_stream(self._lock_stream)
238+
except RecursionError:
239+
raise
240+
except Exception:
241+
self.handleError(record)
242+
243+
def close(self) -> None:
244+
self.acquire()
245+
try:
246+
stream, self._lock_stream = self._lock_stream, None
247+
self._lock_identity = None
248+
try:
249+
if stream is not None:
250+
stream.close()
251+
finally:
252+
super().close()
191253
finally:
192-
_unlock_log_stream(lock_stream)
193-
lock_stream.close()
254+
self.release()
194255

195256

196257
def _open_log_lock(path: Path) -> BinaryIO:
197258
path.parent.mkdir(parents=True, exist_ok=True)
198259
stream = path.open("a+b")
199260
try:
200-
if stream.seek(0, os.SEEK_END) == 0:
201-
stream.write(b"0")
202-
stream.flush()
261+
# Byte-range locks may extend beyond EOF. Writing a sentinel before
262+
# acquiring the lock races a Windows writer already holding byte zero.
203263
restrict_file(path)
204264
return stream
205265
except BaseException:
@@ -253,13 +313,19 @@ def __init__(self, *, use_utc: bool | None = None, use_color: bool = False) -> N
253313
resolved_use_utc = legacy_use_utc == "1"
254314
self.use_utc = resolved_use_utc
255315
self.use_color = use_color
316+
self._source_key: tuple[object, ...] | None = None
317+
self._source_roots: tuple[Path, ...] = ()
318+
self._source_cache: dict[str, str] = {}
319+
self._cache_lock = RLock()
320+
self._time_key: tuple[object, ...] | None = None
321+
self._time_text = ""
256322
datefmt = "%Y-%m-%d %H:%M:%S UTC" if self.use_utc else "%Y-%m-%d %H:%M:%S %z"
257323
super().__init__(datefmt=datefmt)
258324
self.converter = time.gmtime if self.use_utc else time.localtime
259325

260326
def format(self, record: logging.LogRecord) -> str:
261327
timestamp = self.formatTime(record, self.datefmt)
262-
source = _source_path(record)
328+
source = self._source_path(record)
263329
level = _level_name(record)
264330
line = f"{timestamp} {level:<7} {source}:{record.lineno} {record.getMessage()}"
265331
if record.exc_info:
@@ -273,6 +339,49 @@ def format(self, record: logging.LogRecord) -> str:
273339
color = _LEVEL_COLORS.get(record.levelno)
274340
return f"{color}{line}{_COLOR_RESET}" if color else line
275341

342+
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
343+
with self._cache_lock:
344+
if datefmt is None:
345+
return super().formatTime(record, datefmt)
346+
# Human timestamps have second precision. Repeated calls to localtime
347+
# and strftime otherwise re-read timezone state on some platforms.
348+
key = (record.created // 1, datefmt, self.converter, os.environ.get("TZ"), time.tzname)
349+
if key != self._time_key:
350+
self._time_text = super().formatTime(record, datefmt)
351+
self._time_key = key
352+
return self._time_text
353+
354+
def _source_path(self, record: logging.LogRecord) -> str:
355+
with self._cache_lock:
356+
cwd = current_working_dir()
357+
try:
358+
context = get_current_context()
359+
except RuntimeError:
360+
key: tuple[object, ...] = (None, cwd)
361+
roots: tuple[Path, ...] = (cwd,)
362+
else:
363+
key = (context.run_id, context.application_home, context.project_root, cwd)
364+
roots = tuple(p for p in (context.application_home, context.project_root) if p is not None)
365+
if key != self._source_key:
366+
self._source_roots = tuple(p.resolve() for p in (*roots, cwd))
367+
self._source_key = key
368+
self._source_cache.clear()
369+
cached = self._source_cache.get(record.pathname)
370+
if cached is not None:
371+
return cached
372+
path = Path(record.pathname).resolve()
373+
source = str(path)
374+
for root in self._source_roots:
375+
try:
376+
source = str(path.relative_to(root))
377+
break
378+
except ValueError:
379+
continue
380+
if len(self._source_cache) >= 256:
381+
self._source_cache.clear()
382+
self._source_cache[record.pathname] = source
383+
return source
384+
276385

277386
def _level_name(record: logging.LogRecord) -> str:
278387
if record.levelno == logging.WARNING:
@@ -282,41 +391,6 @@ def _level_name(record: logging.LogRecord) -> str:
282391
return record.levelname
283392

284393

285-
def _source_path(record: logging.LogRecord) -> str:
286-
path = Path(record.pathname)
287-
candidates = []
288-
application_home = _active_application_home()
289-
if application_home is not None:
290-
candidates.append(application_home)
291-
project_root = _active_project_root()
292-
if project_root is not None:
293-
candidates.append(project_root)
294-
candidates.append(current_working_dir())
295-
296-
for root in candidates:
297-
try:
298-
return str(path.resolve().relative_to(root.resolve()))
299-
except ValueError:
300-
continue
301-
return str(path.resolve())
302-
303-
304-
def _active_project_root() -> Path | None:
305-
try:
306-
context = get_current_context()
307-
except RuntimeError:
308-
return None
309-
return context.project_root
310-
311-
312-
def _active_application_home() -> Path | None:
313-
try:
314-
context = get_current_context()
315-
except RuntimeError:
316-
return None
317-
return context.application_home
318-
319-
320394
def log_invocation(
321395
logger: logging.Logger,
322396
argv: list[str],

‎tests/test_logging_hot_path.py‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import logging
5+
import os
6+
from concurrent.futures import ThreadPoolExecutor
7+
from pathlib import Path
8+
from unittest.mock import patch
9+
10+
import base_cli
11+
import base_cli.logging as module
12+
import pytest
13+
from base_cli.testing import invoke
14+
15+
16+
def test_sidecar_is_opened_once_and_closed(tmp_path: Path) -> None:
17+
handler = module.SecureLogFileHandler(tmp_path / "run.log")
18+
record = logging.LogRecord("test", logging.INFO, __file__, 1, "message", (), None)
19+
with patch.object(module, "_open_log_lock", wraps=module._open_log_lock) as opened:
20+
handler.emit(record)
21+
handler.emit(record)
22+
assert opened.call_count == 1
23+
stream = handler._lock_stream
24+
handler.close()
25+
assert stream.closed
26+
27+
28+
@pytest.mark.skipif(os.name == "nt", reason="Windows does not permit unlinking an open sidecar")
29+
def test_deleted_sidecar_reopens_and_preserves_later_records(tmp_path: Path) -> None:
30+
handler = module.SecureLogFileHandler(tmp_path / "run.log")
31+
try:
32+
handler.emit(logging.LogRecord("test", logging.INFO, __file__, 1, "before", (), None))
33+
handler._lock_path.unlink()
34+
for message in ("after-0", "after-1", "after-2"):
35+
handler.emit(logging.LogRecord("test", logging.INFO, __file__, 1, message, (), None))
36+
finally:
37+
handler.close()
38+
39+
log_text = (tmp_path / "run.log").read_text(encoding="utf-8")
40+
assert log_text.splitlines() == ["before", "after-0", "after-1", "after-2"]
41+
42+
43+
def test_shared_formatter_handles_concurrent_user_and_file_logging(tmp_path: Path) -> None:
44+
user_stream = io.StringIO()
45+
formatter = module.CliFormatter()
46+
logger = base_cli.configure_logger(
47+
"shared-formatter-race",
48+
tmp_path / "run.log",
49+
debug=True,
50+
stream=user_stream,
51+
formatter=formatter,
52+
propagate=False,
53+
)
54+
try:
55+
with ThreadPoolExecutor(max_workers=8) as executor:
56+
list(executor.map(logger.info, (f"message-{index}" for index in range(64))))
57+
assert user_stream.getvalue().count("message-") == 64
58+
assert (tmp_path / "run.log").read_text(encoding="utf-8").count("message-") == 64
59+
finally:
60+
for handler in list(logger.handlers):
61+
handler.close()
62+
logger.removeHandler(handler)
63+
64+
65+
def test_forked_handler_recovers_after_inherited_lock_close_failure(tmp_path: Path) -> None:
66+
handler = module.SecureLogFileHandler(tmp_path / "run.log")
67+
record = logging.LogRecord("test", logging.INFO, __file__, 1, "message", (), None)
68+
69+
class FailingStream:
70+
def close(self) -> None:
71+
raise OSError("already closed")
72+
73+
handler._lock_stream = FailingStream() # type: ignore[assignment]
74+
handler._lock_pid = os.getpid() - 1
75+
try:
76+
handler.emit(record)
77+
assert handler._lock_pid == os.getpid()
78+
assert handler._lock_stream is not None
79+
finally:
80+
handler.close()
81+
82+
83+
def test_recursion_errors_follow_stdlib_handler_contract(tmp_path: Path) -> None:
84+
handler = module.SecureLogFileHandler(tmp_path / "run.log")
85+
record = logging.LogRecord("test", logging.INFO, __file__, 1, "message", (), None)
86+
try:
87+
with patch.object(logging.FileHandler, "emit", side_effect=RecursionError("recursive")):
88+
with pytest.raises(RecursionError, match="recursive"):
89+
handler.emit(record)
90+
finally:
91+
handler.close()
92+
93+
94+
def test_logging_lock_failures_do_not_fail_command(tmp_path: Path) -> None:
95+
app = base_cli.App(name="logging-io-failure")
96+
97+
@app.command()
98+
def main(ctx: base_cli.Context) -> None:
99+
for failure in (PermissionError("unwritable log directory"), OSError("volume full")):
100+
with patch.object(module, "_lock_log_stream", side_effect=failure):
101+
ctx.log.info("still completes")
102+
ctx.log.info("recovers")
103+
104+
result = invoke(app, [], home=tmp_path)
105+
assert result.exit_code == 0
106+
assert "Logging error" in result.stderr
107+
108+
109+
def test_formatter_repeated_paths_do_not_resolve_again(tmp_path: Path) -> None:
110+
app = base_cli.App(name="cached-log-source")
111+
112+
@app.command()
113+
def main(ctx: base_cli.Context) -> None:
114+
ctx.log.info("warm cache")
115+
with patch.object(Path, "resolve", side_effect=AssertionError("unexpected resolution")):
116+
ctx.log.info("cached source")
117+
118+
assert invoke(app, [], home=tmp_path).exit_code == 0
119+
120+
121+
def test_timestamp_cache_preserves_seconds_and_timezone_format() -> None:
122+
for use_utc in (False, True):
123+
formatter = module.CliFormatter(use_utc=use_utc)
124+
reference = logging.Formatter(datefmt=formatter.datefmt)
125+
reference.converter = formatter.converter
126+
record = logging.LogRecord("test", logging.INFO, __file__, 1, "message", (), None)
127+
for created in (1000.1, 1000.9, 1001.0, 1002.3):
128+
record.created = created
129+
assert formatter.formatTime(record, formatter.datefmt) == reference.formatTime(record, formatter.datefmt)
130+
131+
132+
def test_opening_lock_does_not_write_an_unlocked_sentinel(tmp_path: Path) -> None:
133+
path = tmp_path / "append.lock"
134+
with module._open_log_lock(path) as stream:
135+
module._lock_log_stream(stream)
136+
try:
137+
assert path.stat().st_size == 0
138+
finally:
139+
module._unlock_log_stream(stream)

0 commit comments

Comments
 (0)