Skip to content

Commit b2401fc

Browse files
authored
fix: rotate bounded retention size scans (#367)
## Summary - Persist an advisory byte-scan cursor in the atomic diagnostic run index. - Rotate bounded size walks across invocations and process restarts, advancing past unreadable entries. - Keep deletion based on live filesystem discovery and metadata/liveness revalidation, never the cursor/index. - Cover 1,025 bundles over three fresh passes with protected early bundles and an oversized tail bundle. Closes #355 ## Dependency This PR is intentionally stacked on #366 (issue #354) so retention lease hardening merges first. ## Validation - `uv run --extra dev --extra typer --extra quality python -m pytest tests/test_run_bundle_retention.py -q` - `uv run --extra dev --extra typer --extra quality python -m mypy --strict lib/python/base_cli/_runtime.py` - Ruff check/format and `git diff --check`
1 parent 4084636 commit b2401fc

7 files changed

Lines changed: 117 additions & 37 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ and versions are tracked in the repo-root `VERSION` file.
2323

2424
### Fixed
2525

26+
- Rotate bounded byte-retention size walks across invocations with a persisted
27+
advisory cursor so bundles beyond the first scan budget are eventually seen.
2628
- Resolve lifecycle values through the active Typer/Click context for attached
2729
commands, including renamed options, defaults, and environment variables.
2830
- Reject non-finite numbers in JSON and NDJSON output so emitted records remain
@@ -47,7 +49,6 @@ and versions are tracked in the repo-root `VERSION` file.
4749
- Detect JSON capture without running Click callbacks, callable defaults, type
4850
converters, or close hooks a second time; respect option-value arity so a
4951
payload equal to `--json` remains human output.
50-
5152
- Preserve explicit application identities losslessly while using
5253
collision-resistant, path-safe runtime namespace components.
5354
- Give `BatteriesIncludedConfigLoader.cli_name` a documented identity role by

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,9 @@ Recovery work is bounded on the foreground command path. Count- and age-only
906906
policies inspect metadata without recursively sizing bundle contents. A byte
907907
policy performs at most 512 recursive size walks and removes at most 256
908908
bundles per pass; any remaining policy debt is retained safely and reported as
909-
a warning for a later invocation. The diagnostic index records at most 512
909+
a warning for a later invocation. An atomic advisory cursor rotates the size
910+
walk across invocations, so repeated passes eventually inspect the full set;
911+
the cursor never authorizes deletion. The diagnostic index records at most 512
910912
entries and sets `complete: false` plus `omitted_bundles` when a cache is
911913
larger, so a stale, corrupt, or missing index is always reconciled from the
912914
filesystem rather than trusted for deletion.

‎docs/performance.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ foreground pass (protected bundles and unreadable entries are retained):
105105
When a bound prevents a complete reconciliation, base-cli leaves the
106106
unprocessed bundles intact, writes a partial index with `complete: false`, and
107107
emits a warning describing the remaining policy debt. A later invocation
108-
continues from the filesystem; the index is an observation aid, never an
109-
authorization to delete a path. The retention regression suite covers count,
108+
continues from the filesystem. An atomic advisory cursor rotates the bounded
109+
byte-size walk across invocations, including after process restart; the index
110+
is an observation aid, never an authorization to delete a path. The retention regression suite covers count,
110111
age, byte limits, deep trees, corrupt metadata/index files, unreadable files,
111112
concurrent invocations, and live-run lease protection.
112113

‎lib/python/base_cli/_runtime.py‎

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -410,20 +410,20 @@ def prune_run_bundles(
410410
protected.add(_safe_resolved_path(current_run_root))
411411
clock = time.time() if now is None else now
412412

413-
# Filesystem discovery and recursive size accounting are deliberately
414-
# outside the lock. The destructive phase revalidates each candidate
415-
# under the lock so another invocation can never turn a live bundle into a
416-
# deletion candidate while discovery is in progress.
417-
bundles = _discover_run_bundles(
418-
runs_root,
419-
protected=protected,
420-
max_age_seconds=effective.max_age_seconds,
421-
now=clock,
422-
measure_sizes=effective.max_total_bytes is not None,
423-
size_budget=_RETENTION_SIZE_MEASUREMENT_BUDGET,
424-
)
425413
try:
426414
with _retention_lock(runs_root):
415+
# Keep cursor read, size walk, and index update in one critical
416+
# section so concurrent pruners cannot overwrite scan progress.
417+
size_scan_cursor = _read_size_scan_cursor(runs_root)
418+
bundles, size_scan_cursor = _discover_run_bundles(
419+
runs_root,
420+
protected=protected,
421+
max_age_seconds=effective.max_age_seconds,
422+
now=clock,
423+
measure_sizes=effective.max_total_bytes is not None,
424+
size_budget=_RETENTION_SIZE_MEASUREMENT_BUDGET,
425+
size_scan_cursor=size_scan_cursor,
426+
)
427427
_apply_bundle_retention(
428428
runs_root,
429429
bundles,
@@ -439,6 +439,7 @@ def prune_run_bundles(
439439
log,
440440
current_run_root=current_run_root,
441441
now=clock,
442+
size_scan_cursor=size_scan_cursor,
442443
)
443444
except (OSError, RuntimeError) as exc:
444445
# Retention is maintenance. An unavailable lock or a transient
@@ -460,7 +461,7 @@ def refresh_run_bundle_index(
460461
if not runs_root.exists() or runs_root.is_symlink():
461462
return
462463
try:
463-
bundles = _discover_run_bundles(
464+
bundles, _size_scan_cursor = _discover_run_bundles(
464465
runs_root,
465466
protected=set(),
466467
max_age_seconds=None,
@@ -482,13 +483,14 @@ def _discover_run_bundles(
482483
now: float,
483484
measure_sizes: bool,
484485
size_budget: int,
485-
) -> list[dict[str, Any]]:
486+
size_scan_cursor: str | None = None,
487+
) -> tuple[list[dict[str, Any]], str | None]:
486488
bundles: list[dict[str, Any]] = []
487-
measured_sizes = 0
489+
scan_order: list[dict[str, Any]] = []
488490
try:
489491
children = sorted(runs_root.iterdir(), key=lambda path: path.name)
490492
except OSError:
491-
return bundles
493+
return bundles, size_scan_cursor
492494
for child in children:
493495
if child.name.startswith(".") or child.is_symlink() or not child.is_dir():
494496
continue
@@ -524,18 +526,6 @@ def _discover_run_bundles(
524526
if status not in {"running", "ok", "aborted", "error"}:
525527
continue
526528
resolved = _safe_resolved_path(child)
527-
size = 0
528-
size_known = False
529-
if measure_sizes and measured_sizes < size_budget:
530-
try:
531-
size = _bundle_size(child)
532-
size_known = True
533-
measured_sizes += 1
534-
except OSError:
535-
# A file that disappears or becomes unreadable remains a
536-
# retention candidate for count/age policy, but its byte
537-
# contribution is unknown and must be reported below.
538-
pass
539529
retention_metadata = metadata.get("retention")
540530
preserve = bool(metadata.get("preserve")) or (
541531
isinstance(retention_metadata, dict) and retention_metadata.get("preserve") is True
@@ -548,14 +538,60 @@ def _discover_run_bundles(
548538
"status": status,
549539
"started_at": started_at,
550540
"age": age,
551-
"size": size,
552-
"size_known": size_known,
541+
"size": 0,
542+
"size_known": False,
553543
"preserve": preserve,
554544
"protected": resolved in protected,
555545
}
556546
)
547+
if measure_sizes:
548+
scan_order.append(bundles[-1])
557549
bundles.sort(key=lambda bundle: (float(bundle["started_at"]), str(bundle["path"])))
558-
return bundles
550+
if measure_sizes and bundles and size_budget > 0:
551+
# The run index's cursor affects only which discovered bundles receive
552+
# an expensive size walk. It never authorizes deletion; every candidate
553+
# is re-read and revalidated before the destructive phase.
554+
if size_scan_cursor is not None:
555+
start_index = next(
556+
(index for index, bundle in enumerate(scan_order) if bundle["path"].name > size_scan_cursor),
557+
0,
558+
)
559+
scan_order = scan_order[start_index:] + scan_order[:start_index]
560+
attempted = 0
561+
for bundle in scan_order:
562+
if attempted >= size_budget:
563+
break
564+
attempted += 1
565+
path = bundle["path"]
566+
size_scan_cursor = path.name
567+
try:
568+
bundle["size"] = _bundle_size(path)
569+
bundle["size_known"] = True
570+
except OSError:
571+
# A file that disappears or becomes unreadable remains a
572+
# retention candidate for count/age policy, but its byte
573+
# contribution is unknown and reported below. Advancing the
574+
# cursor prevents one unreadable entry from starving others.
575+
pass
576+
return bundles, size_scan_cursor
577+
578+
579+
def _read_size_scan_cursor(runs_root: Path) -> str | None:
580+
"""Read the advisory byte-scan cursor; never use it to select deletions."""
581+
582+
index_path = runs_root / _RUN_INDEX_NAME
583+
try:
584+
if index_path.is_symlink() or not index_path.is_file() or index_path.stat().st_size > 1_048_576:
585+
return None
586+
payload = json.loads(index_path.read_text(encoding="utf-8"))
587+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
588+
return None
589+
if not isinstance(payload, dict):
590+
return None
591+
cursor = payload.get("byte_scan_cursor")
592+
if not isinstance(cursor, str) or not cursor or len(cursor) > 1024 or "/" in cursor or "\\" in cursor:
593+
return None
594+
return cursor
559595

560596

561597
def _apply_bundle_retention(
@@ -705,6 +741,7 @@ def _write_run_index(
705741
*,
706742
current_run_root: Path | None = None,
707743
now: float | None = None,
744+
size_scan_cursor: str | None = None,
708745
) -> None:
709746
indexed = list(bundles)
710747
if current_run_root is not None and current_run_root.exists():
@@ -734,6 +771,7 @@ def _write_run_index(
734771
"version": 1,
735772
"complete": omitted_bundles == 0,
736773
"omitted_bundles": omitted_bundles,
774+
"byte_scan_cursor": size_scan_cursor if size_scan_cursor is not None else _read_size_scan_cursor(runs_root),
737775
"bundles": [
738776
{
739777
"path": str(bundle["path"]),

‎tests/test_adversarial_regressions.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def _write_log_worker(path_text: str, seed: int, count: int) -> None:
8787
def _prune_worker(runs_root_text: str) -> None:
8888
prune_run_bundles(
8989
Path(runs_root_text),
90-
policy=base_cli.RetentionPolicy(max_bundles=2),
90+
policy=base_cli.RetentionPolicy(max_bundles=2, max_total_bytes=2),
9191
)
9292

9393

@@ -250,6 +250,9 @@ def test_run_bundle_retention_remains_bounded_across_processes(self) -> None:
250250
"preserve": False,
251251
},
252252
)
253+
# Retention now fails closed if a bundle has no lease record,
254+
# because missing liveness cannot prove that it is inactive.
255+
(bundle / ".base-cli-run-lease").write_bytes(b"0")
253256

254257
_run_processes(_prune_worker, [(str(runs_root),) for _seed in SEEDS])
255258

‎tests/test_platform_edge_paths.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,14 @@ def test_retention_scan_and_apply_are_portable_without_recursive_sizes(self) ->
150150
for index in range(3):
151151
bundle = root / f"run-{index}"
152152
bundle.mkdir()
153+
(bundle / ".base-cli-run-lease").write_bytes(b"0")
153154
(bundle / "run.json").write_text(
154155
f'{{"run_id": "run-{index}", "status": "ok", '
155156
'"started_at": "2020-01-01T00:00:00Z", "preserve": false}',
156157
encoding="utf-8",
157158
)
158159
with mock.patch.object(runtime, "_bundle_size", side_effect=AssertionError("unexpected size walk")):
159-
bundles = runtime._discover_run_bundles( # pylint: disable=protected-access
160+
bundles, _size_scan_cursor = runtime._discover_run_bundles( # pylint: disable=protected-access
160161
root,
161162
protected=set(),
162163
max_age_seconds=None,

‎tests/test_run_bundle_retention.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,40 @@ def test_byte_retention_bounds_recursive_size_work(self) -> None:
9494
self.assertLessEqual(bundle_size.call_count, 512)
9595
self.assertTrue(any("size walk(s)" in str(call) for call in logger.warning.call_args_list))
9696

97+
def test_byte_retention_cursor_advances_across_restarted_bounded_passes(self) -> None:
98+
with tempfile.TemporaryDirectory() as tmpdir:
99+
root = Path(tmpdir) / "runs"
100+
root.mkdir()
101+
expected_names = {f"run-{index:05d}" for index in range(1_025)}
102+
for index in range(1_025):
103+
_bundle(
104+
root,
105+
f"run-{index:05d}",
106+
preserve=index < 1_024,
107+
size=1_048_576 if index == 1_024 else 1,
108+
)
109+
110+
scanned: set[str] = set()
111+
policy = RetentionPolicy(max_total_bytes=400_000)
112+
for pass_number in range(1, 4):
113+
with mock.patch.object(runtime, "_bundle_size", wraps=runtime._bundle_size) as bundle_size:
114+
prune_run_bundles(
115+
root,
116+
policy=policy,
117+
logger=logging.getLogger(__name__),
118+
now=1_600_000_000,
119+
)
120+
scanned.update(Path(call.args[0]).name for call in bundle_size.call_args_list)
121+
self.assertLessEqual(bundle_size.call_count, 512)
122+
index = json.loads((root / ".base-cli-run-index.json").read_text(encoding="utf-8"))
123+
self.assertIn("byte_scan_cursor", index)
124+
if pass_number < 3:
125+
self.assertTrue((root / "run-01024").exists())
126+
127+
self.assertTrue(expected_names <= scanned)
128+
self.assertFalse((root / "run-01024").exists())
129+
self.assertTrue(all((root / name).exists() for name in expected_names if name != "run-01024"))
130+
97131
def test_corrupt_index_is_reconciled_without_trusting_paths(self) -> None:
98132
with tempfile.TemporaryDirectory() as tmpdir:
99133
root = Path(tmpdir) / "runs"

0 commit comments

Comments
 (0)