Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
30e9e71
ci: cover all consumer fixtures in style and strict typing gates
codeforester Oct 2, 2026
710208b
ci: keep Markdown examples under the documentation gate
codeforester Oct 2, 2026
8bb0562
fix: preserve consumer logging ownership and routing
codeforester Oct 2, 2026
6556889
perf: reuse logging locks and cache source paths
codeforester Oct 2, 2026
c87eeda
fix: enforce native Windows bundle retention safely
codeforester Oct 2, 2026
f0f9ece
ci: satisfy Windows retention style and typing checks
codeforester Oct 2, 2026
9679928
perf: skip contended retention housekeeping
codeforester Oct 2, 2026
a166e45
perf: cache second-precision human log timestamps
codeforester Oct 2, 2026
baf0ff3
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
c7c21ae
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
d352e43
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester Oct 2, 2026
6ee5cc9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester Oct 2, 2026
f9787b1
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester Oct 2, 2026
3780821
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
333ac40
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
6e4394f
fix: avoid racing Windows lock-sidecar initialization
codeforester Oct 2, 2026
bd210cd
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
f1bda9a
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
73770a5
ci: separate sustained persistence cost from hosted filesystem tails
codeforester Oct 2, 2026
bdf26d9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester Oct 2, 2026
c3eb5dc
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester Oct 2, 2026
0597aaa
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
b24372d
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
8ca0027
docs: regenerate public API signatures for configuration trust options
codeforester Oct 2, 2026
9191a7e
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester Oct 2, 2026
b7fd5df
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
6e9d231
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
857e52e
Merge main for branch maintenance (#426)
codeforester Oct 4, 2026
b48e444
Merge updated PR #414 for branch maintenance (#426)
codeforester Oct 4, 2026
c4af745
Merge updated PR #417 for branch maintenance (#426)
codeforester Oct 4, 2026
66ce268
Merge updated PR #415 for branch maintenance (#426)
codeforester Oct 4, 2026
e82ce1f
Merge updated PR #416 for branch maintenance (#426)
codeforester Oct 4, 2026
b00c288
fix: defer contended retention index refresh
codeforester Oct 5, 2026
9db3464
fix: preserve logger routing and levels
codeforester Oct 5, 2026
fbcc75c
fix: harden logging caches and sidecar locks
codeforester Oct 5, 2026
841d1e6
fix: handle Windows retention reparse leaves
codeforester Oct 5, 2026
72336ec
ci: expose consumer typing source coverage
codeforester Oct 5, 2026
311c280
test: verify retention converges after contention
codeforester Oct 5, 2026
ae935d1
fix: serialize logger reconfiguration
codeforester Oct 5, 2026
f15f2c2
style: clean retention convergence test
codeforester Oct 5, 2026
990cb66
style: format typing gate test
codeforester Oct 5, 2026
50b0695
style: format sidecar lock condition
codeforester Oct 5, 2026
c8e829d
Merge remote-tracking branch 'origin/enhancement/381-20261003-perf-li…
codeforester Oct 5, 2026
496decf
style: format retention lock logging
codeforester Oct 5, 2026
0f35aae
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester Oct 5, 2026
6efcb60
test: cover debug logging with consumer handlers
codeforester Oct 5, 2026
436807b
test: cover concurrent and inherited logging state
codeforester Oct 5, 2026
2a916ea
test: verify Windows retention pinning and reparse cleanup
codeforester Oct 5, 2026
3f58457
Merge remote-tracking branch 'origin/ci/393-20261003-ci-ruff-check-an…
codeforester Oct 5, 2026
92cad10
Merge remote-tracking branch 'origin/bug/387-20261003-bug-configure-l…
codeforester Oct 5, 2026
e15763e
Merge remote-tracking branch 'origin/enhancement/381-20261003-perf-li…
codeforester Oct 5, 2026
2f96072
Merge remote-tracking branch 'origin/bug/378-20261003-bug-run-bundle-…
codeforester Oct 5, 2026
b8feb66
Merge main into retention contention performance fix
codeforester Oct 5, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and versions are tracked in the repo-root `VERSION` file.

### Changed

- Skip contended retention passes instead of blocking CLI invocations on housekeeping locks (#386).
- Reuse secure log lock descriptors and cache source paths per invocation; logging I/O failures stay inside logging (#381).
- Align the Typer support floor with the tested matrix and cover representative
minimum/maximum Typer and Click version pairings.
Expand Down
14 changes: 14 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,17 @@ with an advisory lock around each append and a fresh descriptor after fork.
Human formatters cache up to 256 source paths for the current invocation and
project binding. Repeated paths require no filesystem resolution. Sidecar I/O
errors are routed through `logging.Handler.handleError` and do not fail commands.

### Concurrent retention

Retention takes a nonblocking maintenance lock on POSIX and Windows. A busy lock
skips that pass at debug level; the next successful invocation reconciles policy
debt. Startup and teardown both acquire the lock before scanning. Command work
never waits for a stopped lock holder. Deletion still revalidates metadata and
leases under the lock.

`RetentionPolicy.safe_defaults()` includes `max_total_bytes=512 MiB`, so the
default policy uses the byte-policy recursive-walk bounds described above. A
consumer that needs only count/age retention can explicitly omit the byte cap.
The concurrent benchmark in #391 measures twelve processes against one warmed
cache and gates the p95-to-serial ratio.
42 changes: 30 additions & 12 deletions lib/python/base_cli/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ def prune_run_bundles(
now=clock,
size_scan_cursor=size_scan_cursor,
)
except BlockingIOError:
Comment thread
codeforester marked this conversation as resolved.
log.debug("Skipping run bundle retention: another invocation holds the maintenance lock.")
except (OSError, RuntimeError) as exc:
# Retention is maintenance. An unavailable lock or a transient
# filesystem failure must not turn an otherwise valid invocation into
Expand All @@ -459,23 +461,31 @@ def refresh_run_bundle_index(
current_run_root: Path | None = None,
logger: logging.Logger | None = None,
) -> None:
"""Refresh the diagnostic bundle index after a run becomes terminal."""
"""Refresh the index after a run becomes terminal.

A concurrent maintenance pass may win the nonblocking lock. In that case
the index is eventually consistent and the next foreground pass retries it.
"""

log = logger or logging.getLogger(__name__)
runs_root = Path(runs_root)
if not runs_root.exists() or runs_root.is_symlink():
return
try:
bundles, _size_scan_cursor = _discover_run_bundles(
runs_root,
protected=set(),
max_age_seconds=None,
now=time.time(),
measure_sizes=False,
size_budget=0,
)
with _retention_lock(runs_root):
Comment thread
codeforester marked this conversation as resolved.
bundles, _size_scan_cursor = _discover_run_bundles(
runs_root,
protected=set(),
max_age_seconds=None,
now=time.time(),
measure_sizes=False,
size_budget=0,
)
_write_run_index(runs_root, bundles, log, current_run_root=current_run_root)
except BlockingIOError:
log.debug(
"Skipping run bundle index refresh under '%s': another invocation holds the maintenance lock.", runs_root
)
except (OSError, RuntimeError) as exc:
log.debug("Could not refresh run bundle index under '%s': %s", runs_root, exc)

Expand Down Expand Up @@ -922,23 +932,31 @@ def _retention_lock(runs_root: Path) -> Iterator[None]:
pass
restrict_file(lock_path)
stream = lock_path.open("a+b")
locked = False
try:
_lock_retention_stream(stream)
locked = True
yield
finally:
try:
_unlock_retention_stream(stream)
if locked:
_unlock_retention_stream(stream)
finally:
stream.close()


def _lock_retention_stream(stream: object) -> None:
fd = stream.fileno() # type: ignore[attr-defined]
if _fcntl is not None:
_fcntl.flock(fd, _fcntl.LOCK_EX)
_fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
elif _msvcrt is not None: # pragma: no cover - Windows
stream.seek(0)
_msvcrt.locking(fd, _msvcrt.LK_LOCK, 1)
try:
_msvcrt.locking(fd, _msvcrt.LK_NBLCK, 1)
except OSError as exc:
if exc.errno in {11, 13, 36}:
raise BlockingIOError("retention lock is busy") from exc
raise


def _unlock_retention_stream(stream: object) -> None:
Expand Down
93 changes: 93 additions & 0 deletions tests/test_retention_contention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import annotations

import logging
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch

from base_cli import _runtime as runtime


def test_contended_retention_returns_without_waiting(tmp_path: Path) -> None:
# A separate process faithfully models advisory lock contention on both OSes.
code = """
import sys
from pathlib import Path
from base_cli._runtime import _retention_lock
with _retention_lock(Path(sys.argv[1])):
print('locked', flush=True)
sys.stdin.readline()
"""
env = {**os.environ, "PYTHONPATH": str(Path(runtime.__file__).resolve().parents[1])}
child = subprocess.Popen(
[sys.executable, "-c", code, str(tmp_path)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
try:
assert child.stdout.readline().strip() == "locked"
probe = subprocess.run(
[
sys.executable,
"-c",
"""
import sys
from pathlib import Path
from base_cli._runtime import prune_run_bundles, refresh_run_bundle_index
root = Path(sys.argv[1])
prune_run_bundles(root, max_bundles=1)
refresh_run_bundle_index(root)
""",
str(tmp_path),
],
env=env,
capture_output=True,
text=True,
timeout=5,
)
assert probe.returncode == 0, probe.stderr
finally:
child.communicate("done\n", timeout=5)


def test_contended_index_refresh_is_eventually_consistent(tmp_path: Path) -> None:
logger = logging.getLogger("retention-index-refresh")
with patch.object(runtime, "_retention_lock", side_effect=BlockingIOError("busy")):
runtime.refresh_run_bundle_index(tmp_path, logger=logger)


def test_concurrent_passes_converge_after_a_serial_pass(tmp_path: Path) -> None:
for index in range(8):
bundle = tmp_path / f"run-{index:02d}"
bundle.mkdir()
(bundle / "run.json").write_text(
f'{{"run_id": "run-{index:02d}", "status": "ok", "started_at": "2020-01-01T00:00:00Z"}}',
encoding="utf-8",
)
code = """
import sys
from pathlib import Path
from base_cli._runtime import prune_run_bundles
prune_run_bundles(Path(sys.argv[1]), max_bundles=3)
"""
env = {**os.environ, "PYTHONPATH": str(Path(runtime.__file__).resolve().parents[1])}
children = [
subprocess.Popen(
[sys.executable, "-c", code, str(tmp_path)],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
for _ in range(8)
]
for child in children:
_stdout, stderr = child.communicate(timeout=10)
assert child.returncode == 0, stderr.decode()

runtime.prune_run_bundles(tmp_path, max_bundles=3)
assert len([path for path in tmp_path.iterdir() if path.is_dir() and path.name.startswith("run-")]) <= 3
Loading