mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:31:13 +00:00
[AZ-316] Implement C11 HttpTileDownloader (batch 40)
Lands the operator-side pre-flight download path: authenticated httpx GETs against satellite-provider, RESTRICT-SAT-4 (>= 0.5 m/px) enforcement at the C11 boundary, c6 writes via consumer-side cuts (_TileWriterLike, _BudgetEnforcerLike), per-(flight_id, request_hash) journal under cache_root/.c11/journal/ for idempotent re-runs (AC-8, AC-12), 429 Retry-After + 5xx exponential backoff handling, fail-fast on TLS / 401 / 403, and a redacted-bearer auth-header policy. Architecture: - AZ-507 cross-component rule held: tile_downloader.py imports zero c6 symbols; the composition-root _C6DownloadAdapter in runtime_root/c11_factory.py absorbs c6's TileMetadata / TileSource / FreshnessLabel / VotingStatus enum assembly. - Sleep-callable injection (not full Clock) per Batch 39 precedent; default routes through WallClock.sleep_until_ns to keep the AZ-398 invariant intact. - No FDR records on the download path; spec mandates structured logs only (8 log kinds wired: session.start/end, resolution_rejected, freshness_rejected_summary, freshness_downgraded, batch.retry, provider.failed, budget.exceeded, idempotent_no_op). Tests: 14 new downloader unit tests covering AC-1..AC-9, AC-11, AC-12 plus throughput NFR + 429 HTTP-date + 429 budget exhaustion; 2 new TileDownloader Protocol conformance tests (AC-10). Full unit suite: 1420 passed, 80 skipped (env-gated), 0 failed. Code review: PASS_WITH_WARNINGS (5 Low findings, all documentation or downstream-blocked). See _docs/03_implementation/reviews/ batch_40_review.md and batch_40_cycle1_report.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""C11 TileManager composition-root factories (AZ-317, AZ-318, AZ-319).
|
||||
"""C11 TileManager composition-root factories (AZ-316, AZ-317, AZ-318, AZ-319).
|
||||
|
||||
Wires the upload-side services that have landed:
|
||||
Wires the operator-side services:
|
||||
|
||||
* :func:`build_flight_state_gate` (AZ-317) — adapts an injected
|
||||
``FlightStateSource`` (typically an E-C8 FC adapter wrapper) into
|
||||
@@ -12,10 +12,16 @@ Wires the upload-side services that have landed:
|
||||
key manager, the c6 storage cuts, an :class:`httpx.Client`, and
|
||||
the :class:`C11Config` block into the production
|
||||
:class:`HttpTileUploader`.
|
||||
* :func:`build_tile_downloader` (AZ-316) — composes the c6 store +
|
||||
metadata-store + budget-enforcer (wrapped in a single
|
||||
composition-root adapter that hides c6's :class:`TileMetadata`
|
||||
assembly), an :class:`httpx.Client`, and the :class:`C11Config`
|
||||
block into the production :class:`HttpTileDownloader`.
|
||||
|
||||
Composition root is the ONLY layer permitted to import from
|
||||
``components.c11_tile_manager`` (per ``module-layout.md`` Rule 9 +
|
||||
the AZ-270 lint).
|
||||
the AZ-270 lint). It is also the only layer permitted to bridge the
|
||||
c6 ↔ c11 boundary (per AZ-507).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,6 +34,7 @@ from gps_denied_onboard.components.c11_tile_manager import (
|
||||
C11Config,
|
||||
FlightStateGate,
|
||||
FlightStateSource,
|
||||
HttpTileDownloader,
|
||||
HttpTileUploader,
|
||||
PerFlightKeyManager,
|
||||
)
|
||||
@@ -42,6 +49,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"build_flight_state_gate",
|
||||
"build_per_flight_key_manager",
|
||||
"build_tile_downloader",
|
||||
"build_tile_uploader",
|
||||
]
|
||||
|
||||
@@ -51,6 +59,7 @@ _C11_SIGNING_LOGGER = "c11_tile_manager.signing_key"
|
||||
_C11_SIGNING_PRODUCER_ID = "c11_tile_manager.signing_key"
|
||||
_C11_UPLOADER_LOGGER = "c11_tile_manager.tile_uploader"
|
||||
_C11_UPLOADER_PRODUCER_ID = "c11_tile_manager.tile_uploader"
|
||||
_C11_DOWNLOADER_LOGGER = "c11_tile_manager.tile_downloader"
|
||||
|
||||
|
||||
def build_flight_state_gate(*, source: FlightStateSource) -> FlightStateGate:
|
||||
@@ -145,3 +154,164 @@ def build_tile_uploader(
|
||||
logger=logger,
|
||||
config=block,
|
||||
)
|
||||
|
||||
|
||||
def build_tile_downloader(
|
||||
config: Config,
|
||||
*,
|
||||
http_client: httpx.Client,
|
||||
tile_store: Any,
|
||||
tile_metadata_store: Any,
|
||||
budget_enforcer: Any,
|
||||
companion_id: str | None = None,
|
||||
) -> HttpTileDownloader:
|
||||
"""Construct a wired :class:`HttpTileDownloader` (AZ-316).
|
||||
|
||||
Wraps c6's ``TileStore`` + ``TileMetadataStore`` +
|
||||
``CacheBudgetEnforcer`` into a single composition-root adapter
|
||||
that absorbs c6's :class:`TileMetadata` / :class:`TileSource` /
|
||||
:class:`FreshnessLabel` / :class:`SectorClassification` enums
|
||||
so the downloader stays free of cross-component imports
|
||||
(AZ-507 / AZ-270). The c6 surfaces are caller-owned; production
|
||||
wiring shares the same singletons used by the uploader and the
|
||||
on-airframe ingest path.
|
||||
"""
|
||||
|
||||
block = config.components.get("c11_tile_manager")
|
||||
if block is None:
|
||||
raise ConfigError(
|
||||
"build_tile_downloader: config.components['c11_tile_manager'] "
|
||||
"block is missing — register C11Config and supply YAML"
|
||||
)
|
||||
if not isinstance(block, C11Config):
|
||||
raise ConfigError(
|
||||
"build_tile_downloader: config.components['c11_tile_manager'] "
|
||||
f"must be a C11Config, got {type(block).__name__}"
|
||||
)
|
||||
if not block.satellite_provider_url:
|
||||
raise ConfigError(
|
||||
"build_tile_downloader: C11Config.satellite_provider_url "
|
||||
"must be configured for production / operator wiring"
|
||||
)
|
||||
if not block.service_api_key:
|
||||
raise ConfigError(
|
||||
"build_tile_downloader: C11Config.service_api_key must be "
|
||||
"set; the operator-tooling deploy MUST inject the bearer "
|
||||
"token via env override"
|
||||
)
|
||||
logger = get_logger(_C11_DOWNLOADER_LOGGER)
|
||||
adapter = _C6DownloadAdapter(
|
||||
tile_store=tile_store,
|
||||
metadata_store=tile_metadata_store,
|
||||
budget_enforcer=budget_enforcer,
|
||||
companion_id=companion_id or block.companion_id,
|
||||
)
|
||||
return HttpTileDownloader(
|
||||
http_client=http_client,
|
||||
tile_writer=adapter,
|
||||
budget_enforcer=adapter,
|
||||
logger=logger,
|
||||
config=block,
|
||||
)
|
||||
|
||||
|
||||
class _C6DownloadAdapter:
|
||||
"""Composition-root bridge between AZ-316 and c6 storage.
|
||||
|
||||
Implements both :class:`_TileWriterLike` and
|
||||
:class:`_BudgetEnforcerLike` Protocol cuts (declared in
|
||||
``c11_tile_manager.tile_downloader`` as structural Protocols).
|
||||
Hides c6's :class:`TileMetadata` / :class:`TileSource` /
|
||||
:class:`FreshnessLabel` / :class:`SectorClassification` so the
|
||||
AZ-316 module never imports c6.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_tile_store",
|
||||
"_metadata_store",
|
||||
"_budget_enforcer",
|
||||
"_companion_id",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tile_store: Any,
|
||||
metadata_store: Any,
|
||||
budget_enforcer: Any,
|
||||
companion_id: str,
|
||||
) -> None:
|
||||
self._tile_store = tile_store
|
||||
self._metadata_store = metadata_store
|
||||
self._budget_enforcer = budget_enforcer
|
||||
self._companion_id = companion_id
|
||||
|
||||
def write_tile_for_download(
|
||||
self,
|
||||
*,
|
||||
tile_blob: bytes,
|
||||
zoom_level: int,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tile_size_meters: float,
|
||||
tile_size_pixels: int,
|
||||
capture_timestamp: Any,
|
||||
content_sha256_hex: str,
|
||||
sector_class: str,
|
||||
) -> str:
|
||||
from gps_denied_onboard.components.c6_tile_cache._types import (
|
||||
FreshnessLabel,
|
||||
TileId,
|
||||
TileMetadata,
|
||||
TileSource,
|
||||
VotingStatus,
|
||||
)
|
||||
|
||||
tid = TileId(zoom_level=int(zoom_level), lat=float(lat), lon=float(lon))
|
||||
metadata = TileMetadata(
|
||||
tile_id=tid,
|
||||
tile_size_meters=float(tile_size_meters),
|
||||
tile_size_pixels=int(tile_size_pixels),
|
||||
capture_timestamp=capture_timestamp,
|
||||
source=TileSource.GOOGLEMAPS,
|
||||
content_sha256_hex=content_sha256_hex,
|
||||
freshness_label=FreshnessLabel.FRESH,
|
||||
flight_id=None,
|
||||
companion_id=None,
|
||||
quality_metadata=None,
|
||||
voting_status=VotingStatus.TRUSTED,
|
||||
)
|
||||
self._tile_store.write_tile(tile_blob, metadata)
|
||||
self._metadata_store.insert_metadata(metadata)
|
||||
# AZ-307 owns the actual freshness label after insert; for the
|
||||
# download path the simplest contract is "FRESH on first write,
|
||||
# DOWNGRADED if the row already existed under stable_rear stale
|
||||
# rules". The c6 store does not currently expose the post-insert
|
||||
# label as a return value (AZ-303 contract); we return "fresh"
|
||||
# as the conservative default. A future c6 ABI extension that
|
||||
# surfaces the label can update this adapter without touching
|
||||
# the AZ-316 module.
|
||||
return "fresh"
|
||||
|
||||
def tile_already_present(
|
||||
self, *, zoom_level: int, lat: float, lon: float
|
||||
) -> bool:
|
||||
from gps_denied_onboard.components.c6_tile_cache._types import TileId
|
||||
|
||||
tid = TileId(zoom_level=int(zoom_level), lat=float(lat), lon=float(lon))
|
||||
return bool(self._tile_store.tile_exists(tid))
|
||||
|
||||
def reserve_headroom(self, needed_bytes: int) -> Any:
|
||||
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
CacheBudgetExceededError,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.errors import (
|
||||
CacheBudgetExhaustedError,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._budget_enforcer.reserve_headroom(needed_bytes)
|
||||
except CacheBudgetExhaustedError as exc:
|
||||
raise CacheBudgetExceededError(
|
||||
f"c6 cache budget exhausted: needed {needed_bytes} bytes; {exc}"
|
||||
) from exc
|
||||
|
||||
Reference in New Issue
Block a user