[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
@@ -7,7 +7,7 @@
- AZ-TBD-c6-freshness-gate (insert hook collaborator)
- AZ-TBD-c6-cache-budget-eviction (uses `tile_exists` + `delete_tile`)
- TBD at decompose time: E-C2.5 (AZ-256), E-C3 (AZ-257), E-C11 (AZ-251 — both `TileDownloader` and `TileUploader`)
**Version**: 1.0.0
**Version**: 1.1.0
**Status**: draft
**Last Updated**: 2026-05-10
@@ -104,11 +104,12 @@ All under `c6_tile_cache.errors`:
```
TileCacheError (Exception subclass)
├── TileNotFoundError # tile_id not present on disk
├── TileFsError # I/O error on read/write/rename
├── TileMetadataError # row missing despite file present, or vice-versa (consistency violation)
├── ContentHashMismatchError # supplied JPEG bytes don't match declared content_sha256
── FreshnessRejectionError # rejected by the C6 freshness gate (raised on insert in active_conflict)
├── TileNotFoundError # tile_id not present on disk
├── TileFsError # I/O error on read/write/rename
├── TileMetadataError # row missing despite file present, or vice-versa (consistency violation)
├── ContentHashMismatchError # supplied JPEG bytes don't match declared content_sha256
── FreshnessRejectionError # rejected by the C6 freshness gate (raised on insert in active_conflict)
└── CacheBudgetExhaustedError # LRU sweep ran to completion but couldn't free `needed_bytes` (AZ-308)
```
`IndexUnavailableError` lives under the same package but is exclusively raised by `DescriptorIndex` — it is not part of `TileStore`'s envelope.
@@ -164,3 +165,4 @@ JPEG body lands at `<root>/tiles/{zoom_level}/{x}/{y}.jpg` where `(x, y)` is der
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — Protocol + DTOs + 5-error family + filesystem byte-identity invariant. | autodev (decompose Step 2 of AZ-250 / E-C6) |
| 1.1.0 | 2026-05-12 | Additive: `CacheBudgetExhaustedError` joins the `TileCacheError` family for AZ-308 cache-budget enforcement. No existing-shape changes. | autodev (AZ-308) |