fix(cleanup): invalidate sandbox_dirs_cache after purge (#7527)

Implemented cache invalidation for CleanupService to fix stale sandbox paths
being reported after purge() completes. The _sandbox_dirs_cache is now
invalidated after _purge_sandboxes() so subsequent scan() calls re-read the
filesystem instead of returning already-deleted paths.

Changes:
- Added self._sandbox_dirs_cache = None at end of _purge_sandboxes()
- Updated docstring to document cache invalidation behavior
- Created comprehensive BDD test coverage with 5 scenarios
- Updated CHANGELOG.md with bug fix entry
- Updated CONTRIBUTORS.md with PR #8257

ISSUES CLOSED: #7527
This commit is contained in:
HAL 9000
2026-05-07 16:34:32 +00:00
parent 94dd77fbcd
commit a758bf75be
5 changed files with 224 additions and 1 deletions
+6
View File
@@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- **CleanupService sandbox cache stale after purge** (#7527): `_purge_sandboxes()`
now invalidates `_sandbox_dirs_cache` after deleting directories so that a
subsequent `scan()` call on the same instance re-reads the filesystem instead
of returning already-deleted paths as stale items. Added BDD regression tests
(`features/cleanup_service_cache_invalidation.feature`) to verify correct cache
lifecycle across scan→purge→scan workflows.
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
+2
View File
@@ -8,6 +8,8 @@
* Luis Mendes <luis.p.mendes@gmail.com>
* Rui Hu <rui.hu@cleverthis.com>
* HAL 9000 has contributed automated bug fixes, cache invalidation improvements, and BDD test coverage for the CleanupService sandbox lifecycle (PR #8257, fixes #7527).
# Details
Below are some of the specific details of various contributions.
@@ -0,0 +1,31 @@
@unit @regression
Feature: CleanupService sandbox cache invalidation after purge (#7527)
As a platform operator running CleanupService in a daemon context
I want the sandbox directory cache to be invalidated after purge()
So that a subsequent scan() reflects the actual filesystem state
and does not report already-deleted paths as stale items
Scenario: cache is None after _purge_sandboxes completes
Given cache invalidation has a CleanupService with a pre-populated sandbox cache
When cache invalidation calls _purge_sandboxes
Then cache invalidation sandbox dirs cache should be None
Scenario: scan after purge does not return the previously created sandbox directories
Given cache invalidation has a CleanupService with stale sandbox directories on disk
When cache invalidation calls scan then purge then scan again
Then cache invalidation second scan should not contain the previously created sandbox directories
Scenario: scan after purge does not return previously cached paths
Given cache invalidation has a CleanupService with a pre-populated sandbox cache
When cache invalidation calls purge then scan
Then cache invalidation scan result should not contain the pre-cached paths
Scenario: cache is repopulated on next _get_sandbox_dirs call after purge
Given cache invalidation has a CleanupService with a pre-populated sandbox cache
When cache invalidation calls _purge_sandboxes then _get_sandbox_dirs
Then cache invalidation cache should be repopulated from filesystem
Scenario: purge with no stale sandboxes still invalidates cache
Given cache invalidation has a CleanupService with a fresh non-stale sandbox cache
When cache invalidation calls _purge_sandboxes
Then cache invalidation sandbox dirs cache should be None
@@ -0,0 +1,174 @@
"""Step definitions for CleanupService sandbox cache invalidation tests (#7527).
Verifies that ``_purge_sandboxes()`` invalidates ``_sandbox_dirs_cache``
so that a subsequent ``scan()`` call re-reads the filesystem instead of
returning already-deleted paths.
All tests use real filesystem operations — no mocks, no patches.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.cleanup_service import (
CleanupReport,
CleanupService,
)
from cleveragents.config.settings import Settings
def _make_settings(**overrides: object) -> Settings:
s = Settings()
for key, value in overrides.items():
object.__setattr__(s, key, value)
return s
_STALE_MAX_AGE_HOURS = 1
_STALE_MTIME_OFFSET = 999_999
def _unique_sandbox_name(prefix: str = "ca-sandbox-test") -> str:
return f"{prefix}-{uuid.uuid4().hex[:12]}"
def _make_real_stale_sandbox(name: str) -> Path:
tmp = Path(tempfile.gettempdir())
sandbox = tmp / name
sandbox.mkdir(exist_ok=True)
old_time = time.time() - _STALE_MTIME_OFFSET
os.utime(str(sandbox), (old_time, old_time))
return sandbox
def _make_real_fresh_sandbox(name: str) -> Path:
tmp = Path(tempfile.gettempdir())
sandbox = tmp / name
sandbox.mkdir(exist_ok=True)
return sandbox
def _register_cleanup(context: Context, path: Path) -> None:
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
def _remove() -> None:
if path.exists():
shutil.rmtree(str(path), ignore_errors=True)
context._cleanup_handlers.append(_remove)
@given("cache invalidation has a CleanupService with a pre-populated sandbox cache")
def step_cache_inv_prepopulated(context: Context) -> None:
context.cache_inv_settings = _make_settings(cleanup_sandbox_max_age_hours=_STALE_MAX_AGE_HOURS)
context.cache_inv_service = CleanupService(context.cache_inv_settings)
na = _unique_sandbox_name("ca-sandbox-plan-aaa")
nb = _unique_sandbox_name("ca-sandbox-plan-bbb")
da = _make_real_stale_sandbox(na)
db = _make_real_stale_sandbox(nb)
_register_cleanup(context, da)
_register_cleanup(context, db)
context.cache_inv_service._sandbox_dirs_cache = [da, db]
context.cache_inv_pre_cached_paths = [da, db]
@given("cache invalidation has a CleanupService with stale sandbox directories on disk")
def step_cache_inv_real_stale(context: Context) -> None:
na = _unique_sandbox_name("ca-sandbox-planA")
nb = _unique_sandbox_name("ca-sandbox-planB")
da = _make_real_stale_sandbox(na)
db = _make_real_stale_sandbox(nb)
_register_cleanup(context, da)
_register_cleanup(context, db)
context.cache_inv_settings = _make_settings(cleanup_sandbox_max_age_hours=_STALE_MAX_AGE_HOURS)
context.cache_inv_service = CleanupService(context.cache_inv_settings)
context.cache_inv_real_dirs = [da, db]
@given("cache invalidation has a CleanupService with a fresh non-stale sandbox cache")
def step_cache_inv_fresh(context: Context) -> None:
context.cache_inv_settings = _make_settings()
context.cache_inv_service = CleanupService(context.cache_inv_settings)
nm = _unique_sandbox_name("ca-sandbox-plan-fresh")
fd = _make_real_fresh_sandbox(nm)
_register_cleanup(context, fd)
context.cache_inv_service._sandbox_dirs_cache = [fd]
context.cache_inv_fresh_dir = fd
@when("cache invalidation calls _purge_sandboxes")
def step_cache_inv_purge(context: Context) -> None:
report = CleanupReport(dry_run=False)
context.cache_inv_service._purge_sandboxes(report)
context.cache_inv_report = report
@when("cache invalidation calls scan then purge then scan again")
def step_cache_inv_scan_purge_scan(context: Context) -> None:
svc = context.cache_inv_service
svc._sandbox_dirs_cache = list(context.cache_inv_real_dirs)
context.cache_inv_first_scan = svc.scan()
context.cache_inv_purge_report = svc.purge()
context.cache_inv_second_scan = svc.scan()
@when("cache invalidation calls purge then scan")
def step_cache_inv_purge_scan(context: Context) -> None:
svc = context.cache_inv_service
context.cache_inv_purge_report = svc.purge()
context.cache_inv_scan_after_purge = svc.scan()
@when("cache invalidation calls _purge_sandboxes then _get_sandbox_dirs")
def step_cache_inv_purge_get_dirs(context: Context) -> None:
svc = context.cache_inv_service
report = CleanupReport(dry_run=False)
svc._purge_sandboxes(report)
nm = _unique_sandbox_name("ca-sandbox-plan-new")
nd = _make_real_stale_sandbox(nm)
_register_cleanup(context, nd)
context.cache_inv_new_dir = nd
context.cache_inv_repopulated = svc._get_sandbox_dirs()
@then("cache invalidation sandbox dirs cache should be None")
def step_cache_inv_none(context: Context) -> None:
assert context.cache_inv_service._sandbox_dirs_cache is None, (
f"Expected _sandbox_dirs_cache to be None after purge, got {context.cache_inv_service._sandbox_dirs_cache!r}"
)
@then("cache invalidation second scan should not contain the previously created sandbox directories")
def step_cache_inv_second_scan(context: Context) -> None:
ss = context.cache_inv_second_scan
cp = {str(d) for d in context.cache_inv_real_dirs}
sp = {i.path for i in ss.stale_items if i.resource_type == "sandbox"}
overlap = cp & sp
assert not overlap, f"Second scan still contains previously created sandbox dirs: {overlap}"
@then("cache invalidation scan result should not contain the pre-cached paths")
def step_cache_inv_no_precached(context: Context) -> None:
sr = context.cache_inv_scan_after_purge
cp = {str(p) for p in context.cache_inv_pre_cached_paths}
sp = {i.path for i in sr.stale_items}
overlap = cp & sp
assert not overlap, f"Scan after purge still contains pre-cached paths: {overlap}"
@then("cache invalidation cache should be repopulated from filesystem")
def step_cache_inv_repopulated(context: Context) -> None:
svc = context.cache_inv_service
nd = context.cache_inv_new_dir
rep = context.cache_inv_repopulated
assert nd in rep, f"Expected repopulated to contain {nd}, got {rep!r}"
assert svc._sandbox_dirs_cache is not None, "Expected cache to be set after _get_sandbox_dirs"
assert nd in svc._sandbox_dirs_cache, f"Expected cache to contain {nd}, got {svc._sandbox_dirs_cache!r}"
@@ -174,7 +174,13 @@ class CleanupService:
)
def _purge_sandboxes(self, report: CleanupReport) -> None:
"""Remove stale sandbox directories."""
"""Remove stale sandbox directories.
Invalidates ``_sandbox_dirs_cache`` after deletion so that a
subsequent call to ``_get_sandbox_dirs()`` (e.g. from a later
``scan()`` call on the same instance) re-reads the filesystem
instead of returning already-deleted paths.
"""
dirs = self._get_sandbox_dirs()
for d in dirs:
report.sandboxes.scanned += 1
@@ -191,6 +197,10 @@ class CleanupService:
report.sandboxes.removed += 1
except OSError:
report.sandboxes.skipped += 1
# Invalidate the cache so subsequent scan()/purge() calls on this
# instance re-discover the filesystem state rather than returning
# stale paths that were just deleted (fixes #7527).
self._sandbox_dirs_cache = None
# ── Checkpoint cleanup ────────────────────────────────────────