[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
@@ -145,6 +145,19 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
"c6.freshness.downgraded": frozenset(
{"tile_id", "age_seconds", "classification", "rule_action", "rule_max_age_seconds"}
),
# AZ-308 / E-C6: emitted by the CacheBudgetEnforcer at the end of every
# LRU sweep that evicted at least one tile (RESTRICT-SAT-2). One record
# per ``reserve_headroom`` call that hit the eviction path, never per
# evicted tile (per-tile detail goes to logs). ``trigger_tile_id`` is
# the canonical UUIDv5 of the tile whose write triggered the sweep;
# ``freed_bytes`` is the integer total reclaimed; ``evicted_count`` is
# the full count regardless of how many ids fit in
# ``evicted_tile_ids`` (capped to 5 to keep the record bounded —
# AC-11). The full eviction list is replayable from the per-tile
# ``c6.evicted`` INFO log records.
"c6.eviction_batch": frozenset(
{"trigger_tile_id", "freed_bytes", "evicted_count", "evicted_tile_ids"}
),
}
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())