Skip to content

fix: rotate bounded retention size scans - #367

Open
codeforester wants to merge 2 commits into
bug/354-20260918-protect-terminal-leasesfrom
bug/355-20260918-fair-retention-scans
Open

codeforester wants to merge 2 commits into
bug/354-20260918-protect-terminal-leasesfrom
bug/355-20260918-fair-retention-scans

Conversation

@codeforester

Copy link
Copy Markdown
Contributor

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

# under the lock so another invocation can never turn a live bundle into a
# deletion candidate while discovery is in progress.
bundles = _discover_run_bundles(
size_scan_cursor = _read_size_scan_cursor(runs_root)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrency: the byte-scan cursor is read and the expensive recursive size walk is performed entirely outside _retention_lock, so concurrent prune_run_bundles invocations can all read the same stale cursor and redundantly re-walk the same bundle range; whichever acquires the lock last simply overwrites the other's cursor, which can silently revert forward progress a faster sibling already made under sustained concurrent load.

# The run index's cursor affects only which discovered bundles receive
# an expensive size walk. It never authorizes deletion; every candidate
# is re-read and revalidated before the destructive phase.
scan_order = sorted(bundles, key=lambda bundle: Path(bundle["path"]).name)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency (minor): _discover_run_bundles sorts bundles twice (once by (started_at, path), then again by name for scan_order) and re-wraps bundle["path"] in Path(...) even though it's already a Path. Capturing the name-sorted order once before the started_at sort would avoid the duplicate O(n log n) pass.

"preserve": False,
},
)
# Retention now fails closed if a bundle has no lease record,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: the only true multi-process concurrency test for retention configures RetentionPolicy(max_bundles=2) with no max_total_bytes, so measure_sizes is always False and the new byte-scan-cursor path is never exercised under real multi-process concurrency — a race in the cursor read/write (see the other comment on _runtime.py:417) wouldn't be caught by the existing suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant