[AZ-308] c6 CacheBudgetEnforcer: 10 GB hard cap + LRU sweep

CacheBudgetEnforcer.reserve_headroom(needed_bytes) returns immediately
when total_disk_bytes() + needed_bytes <= budget, otherwise iterates
lru_candidates in eviction_batch_size batches, deletes via delete_tile,
emits one INFO log per evicted tile (c6.evicted) and one FDR record per
eviction batch (c6.eviction_batch, evicted_tile_ids capped to 5).
Raises CacheBudgetExhaustedError AFTER a full sweep if the budget
cannot be met. BudgetEnforcedTileStore decorates a TileStore so the
policy stays separable from PostgresFilesystemStore. Composition root
in storage_factory.build_tile_store wires the wrapper unconditionally.

PostgresFilesystemStore now accepts lru_clock: Clock | None = None;
when set, read_tile_pixels calls record_lru_access(tile_id, now) so
eviction picks the right LRU candidates. Production wiring injects
WallClock(); AZ-305 unit tests still construct without the clock and
keep their pass-through semantics. Contract tile_store.md bumped to
v1.1.0 to add CacheBudgetExhaustedError to the TileCacheError family;
shared FDR schema bumped to v1.3.0 for the new c6.eviction_batch kind.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 20:37:41 +03:00
parent 39ff47087f
commit d571ca25f9
13 changed files with 1588 additions and 29 deletions
@@ -56,13 +56,9 @@ from gps_denied_onboard.runtime_root.storage_factory import (
build_tile_store,
)
_CONTRACT_DIR = Path(__file__).resolve().parents[3] / (
"_docs/02_document/contracts/c6_tile_cache"
)
_CONTRACT_DIR = Path(__file__).resolve().parents[3] / ("_docs/02_document/contracts/c6_tile_cache")
_FAKE_IMPL_MODULE = "gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index"
_FAKE_STORE_MODULE = (
"gps_denied_onboard.components.c6_tile_cache.postgres_filesystem_store"
)
_FAKE_STORE_MODULE = "gps_denied_onboard.components.c6_tile_cache.postgres_filesystem_store"
def _valid_tile_id(zoom: int = 18, lat: float = 49.94, lon: float = 36.31) -> TileId:
@@ -320,6 +316,15 @@ def _install_fake_postgres_store_module() -> type:
# preserves the single-config-arg shape via this classmethod.
return cls(config)
# AZ-308: ``build_tile_store`` now wraps the store in a
# ``BudgetEnforcedTileStore`` whose constructor reads
# ``total_disk_bytes`` for the AC-12 startup log. Override the
# ``_FullTileMetadataStore`` NotImplementedError stub with a
# working zero-byte response so the factory can construct the
# wrapper without touching a real DB.
def total_disk_bytes(self) -> int:
return 0
fake_module = types.ModuleType(_FAKE_STORE_MODULE)
fake_module.PostgresFilesystemStore = _FakePostgresFilesystemStore # type: ignore[attr-defined]
sys.modules[_FAKE_STORE_MODULE] = fake_module
@@ -349,11 +354,21 @@ def test_ac5_build_descriptor_index_flag_off_raises_no_import(
def test_ac4_build_tile_store_returns_protocol_impl(store_module_cleanup) -> None:
# AZ-308: ``build_tile_store`` now returns a ``BudgetEnforcedTileStore``
# decorator wrapping the inner :class:`TileStore` impl. The decorator
# implements the Protocol surface; the wrapped instance is reachable
# via the private ``_wrapped`` attribute for tests that need to
# introspect the inner store.
from gps_denied_onboard.components.c6_tile_cache.cache_budget_enforcer import (
BudgetEnforcedTileStore,
)
fake_cls = _install_fake_postgres_store_module()
config = _config_with_c6()
store = build_tile_store(config)
assert isinstance(store, fake_cls)
assert isinstance(store, BudgetEnforcedTileStore)
assert isinstance(store, TileStore)
assert isinstance(store._wrapped, fake_cls) # type: ignore[attr-defined]
def test_ac4_build_tile_metadata_store_returns_protocol_impl(
@@ -366,9 +381,7 @@ def test_ac4_build_tile_metadata_store_returns_protocol_impl(
assert isinstance(md, TileMetadataStore)
def test_ac5_tile_store_runtime_module_missing_raises(
store_module_cleanup, monkeypatch
) -> None:
def test_ac5_tile_store_runtime_module_missing_raises(store_module_cleanup, monkeypatch) -> None:
"""AC-5 historical name; after AZ-305 the impl module always exists, so
"missing" is exercised by deleting it from ``sys.modules`` AND making
``importlib`` refuse the import. We patch the module-level lazy import
@@ -378,14 +391,18 @@ def test_ac5_tile_store_runtime_module_missing_raises(
config = _config_with_c6()
import gps_denied_onboard.runtime_root.storage_factory as factory_mod
real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
real_import = (
__builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
)
def _block_postgres_import(name, *args, **kwargs):
if name.endswith("postgres_filesystem_store"):
raise ModuleNotFoundError(name)
return real_import(name, *args, **kwargs)
monkeypatch.setattr(factory_mod, "__builtins__", {"__import__": _block_postgres_import}, raising=False)
monkeypatch.setattr(
factory_mod, "__builtins__", {"__import__": _block_postgres_import}, raising=False
)
monkeypatch.setitem(sys.modules, _FAKE_STORE_MODULE, None) # type: ignore[arg-type]
with pytest.raises(RuntimeNotAvailableError) as exc_info:
build_tile_store(config)
@@ -428,9 +445,7 @@ def test_ac6_unknown_metadata_runtime_rejected() -> None:
({"zoom_level": 18, "lat": 0.0, "lon": -200.0}, "lon"),
],
)
def test_ac7_tile_id_rejects_bad_input(
kwargs: dict[str, float], offending_field: str
) -> None:
def test_ac7_tile_id_rejects_bad_input(kwargs: dict[str, float], offending_field: str) -> None:
with pytest.raises(ValueError) as exc_info:
TileId(**kwargs) # type: ignore[arg-type]
assert offending_field in str(exc_info.value)
@@ -504,9 +519,7 @@ def _methods_from_contract(contract_file: Path) -> set[str]:
def _protocol_methods(proto: type) -> set[str]:
"""Reflect over a Protocol's method names."""
return {
name
for name in dir(proto)
if not name.startswith("_") and callable(getattr(proto, name))
name for name in dir(proto) if not name.startswith("_") and callable(getattr(proto, name))
}
@@ -518,9 +531,7 @@ def _protocol_methods(proto: type) -> set[str]:
("descriptor_index.md", DescriptorIndex),
],
)
def test_ac9_contract_methods_match_protocol(
contract_filename: str, proto: type
) -> None:
def test_ac9_contract_methods_match_protocol(contract_filename: str, proto: type) -> None:
contract_path = _CONTRACT_DIR / contract_filename
contract_methods = _methods_from_contract(contract_path)
protocol_methods = _protocol_methods(proto)