-
Notifications
You must be signed in to change notification settings - Fork 1
fix: rotate bounded retention size scans #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: bug/354-20260918-protect-terminal-leases
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -414,13 +414,15 @@ def prune_run_bundles( | |
| # outside the lock. The destructive phase revalidates each candidate | ||
| # 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) | ||
| bundles, size_scan_cursor = _discover_run_bundles( | ||
| runs_root, | ||
| protected=protected, | ||
| max_age_seconds=effective.max_age_seconds, | ||
| now=clock, | ||
| measure_sizes=effective.max_total_bytes is not None, | ||
| size_budget=_RETENTION_SIZE_MEASUREMENT_BUDGET, | ||
| size_scan_cursor=size_scan_cursor, | ||
| ) | ||
| try: | ||
| with _retention_lock(runs_root): | ||
|
|
@@ -439,6 +441,7 @@ def prune_run_bundles( | |
| log, | ||
| current_run_root=current_run_root, | ||
| now=clock, | ||
| size_scan_cursor=size_scan_cursor, | ||
| ) | ||
| except (OSError, RuntimeError) as exc: | ||
| # Retention is maintenance. An unavailable lock or a transient | ||
|
|
@@ -459,7 +462,7 @@ def refresh_run_bundle_index( | |
| if not runs_root.exists() or runs_root.is_symlink(): | ||
| return | ||
| try: | ||
| bundles = _discover_run_bundles( | ||
| bundles, _size_scan_cursor = _discover_run_bundles( | ||
| runs_root, | ||
| protected=set(), | ||
| max_age_seconds=None, | ||
|
|
@@ -481,13 +484,13 @@ def _discover_run_bundles( | |
| now: float, | ||
| measure_sizes: bool, | ||
| size_budget: int, | ||
| ) -> list[dict[str, Any]]: | ||
| size_scan_cursor: str | None = None, | ||
| ) -> tuple[list[dict[str, Any]], str | None]: | ||
| bundles: list[dict[str, Any]] = [] | ||
| measured_sizes = 0 | ||
| try: | ||
| children = sorted(runs_root.iterdir(), key=lambda path: path.name) | ||
| except OSError: | ||
| return bundles | ||
| return bundles, size_scan_cursor | ||
| for child in children: | ||
| if child.name.startswith(".") or child.is_symlink() or not child.is_dir(): | ||
| continue | ||
|
|
@@ -517,18 +520,6 @@ def _discover_run_bundles( | |
| if status not in {"running", "ok", "aborted", "error"}: | ||
| continue | ||
| resolved = _safe_resolved_path(child) | ||
| size = 0 | ||
| size_known = False | ||
| if measure_sizes and measured_sizes < size_budget: | ||
| try: | ||
| size = _bundle_size(child) | ||
| size_known = True | ||
| measured_sizes += 1 | ||
| except OSError: | ||
| # A file that disappears or becomes unreadable remains a | ||
| # retention candidate for count/age policy, but its byte | ||
| # contribution is unknown and must be reported below. | ||
| pass | ||
| retention_metadata = metadata.get("retention") | ||
| preserve = bool(metadata.get("preserve")) or ( | ||
| isinstance(retention_metadata, dict) and retention_metadata.get("preserve") is True | ||
|
|
@@ -541,14 +532,59 @@ def _discover_run_bundles( | |
| "status": status, | ||
| "started_at": started_at, | ||
| "age": age, | ||
| "size": size, | ||
| "size_known": size_known, | ||
| "size": 0, | ||
| "size_known": False, | ||
| "preserve": preserve, | ||
| "protected": resolved in protected, | ||
| } | ||
| ) | ||
| bundles.sort(key=lambda bundle: (float(bundle["started_at"]), str(bundle["path"]))) | ||
| return bundles | ||
| if measure_sizes and bundles and size_budget > 0: | ||
| # 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) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Efficiency (minor): |
||
| if size_scan_cursor is not None: | ||
| start_index = next( | ||
| (index for index, bundle in enumerate(scan_order) if Path(bundle["path"]).name > size_scan_cursor), | ||
| 0, | ||
| ) | ||
| scan_order = scan_order[start_index:] + scan_order[:start_index] | ||
| attempted = 0 | ||
| for bundle in scan_order: | ||
| if attempted >= size_budget: | ||
| break | ||
| attempted += 1 | ||
| path = Path(bundle["path"]) | ||
| size_scan_cursor = path.name | ||
| try: | ||
| bundle["size"] = _bundle_size(path) | ||
| bundle["size_known"] = True | ||
| except OSError: | ||
| # A file that disappears or becomes unreadable remains a | ||
| # retention candidate for count/age policy, but its byte | ||
| # contribution is unknown and reported below. Advancing the | ||
| # cursor prevents one unreadable entry from starving others. | ||
| pass | ||
| return bundles, size_scan_cursor | ||
|
|
||
|
|
||
| def _read_size_scan_cursor(runs_root: Path) -> str | None: | ||
| """Read the advisory byte-scan cursor; never use it to select deletions.""" | ||
|
|
||
| index_path = runs_root / _RUN_INDEX_NAME | ||
| try: | ||
| if index_path.is_symlink() or not index_path.is_file() or index_path.stat().st_size > 1_048_576: | ||
| return None | ||
| payload = json.loads(index_path.read_text(encoding="utf-8")) | ||
| except (OSError, UnicodeDecodeError, json.JSONDecodeError): | ||
| return None | ||
| if not isinstance(payload, dict): | ||
| return None | ||
| cursor = payload.get("byte_scan_cursor") | ||
| if not isinstance(cursor, str) or not cursor or len(cursor) > 1024 or "/" in cursor or "\\" in cursor: | ||
| return None | ||
| return cursor | ||
|
|
||
|
|
||
| def _apply_bundle_retention( | ||
|
|
@@ -683,6 +719,7 @@ def _write_run_index( | |
| *, | ||
| current_run_root: Path | None = None, | ||
| now: float | None = None, | ||
| size_scan_cursor: str | None = None, | ||
| ) -> None: | ||
| indexed = list(bundles) | ||
| if current_run_root is not None and current_run_root.exists(): | ||
|
|
@@ -708,6 +745,7 @@ def _write_run_index( | |
| "version": 1, | ||
| "complete": omitted_bundles == 0, | ||
| "omitted_bundles": omitted_bundles, | ||
| "byte_scan_cursor": size_scan_cursor if size_scan_cursor is not None else _read_size_scan_cursor(runs_root), | ||
| "bundles": [ | ||
| { | ||
| "path": str(bundle["path"]), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -250,6 +250,9 @@ def test_run_bundle_retention_remains_bounded_across_processes(self) -> None: | |
| "preserve": False, | ||
| }, | ||
| ) | ||
| # Retention now fails closed if a bundle has no lease record, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test gap: the only true multi-process concurrency test for retention configures |
||
| # because missing liveness cannot prove that it is inactive. | ||
| (bundle / ".base-cli-run-lease").write_bytes(b"0") | ||
|
|
||
| _run_processes(_prune_worker, [(str(runs_root),) for _seed in SEEDS]) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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 concurrentprune_run_bundlesinvocations 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.