[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) |
@@ -3,7 +3,7 @@
**Component**: shared_fdr_client (cross-cutting concern owned by E-CC-FDR-CLIENT / AZ-247)
**Producer task**: AZ-272 — `_docs/02_tasks/todo/AZ-272_fdr_record_schema.md`
**Consumer tasks**: every onboard component that emits FDR records (C1C13), the C13 writer (AZ-248 / E-C13), post-flight tooling (E-C12 operator side), the FdrClient ring buffer (AZ-XX / E-CC-FDR-CLIENT next task), and `FakeFdrSink` (AZ-XX / E-CC-FDR-CLIENT fourth task)
**Version**: 1.2.0
**Version**: 1.3.0
**Status**: draft
**Last Updated**: 2026-05-12
@@ -57,6 +57,7 @@ class FdrRecord:
| `c6.write_failed` | C6 (`PostgresFilesystemStore`) | `{tile_id, source, reason, error_class, message}` | v1.1.0 (AZ-305). Emitted on every failed `write_tile` path. `reason``{content_hash_mismatch, freshness_reject, metadata_error, fs_error}`; `error_class` is the exception class name; `message` is the rewrapped exception's `str` (truncated to 512 chars to keep the record inline). Envelope `producer_id="c6_tile_cache.store"`. |
| `c6.freshness.rejected` | C6 (`FreshnessGate`) | `{tile_id, age_seconds, classification, rule_action, rule_max_age_seconds}` | v1.2.0 (AZ-307). Emitted on every active-conflict-stale reject. `tile_id` is the canonical UUIDv5; `age_seconds` is the integer-rounded `(now - capture_timestamp).total_seconds()` at decision time; `classification` is the `SectorClassification` enum value (always `"active_conflict"` for this kind in practice); `rule_action` is always `"reject"`; `rule_max_age_seconds` is the rule's threshold (e.g. `15552000` for the 6-month default). Envelope `producer_id="c6_tile_cache.freshness"`. |
| `c6.freshness.downgraded` | C6 (`FreshnessGate`) | `{tile_id, age_seconds, classification, rule_action, rule_max_age_seconds}` | v1.2.0 (AZ-307). Emitted on every stable-rear-stale downgrade (including the implicit-default path for tiles outside every loaded sector). Same payload shape as `c6.freshness.rejected` so reject/downgrade FDR traces are line-for-line comparable; `rule_action` is always `"downgrade"` and `classification` is always `"stable_rear"` for this kind. Envelope `producer_id="c6_tile_cache.freshness"`. |
| `c6.eviction_batch` | C6 (`CacheBudgetEnforcer`) | `{trigger_tile_id, freed_bytes, evicted_count, evicted_tile_ids}` | v1.3.0 (AZ-308). Emitted once per `reserve_headroom` call that actually evicted at least one tile (RESTRICT-SAT-2 enforcement). `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 of evictions in the batch regardless of payload caps; `evicted_tile_ids` is bounded to the first **5** evicted ids (the full list lives in the per-tile `c6.evicted` INFO logs). Envelope `producer_id="c6_tile_cache.budget"`. |
### Wire bytes
@@ -111,3 +112,4 @@ class FdrRecord:
| 1.0.0 | 2026-05-10 | Initial contract derived from E-CC-FDR-CLIENT epic (AZ-247) | autodev decompose Step 2 |
| 1.1.0 | 2026-05-12 | Add `c6.write` and `c6.write_failed` kinds emitted by C6 `PostgresFilesystemStore` (AZ-305). Non-breaking; v1.0 parsers see the records as unknown kinds and route them through the forward-compat opaque path. | AZ-305 implement |
| 1.2.0 | 2026-05-12 | Add `c6.freshness.rejected` and `c6.freshness.downgraded` kinds emitted by the C6 `FreshnessGate` (AZ-307). Non-breaking; v1.1 parsers see the records as unknown kinds and route them through the forward-compat opaque path. | AZ-307 implement |
| 1.3.0 | 2026-05-12 | Add `c6.eviction_batch` kind emitted by the C6 `CacheBudgetEnforcer` (AZ-308). Non-breaking; v1.2 parsers see the record as an unknown kind and route it through the forward-compat opaque path. | AZ-308 implement |