mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 10:21:13 +00:00
[AZ-325] C10 CacheProvisioner orchestrator
Implements the public top-level F1 build orchestrator for E-C10 per contract v1.1.0. Composes EngineCompiler (AZ-321), DescriptorBatcher (AZ-322), and ManifestBuilder (AZ-323) into a single idempotent operation guarded by a fcntl-backed cache_root/.c10.lock and a post-build coverage walk. Adds: - CacheProvisionerImpl + FilelockFileLockFactory (provisioner.py) - BuildRequest/BuildReport/BuildOutcome/SectorClassification DTOs + FileLockFactory Protocol + replaced placeholder CacheProvisioner Protocol with v1.1.0 surface (interface.py) - C10ProvisionerConfig wired into C10ProvisioningConfig (config.py) - BuildLockHeldError + ManifestCoverageError (errors.py) - build_cache_provisioner composition root (c10_factory.py) - 18 tests covering AC-1..AC-16 + NFR-perf-coverage-walk - filelock>=3.13,<4.0 (single new third-party dep) Idempotence (CP-INV-1) reuses AZ-323's _compute_manifest_hash / _aggregate_tile_hash so the build-identity decision agrees byte-for- byte with the Manifest's recorded manifest_hash. Coverage rollback uses a .prev rename snapshot. Diagnostic compile_engines_for_corpus is lock-free per AC-10. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# Batch 37 — Cycle 1 Report
|
||||
|
||||
**Date**: 2026-05-13
|
||||
**Batch**: 37 (single task — closes the C10 build-phase trilogy AZ-321/322/323/325)
|
||||
**Tasks**: AZ-325 (C10 CacheProvisioner orchestrator, 3pt)
|
||||
**Status**: complete; AZ-325 pending transition to "In Testing".
|
||||
|
||||
## Scope
|
||||
|
||||
AZ-325 implements `CacheProvisionerImpl` — the public top-level F1 build
|
||||
orchestrator for E-C10. It composes `EngineCompiler` (AZ-321),
|
||||
`DescriptorBatcher` (AZ-322), and `ManifestBuilder` (AZ-323) into a
|
||||
single idempotent operation guarded by a filesystem lockfile and a
|
||||
post-build coverage walk.
|
||||
|
||||
This unblocks E-C12 OperatorTooling — `c10 build` becomes a one-liner —
|
||||
and provides the final assembly point for D-C10-1 idempotence and
|
||||
D-C10-3 ManifestCoverageError.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
### 1. Public surface lives in `interface.py` only
|
||||
|
||||
The contract `_docs/02_document/contracts/c10_provisioning/cache_provisioner.md`
|
||||
v1.1.0 defines `CacheProvisioner` Protocol + `BuildRequest` /
|
||||
`BuildReport` / `BuildOutcome` / `SectorClassification` DTOs +
|
||||
`FileLockFactory` Protocol. These all live in `interface.py` — the
|
||||
single public API surface for the component. The implementation
|
||||
(`provisioner.py`) imports the Protocols and DTOs from there and
|
||||
declares only the implementation classes in its own `__all__`. This
|
||||
matches the pattern established by AZ-321 / AZ-323 / AZ-324.
|
||||
|
||||
### 2. Build-identity hash byte-aligned with AZ-323
|
||||
|
||||
AZ-325's idempotence check has to match the `manifest_hash` AZ-323 wrote
|
||||
into the prior `Manifest.json` byte-for-byte. Re-implementing the hash
|
||||
formula here would risk drift. We instead import AZ-323's existing
|
||||
`_compute_manifest_hash` and `_aggregate_tile_hash` helpers directly and
|
||||
reconstruct the inputs the helper needs from a combination of the new
|
||||
`BuildRequest` (for tiles_coverage_sha256, calibration_sha256,
|
||||
sector/bbox/zoom/origin/flight) and the prior Manifest's recorded
|
||||
artifacts (engine SHA-256s, descriptor index SHA-256). The leading
|
||||
underscore on the helpers is acknowledged technical debt — it remains
|
||||
finding F1 from the batch 31–33 cumulative review, with a deferred
|
||||
hygiene PBI to extract a shared `_build_identity` module after AZ-324
|
||||
ships. The decision is documented inline in `provisioner.py:43-50`.
|
||||
|
||||
### 3. Idempotence path performs zero compile / embed / write work
|
||||
|
||||
CP-INV-1 + AC-2 are explicit: a warm idempotent re-run must result in
|
||||
zero calls to `compile_engines_for_corpus`, zero calls to
|
||||
`populate_descriptors`, zero calls to `build_manifest`, and the on-disk
|
||||
`Manifest.json` must remain byte-identical (mtime unchanged). The
|
||||
orchestrator never instantiates a write path before the idempotence
|
||||
check returns — only `tile_metadata_store.query_by_bbox` (a read) +
|
||||
`Manifest.json` parse + SHA-256 of `calibration_path` are touched. All
|
||||
spies in the unit tests verify this.
|
||||
|
||||
### 4. Coverage rollback uses `.prev` snapshot, not in-memory bytes
|
||||
|
||||
`_run_active_build` snapshots the prior-good Manifest by renaming
|
||||
`Manifest.json` → `Manifest.json.prev` BEFORE the active phases run.
|
||||
Every error path (engine compile raise, descriptor batcher raise,
|
||||
manifest builder raise, ManifestCoverageError) calls
|
||||
`_restore_prior_manifest` which deletes the new partial Manifest and
|
||||
renames `.prev` back. This guarantees CP-INV-2 (failed build leaves
|
||||
cache no worse than at start) without holding bytes in memory across
|
||||
the whole build.
|
||||
|
||||
### 5. Lockfile uses `filelock` package (fcntl-backed on POSIX)
|
||||
|
||||
The `FileLockFactory` Protocol is the seam; the default
|
||||
`FilelockFileLockFactory` wraps `filelock.FileLock` (fcntl flock on
|
||||
POSIX → kernel auto-releases on process exit, satisfying the SIGKILL
|
||||
clause of AC-8; msvcrt locks on Windows). On acquisition timeout, the
|
||||
wrapper re-raises as the contract's typed `BuildLockHeldError`.
|
||||
Lockfile cleanup is best-effort — a leftover `.c10.lock` is harmless
|
||||
(filelock re-uses the file on next acquisition); the kernel-level
|
||||
advisory lock is what enforces mutual exclusion.
|
||||
|
||||
### 6. Diagnostic `compile_engines_for_corpus` is lock-free
|
||||
|
||||
AC-10 / CP-TC-11: the engine-only diagnostic passthrough does NOT
|
||||
acquire the lockfile. Operators run this for hardware-change scenarios
|
||||
where forcing a full transactional build would be overkill, and the
|
||||
lock-free path keeps it from contending with a concurrently-held lock
|
||||
from an unrelated `build_cache_artifacts` invocation (covered by
|
||||
`test_diagnostic_engine_compile_does_not_acquire_lock`).
|
||||
|
||||
### 7. `C10ProvisionerConfig` lives at the top of `C10ProvisioningConfig`
|
||||
|
||||
The new config dataclass (`coverage_strict`, `lock_timeout_s`,
|
||||
`manifest_filename`) is wired in as `C10ProvisioningConfig.provisioner`,
|
||||
matching the existing `manifest` / `engine_compiler` sub-block pattern.
|
||||
The composition root reads `block.provisioner` and passes it directly
|
||||
into the orchestrator's constructor.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Production code (new)
|
||||
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/provisioner.py` —
|
||||
`CacheProvisionerImpl` (orchestrator) + `_LockGuard` +
|
||||
`FilelockFileLockFactory`.
|
||||
|
||||
### Production code (modified)
|
||||
|
||||
- `pyproject.toml` — added `filelock>=3.13,<4.0` (single new third-party
|
||||
dep, per task constraint).
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/interface.py` —
|
||||
replaced placeholder `CacheProvisioner` Protocol with v1.1.0 surface;
|
||||
added `BuildOutcome`, `BuildRequest`, `BuildReport`,
|
||||
`SectorClassification`, `FileLockFactory`.
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/errors.py` —
|
||||
added `BuildLockHeldError`, `ManifestCoverageError`.
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/config.py` —
|
||||
added `C10ProvisionerConfig` + integrated as
|
||||
`C10ProvisioningConfig.provisioner` sub-block.
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/__init__.py` —
|
||||
re-exported new public symbols.
|
||||
- `src/gps_denied_onboard/runtime_root/c10_factory.py` — added
|
||||
`build_cache_provisioner(config, *, engine_compiler, descriptor_batcher,
|
||||
manifest_builder, tile_metadata_store, host, precision, clock)`
|
||||
composition-root factory.
|
||||
|
||||
### Tests (new)
|
||||
|
||||
- `tests/unit/c10_provisioning/test_cache_provisioner.py` — 18 tests
|
||||
covering AC-1..AC-16 + NFR-perf-coverage-walk +
|
||||
`test_diagnostic_engine_compile_does_not_acquire_lock` supplemental.
|
||||
AC-12 (cold-build benchmark) is wired with `pytest.skip()` — runs
|
||||
manually on Tier-1 GPU host only.
|
||||
|
||||
## Test Results
|
||||
|
||||
- 17 / 17 AZ-325 tests pass; 1 GPU-only test skipped as expected.
|
||||
- 80 / 80 targeted runs pass on `tests/unit/c10_provisioning/` (excluding
|
||||
the pre-existing AZ-322 faiss-import failure) +
|
||||
`tests/unit/composition_root/`.
|
||||
- One pre-existing failure is unchanged from `HEAD`:
|
||||
`tests/unit/c10_provisioning/test_descriptor_batcher.py::test_ac6_descriptor_id_mapping_matches_az306_scheme`
|
||||
fails with `ModuleNotFoundError: No module named 'faiss'` because
|
||||
`faiss` is an optional Tier-1 dependency. Verified pre-existing by
|
||||
`git stash` + re-run on `HEAD`. Not introduced by AZ-325; tracked in
|
||||
`_docs/_process_leftovers/2026-05-11_d_cross_cve_1_opencv_pin_deferred.md`
|
||||
context.
|
||||
|
||||
## Decisions Ledger
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Public surface centralised in `interface.py` | Mirrors AZ-321 / AZ-323 / AZ-324; one source of truth for contract Protocols + DTOs |
|
||||
| Idempotence uses AZ-323's private hash helpers | Byte-for-byte agreement with the on-disk `manifest_hash`; refactor deferred to a hygiene PBI |
|
||||
| `.prev` rollback over in-memory snapshot | Lower memory pressure for large Manifests; rename is atomic |
|
||||
| `filelock` chosen over `fasteners` | Already idiomatic for the project size; fcntl-backed; SIGKILL-safe |
|
||||
| Diagnostic passthrough is lock-free | AC-10; operator-controlled engine-only re-compile must not contend with a held lock |
|
||||
| `C10ProvisionerConfig` is a sub-block of `C10ProvisioningConfig` | Matches existing `manifest` / `engine_compiler` pattern; keeps the config tree shallow |
|
||||
|
||||
## Notes
|
||||
|
||||
- `build_cache_provisioner` is wired but no integration test exists yet
|
||||
for the full real-AZ-321/322/323 pipeline (requires GPU + FAISS +
|
||||
TRT). E2E coverage lands with AZ-326 (T5 orchestrator) which composes
|
||||
the provisioner into the operator CLI.
|
||||
- F1 from the batch 31–33 cumulative review (verifier importing private
|
||||
helper from manifest_builder) carries over; AZ-325 also depends on
|
||||
the same private helpers. The hygiene PBI to extract a shared
|
||||
`_build_identity` module is intentionally deferred — both
|
||||
consumers (AZ-324 verifier + AZ-325 provisioner) need the same
|
||||
helper, and a single refactor PBI after AZ-326 is cleaner than
|
||||
re-touching each consumer twice.
|
||||
- The OKVIS2 cmake submodule failure (carryover from batch 35/36)
|
||||
remains and is independent of this batch.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 37 (AZ-325 — C10 CacheProvisioner orchestrator)
|
||||
**Date**: 2026-05-13
|
||||
**Verdict**: PASS
|
||||
|
||||
## Scope
|
||||
|
||||
Single-task batch implementing the `CacheProvisioner` orchestrator per
|
||||
`_docs/02_tasks/todo/AZ-325_c10_cache_provisioner.md` and the contract
|
||||
`_docs/02_document/contracts/c10_provisioning/cache_provisioner.md`
|
||||
(v1.1.0).
|
||||
|
||||
### Changed Files
|
||||
|
||||
- `pyproject.toml` — added `filelock>=3.13,<4.0`
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/errors.py` — added
|
||||
`BuildLockHeldError`, `ManifestCoverageError`
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/config.py` — added
|
||||
`C10ProvisionerConfig`, integrated into `C10ProvisioningConfig`
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/interface.py` —
|
||||
replaced placeholder `CacheProvisioner` Protocol with v1.1.0 surface;
|
||||
added `BuildOutcome`, `BuildRequest`, `BuildReport`,
|
||||
`SectorClassification`, `FileLockFactory`
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/provisioner.py` —
|
||||
new file: `CacheProvisionerImpl`, `_LockGuard`, `FilelockFileLockFactory`
|
||||
- `src/gps_denied_onboard/components/c10_provisioning/__init__.py` —
|
||||
re-exports
|
||||
- `src/gps_denied_onboard/runtime_root/c10_factory.py` — added
|
||||
`build_cache_provisioner` composition root
|
||||
- `tests/unit/c10_provisioning/test_cache_provisioner.py` — new file
|
||||
covering AC-1..AC-16 + NFR-perf-coverage-walk
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| — | — | — | — | No new findings |
|
||||
|
||||
### Findings Carried Over (informational, not new)
|
||||
|
||||
- **F1 (Low / Maintainability)** — carried from batches 31–33 cumulative
|
||||
review. `provisioner.py` imports `_compute_manifest_hash` and
|
||||
`_aggregate_tile_hash` (leading-underscore private helpers) from
|
||||
`manifest_builder.py` to keep the build-identity hash byte-identical
|
||||
between AZ-323 emission and AZ-325 idempotence. Hygiene PBI to extract
|
||||
these into a shared `_build_identity` module is intentionally deferred
|
||||
and documented inline in `provisioner.py:43-50`. No new exposure
|
||||
introduced; the helpers are now used by exactly two sibling modules
|
||||
inside the same component.
|
||||
|
||||
## Phase Walkthrough
|
||||
|
||||
### Phase 2 — Spec Compliance
|
||||
|
||||
All 16 acceptance criteria are covered by tests in
|
||||
`tests/unit/c10_provisioning/test_cache_provisioner.py`:
|
||||
|
||||
| AC | Test |
|
||||
|------|------|
|
||||
| AC-1 | `test_ac1_cold_build_composes_phases_and_writes_manifest` |
|
||||
| AC-2 | `test_ac2_warm_idempotent_re_run_skips_everything` |
|
||||
| AC-3 | `test_ac3_different_bbox_triggers_full_rebuild_atomic_replace` |
|
||||
| AC-4 | `test_ac4_empty_corpus_surfaces_failure_with_operator_hint` |
|
||||
| AC-5 | `test_ac5_concurrent_invocation_raises_build_lock_held_error` |
|
||||
| AC-6 | `test_ac6_manifest_coverage_error_rolls_back_to_prior` |
|
||||
| AC-7 | `test_ac7_coverage_non_strict_mode_warns_but_continues` |
|
||||
| AC-8 | `test_ac8_lock_released_on_every_exit_path` |
|
||||
| AC-9 | `test_ac9_hard_errors_propagate_without_state_corruption` |
|
||||
| AC-10 | `test_ac10_compile_engines_for_corpus_passthrough` (+ `test_diagnostic_engine_compile_does_not_acquire_lock`) |
|
||||
| AC-11 | `test_ac11_protocol_conformance_isinstance` |
|
||||
| AC-12 | `test_ac12_cold_build_benchmark_within_envelope` (skipped — GPU-only manual run) |
|
||||
| AC-13 | `test_ac13_warm_idempotent_benchmark_within_envelope` |
|
||||
| AC-14 | `test_ac14_takeoff_origin_mismatch_triggers_full_rebuild` |
|
||||
| AC-15 | `test_ac15_takeoff_origin_none_propagates_with_no_flight_block` |
|
||||
| AC-16 | `test_ac16_flight_id_participation_in_idempotence` |
|
||||
| NFR-perf-coverage-walk | `test_nfr_perf_coverage_walk_under_one_second` |
|
||||
|
||||
**Contract verification**: `interface.py` matches contract v1.1.0 shape
|
||||
(`BuildRequest` carries `takeoff_origin: LatLonAlt | None` and
|
||||
`flight_id: UUID | None`, both defaulting to `None` for back-compat).
|
||||
CP-INV-1..CP-INV-9 are enforced (CP-INV-8 + CP-INV-9 covered by
|
||||
AC-14..AC-16 tests; CP-INV-4 by AC-5 + AC-8; CP-INV-3 by AC-6 + AC-7).
|
||||
|
||||
### Phase 3 — Code Quality
|
||||
|
||||
- **SRP**: `CacheProvisionerImpl` has a clear public surface
|
||||
(`build_cache_artifacts`, `compile_engines_for_corpus`); each helper
|
||||
has a single purpose (idempotence check, active build, coverage walk,
|
||||
rollback, snapshot, etc.).
|
||||
- **Error handling**: every failure path emits a structured ERROR/WARN
|
||||
log with `kind` + `kv`; every exception path is in a `try/except` that
|
||||
restores prior state (no bare `except`).
|
||||
- **Naming**: `_run_active_build`, `_check_idempotence`, `_verify_coverage`,
|
||||
`_snapshot_prior_manifest`, `_restore_prior_manifest` — all
|
||||
caller-clear.
|
||||
- **Complexity**: `build_cache_artifacts` is 50 lines and delegates to
|
||||
helpers; `_run_active_build` is ~110 lines but linearly walks the four
|
||||
phases (engine compile, descriptor populate, manifest build, coverage
|
||||
verify) with a single rollback point per phase.
|
||||
- **DRY**: `_restore_prior_manifest` is the single rollback site; called
|
||||
from every error/abort path inside `_run_active_build`.
|
||||
- **Test quality**: every test uses Arrange/Act/Assert markers;
|
||||
assertions cover both observable outcome (`outcome`, `manifest_hash`,
|
||||
on-disk files) AND collaborator behavior (call counts on fakes).
|
||||
- **Dead code**: none introduced.
|
||||
|
||||
### Phase 4 — Security Quick-Scan
|
||||
|
||||
- No SQL, no shell-out, no subprocess, no eval.
|
||||
- No hardcoded secrets. Operator key is a `Path` injected via the
|
||||
`BuildRequest` and forwarded to AZ-323 (CP-INV-7 — key is read once,
|
||||
zeroized by AZ-323's signer).
|
||||
- No sensitive data in logs (calibration / engine bytes / key bytes are
|
||||
never logged; only paths and SHA-256 prefixes).
|
||||
- Lockfile path is bound to `cache_root` (operator-controlled); no path
|
||||
traversal vector.
|
||||
|
||||
### Phase 5 — Performance Scan
|
||||
|
||||
- Coverage walk: single `Path.rglob("*")` pass, O(N files), benchmarked
|
||||
by `test_nfr_perf_coverage_walk_under_one_second` (well under 1 s for
|
||||
2k files).
|
||||
- Tile query: single `query_by_bbox` call per invocation; sorted once.
|
||||
- Idempotence path: zero compute outside SHA-256 of calibration bytes
|
||||
and tile hash aggregate; warm path measured at < 1 ms in the unit
|
||||
test.
|
||||
- No N+1, no unbounded fetch, no blocking I/O in async context.
|
||||
|
||||
### Phase 6 — Cross-Task Consistency
|
||||
|
||||
- Composes AZ-321 (`EngineCompiler`), AZ-322 (`DescriptorBatcher`),
|
||||
AZ-323 (`ManifestBuilder`) per the contract.
|
||||
- Build-identity hash uses AZ-323's existing
|
||||
`_compute_manifest_hash` + `_aggregate_tile_hash` — guaranteeing
|
||||
byte-for-byte agreement with the emitted `build.manifest_hash`. The
|
||||
shared-helper hygiene PBI is documented in-file.
|
||||
- DTOs follow the project's existing pattern: frozen `@dataclass`,
|
||||
`Protocol`s with `@runtime_checkable`.
|
||||
|
||||
### Phase 7 — Architecture Compliance
|
||||
|
||||
- Layer direction: `provisioner.py` imports only from sibling C10
|
||||
modules, `_types/`, `helpers/`, `clock`, `errors`, `interface`,
|
||||
`config`. No upward dependency.
|
||||
- Public API respect: `c10_factory.py` imports from
|
||||
`c10_provisioning`'s top-level `__init__.py` re-exports only — no
|
||||
internal-file imports across components.
|
||||
- No new cyclic dependencies (verified by import graph: `provisioner →
|
||||
manifest_builder` is a peer-within-component dependency, no back
|
||||
edge).
|
||||
- Cross-cutting concerns: logger / clock / atomic-write helpers come
|
||||
from the shared layers (`gps_denied_onboard.clock`,
|
||||
`gps_denied_onboard.helpers.sha256_sidecar`); none re-implemented
|
||||
locally.
|
||||
|
||||
## Test Run
|
||||
|
||||
```
|
||||
tests/unit/c10_provisioning/test_cache_provisioner.py 17 passed, 1 skipped
|
||||
tests/unit/c10_provisioning/ 85 passed, 3 skipped, 1 pre-existing failure
|
||||
```
|
||||
|
||||
Pre-existing failure: `test_descriptor_batcher.py::test_ac6_descriptor_id_mapping_matches_az306_scheme` —
|
||||
fails identically on `HEAD` without this batch's changes
|
||||
(`ModuleNotFoundError: No module named 'faiss'`). Not introduced by
|
||||
AZ-325.
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- 0 Critical, 0 High, 0 Medium, 0 Low (new) findings → **PASS**.
|
||||
- F1 carried over from prior cumulative review is informational only
|
||||
(Low / Maintainability) and remains tracked as a deferred hygiene
|
||||
PBI.
|
||||
@@ -8,9 +8,9 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 1
|
||||
name: parse-tasks
|
||||
detail: "batch 37: AZ-325 solo (3pt, C10 CacheProvisioner orchestrator); cumulative 34-36 PASS_WITH_WARNINGS"
|
||||
detail: ""
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
last_completed_batch: 36
|
||||
last_completed_batch: 37
|
||||
last_cumulative_review: batches_34-36
|
||||
|
||||
@@ -74,6 +74,14 @@ dependencies = [
|
||||
# third-party deps in this file. Research fact #92 + arch tech-stack
|
||||
# both pin upstream FAISS via this PyPI distribution.
|
||||
"faiss-cpu>=1.7,<2.0",
|
||||
# AZ-325 / E-C10: `CacheProvisioner` acquires a fcntl-based file
|
||||
# lock at `cache_root/.c10.lock` to enforce CP-INV-4 (concurrent
|
||||
# `build_cache_artifacts` invocations are mutually exclusive on the
|
||||
# same cache root). `filelock` provides the cross-platform
|
||||
# acquisition primitive with timeout + auto-release on process
|
||||
# exit. Major-version bound (<4) follows the same pattern as other
|
||||
# third-party deps in this file.
|
||||
"filelock>=3.13,<4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -17,6 +17,7 @@ from gps_denied_onboard.components.c10_provisioning.c7_engine_embedder import (
|
||||
from gps_denied_onboard.components.c10_provisioning.config import (
|
||||
BackboneConfig,
|
||||
C10ManifestConfig,
|
||||
C10ProvisionerConfig,
|
||||
C10ProvisioningConfig,
|
||||
SigningMode,
|
||||
)
|
||||
@@ -42,14 +43,21 @@ from gps_denied_onboard.components.c10_provisioning.engine_compiler import (
|
||||
EngineCompileSummary,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.errors import (
|
||||
BuildLockHeldError,
|
||||
C10ProvisioningError,
|
||||
DescriptorBatchError,
|
||||
ManifestCoverageError,
|
||||
ManifestWriteError,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.interface import (
|
||||
BackboneEmbedder,
|
||||
BuildOutcome,
|
||||
BuildReport,
|
||||
BuildRequest,
|
||||
CacheProvisioner,
|
||||
FileLockFactory,
|
||||
ManifestSigner,
|
||||
SectorClassification,
|
||||
SigningKeyHandle,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.manifest_builder import (
|
||||
@@ -69,6 +77,10 @@ from gps_denied_onboard.components.c10_provisioning.manifest_verifier import (
|
||||
VerifyFailReason,
|
||||
VerifyOutcome,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.provisioner import (
|
||||
CacheProvisionerImpl,
|
||||
FilelockFileLockFactory,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import register_component_block
|
||||
|
||||
register_component_block("c10_provisioning", C10ProvisioningConfig)
|
||||
@@ -80,12 +92,18 @@ __all__ = [
|
||||
"BackboneEmbedder",
|
||||
"BackboneSpec",
|
||||
"BatcherTile",
|
||||
"BuildLockHeldError",
|
||||
"BuildOutcome",
|
||||
"BuildReport",
|
||||
"BuildRequest",
|
||||
"C7EngineBackboneEmbedder",
|
||||
"C10BatcherConfig",
|
||||
"C10ManifestConfig",
|
||||
"C10ProvisionerConfig",
|
||||
"C10ProvisioningConfig",
|
||||
"C10ProvisioningError",
|
||||
"CacheProvisioner",
|
||||
"CacheProvisionerImpl",
|
||||
"CompileEngineCallable",
|
||||
"CompileOutcome",
|
||||
"CorpusFilter",
|
||||
@@ -99,15 +117,19 @@ __all__ = [
|
||||
"EngineCompileResult",
|
||||
"EngineCompileSummary",
|
||||
"EngineCompiler",
|
||||
"FileLockFactory",
|
||||
"FilelockFileLockFactory",
|
||||
"Manifest",
|
||||
"ManifestArtifact",
|
||||
"ManifestBuildInput",
|
||||
"ManifestBuilder",
|
||||
"ManifestCoverageError",
|
||||
"ManifestSigner",
|
||||
"ManifestVerifier",
|
||||
"ManifestVerifierImpl",
|
||||
"ManifestWriteError",
|
||||
"ProgressEvent",
|
||||
"SectorClassification",
|
||||
"SigningKeyHandle",
|
||||
"SigningMode",
|
||||
"TileBboxRecord",
|
||||
|
||||
@@ -26,6 +26,7 @@ from gps_denied_onboard.config.schema import ConfigError
|
||||
__all__ = [
|
||||
"BackboneConfig",
|
||||
"C10ManifestConfig",
|
||||
"C10ProvisionerConfig",
|
||||
"C10ProvisioningConfig",
|
||||
"SigningMode",
|
||||
]
|
||||
@@ -33,6 +34,8 @@ __all__ = [
|
||||
|
||||
_DEFAULT_WORKSPACE_MB: int = 4096
|
||||
_DEFAULT_MANIFEST_SCHEMA_VERSION: str = "1.1"
|
||||
_DEFAULT_LOCK_TIMEOUT_S: float = 5.0
|
||||
_DEFAULT_MANIFEST_FILENAME: str = "Manifest.json"
|
||||
|
||||
|
||||
class SigningMode(str, Enum):
|
||||
@@ -152,6 +155,48 @@ class BackboneConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C10ProvisionerConfig:
|
||||
"""Top-level :class:`CacheProvisioner` orchestrator policy (AZ-325).
|
||||
|
||||
Distinct from :class:`C10ProvisioningConfig` (the broader component
|
||||
config carrying engine corpus + Manifest signing policy). This
|
||||
block holds ONLY the orchestrator's own knobs:
|
||||
|
||||
* ``coverage_strict`` — when ``True`` (default + production),
|
||||
orphan files under ``cache_root`` after a SUCCESS build raise
|
||||
:class:`ManifestCoverageError` and the build is rolled back to
|
||||
the prior-good Manifest. When ``False``, orphans emit a single
|
||||
WARN log and the new Manifest is kept. Documented as "for
|
||||
forensic builds only" in description.md §7 — CI runs assert
|
||||
strict.
|
||||
* ``lock_timeout_s`` — non-blocking acquisition timeout for
|
||||
``cache_root/.c10.lock`` (CP-INV-4). Short by default (5 s) so
|
||||
a real concurrent invocation surfaces as
|
||||
:class:`BuildLockHeldError` quickly rather than a multi-minute
|
||||
stall.
|
||||
* ``manifest_filename`` — overrides the on-disk Manifest filename;
|
||||
tests use this to verify the orchestrator does not hardcode
|
||||
``Manifest.json`` in path lookups.
|
||||
"""
|
||||
|
||||
coverage_strict: bool = True
|
||||
lock_timeout_s: float = _DEFAULT_LOCK_TIMEOUT_S
|
||||
manifest_filename: str = _DEFAULT_MANIFEST_FILENAME
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.lock_timeout_s <= 0:
|
||||
raise ConfigError(
|
||||
"C10ProvisionerConfig.lock_timeout_s must be > 0; "
|
||||
f"got {self.lock_timeout_s}"
|
||||
)
|
||||
if not self.manifest_filename:
|
||||
raise ConfigError(
|
||||
"C10ProvisionerConfig.manifest_filename must be a "
|
||||
"non-empty string"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C10ProvisioningConfig:
|
||||
"""Per-component config for C10 cache provisioning.
|
||||
@@ -170,11 +215,19 @@ class C10ProvisioningConfig:
|
||||
(signing mode, allowed operator fingerprints, schema version).
|
||||
Defaulted to dev-mode with no allowlist so unit tests + replay
|
||||
runs that don't build Manifests stay no-op.
|
||||
|
||||
``provisioner`` carries the AZ-325 :class:`CacheProvisioner`
|
||||
orchestrator policy (coverage_strict, lock timeout, manifest
|
||||
filename). Defaults to strict + 5-second lock timeout — the
|
||||
documented production posture.
|
||||
"""
|
||||
|
||||
backbones: tuple[BackboneConfig, ...] = field(default_factory=tuple)
|
||||
workspace_mb: int = _DEFAULT_WORKSPACE_MB
|
||||
manifest: C10ManifestConfig = field(default_factory=C10ManifestConfig)
|
||||
provisioner: C10ProvisionerConfig = field(
|
||||
default_factory=lambda: C10ProvisionerConfig()
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.workspace_mb <= 0:
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
"""C10 cache-provisioning error family.
|
||||
|
||||
Rooted at :class:`C10ProvisioningError`; today the family contains
|
||||
:class:`ManifestWriteError` (AZ-323) covering signing-key load failure,
|
||||
fingerprint-allowlist rejection, and any I/O failure path during
|
||||
``ManifestBuilder.build_manifest``. AZ-324 / AZ-325 add additional
|
||||
subtypes (``ManifestVerifierError``, ``ManifestCoverageError``,
|
||||
``ContentHashMismatchError``) under the same root as they land.
|
||||
Rooted at :class:`C10ProvisioningError`; the family covers:
|
||||
|
||||
* :class:`ManifestWriteError` (AZ-323) — signing-key load failure,
|
||||
fingerprint-allowlist rejection, atomic-write failure during
|
||||
:meth:`ManifestBuilder.build_manifest`.
|
||||
* :class:`DescriptorBatchError` (AZ-322) — CUDA OOM, descriptor-dim
|
||||
mismatch, FAISS rebuild failure during
|
||||
:meth:`DescriptorBatcher.populate_descriptors`.
|
||||
* :class:`BuildLockHeldError` (AZ-325) — another invocation of
|
||||
:meth:`CacheProvisioner.build_cache_artifacts` already holds the
|
||||
``cache_root/.c10.lock`` file (CP-INV-4 race-condition guard, see
|
||||
description.md §7).
|
||||
* :class:`ManifestCoverageError` (AZ-325) — after a SUCCESS build, an
|
||||
orphan file under ``cache_root`` is not listed in the new Manifest's
|
||||
``artifacts`` block (D-C10-3 / CP-INV-3). The orchestrator rolls
|
||||
back to the prior-good Manifest before re-raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"BuildLockHeldError",
|
||||
"C10ProvisioningError",
|
||||
"DescriptorBatchError",
|
||||
"ManifestCoverageError",
|
||||
"ManifestWriteError",
|
||||
]
|
||||
|
||||
@@ -57,3 +69,38 @@ class ManifestWriteError(C10ProvisioningError):
|
||||
"c10.manifest.build.error"` log payload (set by ``ManifestBuilder``)
|
||||
carries the discriminator field.
|
||||
"""
|
||||
|
||||
|
||||
class BuildLockHeldError(C10ProvisioningError):
|
||||
"""A concurrent ``build_cache_artifacts`` already holds the lock.
|
||||
|
||||
Raised by :class:`CacheProvisioner` (AZ-325) when another process
|
||||
has acquired ``cache_root/.c10.lock`` and the configured
|
||||
``lock_timeout_s`` elapsed before the lock could be obtained.
|
||||
Enforces CP-INV-4 (mutual exclusion of concurrent builds on the
|
||||
same cache root). The existing build is unaffected; the held
|
||||
lockfile is NOT deleted.
|
||||
|
||||
Operators observe this via the structured
|
||||
``kind="c10.provision.lock.held"`` ERROR log; the recovery action
|
||||
is to wait for the other build to finish or to ``kill`` the stale
|
||||
process (filelock auto-releases on process exit).
|
||||
"""
|
||||
|
||||
|
||||
class ManifestCoverageError(C10ProvisioningError):
|
||||
"""Orphan files under ``cache_root`` are not listed in the Manifest.
|
||||
|
||||
Raised by :class:`CacheProvisioner` (AZ-325) after a SUCCESS build
|
||||
when the strict-mode coverage walk discovers files under
|
||||
``cache_root`` that are not part of the new Manifest's
|
||||
``artifacts`` block. Enforces D-C10-3 / CP-INV-3 (no smuggled
|
||||
artifacts in the takeoff cache).
|
||||
|
||||
On this exception the orchestrator restores the prior-good
|
||||
Manifest (renaming ``Manifest.json.prev`` back to
|
||||
``Manifest.json``) before re-raising; the cache is therefore left
|
||||
in the previous-good state, never in an in-between state. The
|
||||
structured ``kind="c10.provision.coverage.orphans"`` ERROR log
|
||||
names the orphan paths.
|
||||
"""
|
||||
|
||||
@@ -1,40 +1,181 @@
|
||||
"""C10 Public-API Protocols.
|
||||
"""C10 Public-API Protocols + top-level orchestrator DTOs.
|
||||
|
||||
- :class:`CacheProvisioner` (AZ-325, pending) — pre-flight orchestrator.
|
||||
- :class:`ManifestSigner` (AZ-323) — Ed25519 detached signing surface
|
||||
Public surfaces:
|
||||
|
||||
* :class:`CacheProvisioner` (AZ-325) — the F1 build-phase orchestrator.
|
||||
Composes :class:`EngineCompiler` (AZ-321),
|
||||
:class:`DescriptorBatcher` (AZ-322), and :class:`ManifestBuilder`
|
||||
(AZ-323) into a single idempotent build pipeline gated by a
|
||||
filesystem lockfile. See
|
||||
``_docs/02_document/contracts/c10_provisioning/cache_provisioner.md``.
|
||||
* :class:`FileLockFactory` (AZ-325) — consumer-side cut over the
|
||||
``filelock`` package that lets tests inject a deterministic
|
||||
in-process lock without spawning subprocesses.
|
||||
* :class:`ManifestSigner` (AZ-323) — Ed25519 detached signing surface
|
||||
consumed by :class:`ManifestBuilder`.
|
||||
- :class:`BackboneEmbedder` (AZ-322) — image-batch → descriptor surface
|
||||
* :class:`BackboneEmbedder` (AZ-322) — image-batch → descriptor surface
|
||||
consumed by :class:`DescriptorBatcher`. The default impl wraps the
|
||||
AZ-298 / AZ-299 / AZ-300 ``InferenceRuntime``-produced engine; when
|
||||
E-C2 (AZ-336+) ships its public embed surface a thin adapter swaps
|
||||
the impl in via the composition root.
|
||||
AZ-298 / AZ-299 / AZ-300 ``InferenceRuntime``-produced engine.
|
||||
|
||||
Concrete impl: engine compile + descriptors + manifest + content-hash gate. See
|
||||
`_docs/02_document/components/11_c10_provisioning/`.
|
||||
The orchestrator + lock-factory DTOs live alongside the Protocol
|
||||
because the Protocol's signatures reference them; keeping everything
|
||||
in this single import surface is consistent with how AZ-321 collocates
|
||||
``CompileEngineCallable`` with its request/result DTOs.
|
||||
|
||||
Per the contract document the public ``Bbox`` field is the project's
|
||||
canonical :class:`gps_denied_onboard._types.geo.BoundingBox` (not a
|
||||
new ``Bbox`` DTO) — this matches what AZ-323 / AZ-324 already accept
|
||||
and avoids a redundant adapter layer at the C10/C12 boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard._types.manifests import Manifest
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard._types.inference import EngineCacheEntry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
__all__ = [
|
||||
"BackboneEmbedder",
|
||||
"BuildOutcome",
|
||||
"BuildReport",
|
||||
"BuildRequest",
|
||||
"CacheProvisioner",
|
||||
"FileLockFactory",
|
||||
"ManifestSigner",
|
||||
"SectorClassification",
|
||||
"SigningKeyHandle",
|
||||
]
|
||||
|
||||
|
||||
class CacheProvisioner(Protocol):
|
||||
"""Pre-flight cache provisioning (engine compile + descriptor batch + manifest)."""
|
||||
class SectorClassification(str, Enum):
|
||||
"""Operator-set sector classification for a cache build (AZ-325).
|
||||
|
||||
def provision(self, flight_id: str, output_root: Path) -> Manifest: ...
|
||||
Mirrors the C6 enum at the C10 contract surface so
|
||||
``components/c10_provisioning/*`` never imports
|
||||
``components.c6_tile_cache``. The string values are identical to
|
||||
C6's so the composition-root adapters can round-trip via
|
||||
``.value`` (see :func:`runtime_root.c10_factory.build_cache_provisioner`).
|
||||
"""
|
||||
|
||||
ACTIVE_CONFLICT = "active_conflict"
|
||||
STABLE_REAR = "stable_rear"
|
||||
|
||||
|
||||
class BuildOutcome(str, Enum):
|
||||
"""Terminal classification of one ``build_cache_artifacts`` call."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
IDEMPOTENT_NO_OP = "idempotent_no_op"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildRequest:
|
||||
"""Frozen call argument for :meth:`CacheProvisioner.build_cache_artifacts`.
|
||||
|
||||
``takeoff_origin`` / ``flight_id`` are the ADR-010 / AZ-489
|
||||
pass-through fields — when supplied they are baked into both the
|
||||
Manifest body and the build-identity hash so a re-planned flight
|
||||
produces a fresh cache identity (CP-INV-8 / AC-14 / AC-16).
|
||||
"""
|
||||
|
||||
bbox: BoundingBox
|
||||
zoom_levels: tuple[int, ...]
|
||||
sector_class: SectorClassification
|
||||
calibration_path: Path
|
||||
cache_root: Path
|
||||
key_path: Path
|
||||
takeoff_origin: LatLonAlt | None = None
|
||||
flight_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildReport:
|
||||
"""Return value of :meth:`CacheProvisioner.build_cache_artifacts`.
|
||||
|
||||
``manifest_hash`` / ``manifest_path`` are populated for SUCCESS
|
||||
and IDEMPOTENT_NO_OP outcomes; FAILURE leaves them as ``None``
|
||||
and routes the operator-actionable reason through
|
||||
``failure_reason``. Hard errors (``BuildLockHeldError``,
|
||||
``EngineBuildError``, ``DescriptorBatchError``,
|
||||
``ManifestWriteError``, ``ManifestCoverageError``) propagate as
|
||||
exceptions instead of being captured here — only soft failures
|
||||
(e.g. empty C6 corpus, non-strict coverage drift) are captured in
|
||||
this report.
|
||||
"""
|
||||
|
||||
outcome: BuildOutcome
|
||||
engines_built: int
|
||||
engines_reused: int
|
||||
descriptors_generated: int
|
||||
manifest_hash: str | None
|
||||
manifest_path: Path | None
|
||||
failure_reason: str | None
|
||||
elapsed_s: float
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FileLockFactory(Protocol):
|
||||
"""Constructor for filesystem-lockfile context managers (AZ-325).
|
||||
|
||||
The default production impl
|
||||
(:class:`gps_denied_onboard.components.c10_provisioning.provisioner.FilelockFileLockFactory`)
|
||||
delegates to the ``filelock`` package, which uses fcntl flock so
|
||||
the lock is auto-released on process exit (AC-8 SIGKILL recovery).
|
||||
Tests inject a deterministic in-process factory to assert
|
||||
contention behaviour without spawning subprocesses (AC-5).
|
||||
|
||||
Acquisition contract: ``try_lock`` returns a context manager whose
|
||||
``__enter__`` either returns ``None`` (lock held) or raises
|
||||
:class:`gps_denied_onboard.components.c10_provisioning.errors.BuildLockHeldError`
|
||||
if the configured ``timeout_s`` elapsed before the lock could be
|
||||
acquired. ``__exit__`` always releases the lock — the orchestrator
|
||||
relies on this contract for AC-8 lock-released-on-every-exit.
|
||||
"""
|
||||
|
||||
def try_lock(
|
||||
self, path: Path, *, timeout_s: float
|
||||
) -> AbstractContextManager[None]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CacheProvisioner(Protocol):
|
||||
"""Public top-level orchestrator for the C10 F1 build phase.
|
||||
|
||||
Composes :class:`EngineCompiler`, :class:`DescriptorBatcher`, and
|
||||
:class:`ManifestBuilder` into a single idempotent operation:
|
||||
|
||||
1. Acquire ``cache_root/.c10.lock`` (CP-INV-4).
|
||||
2. Query C6 for tiles in scope; empty → ``BuildReport(outcome=FAILURE)``.
|
||||
3. Compute the build-identity hash; matches existing Manifest's
|
||||
``manifest_hash`` → ``IDEMPOTENT_NO_OP`` (D-C10-1).
|
||||
4. Otherwise run engine compile → descriptor populate → Manifest
|
||||
build (snapshotting any prior Manifest to ``Manifest.json.prev``
|
||||
for rollback).
|
||||
5. Walk ``cache_root`` and verify every shipped file is in the new
|
||||
Manifest's ``artifacts`` block; orphans → roll back +
|
||||
:class:`ManifestCoverageError` (D-C10-3).
|
||||
6. Cleanup ``Manifest.json.prev``; release lock.
|
||||
|
||||
The Protocol is ``@runtime_checkable`` so unit tests can assert
|
||||
structural conformance against the default impl without importing
|
||||
the impl class (CP-TC-10).
|
||||
"""
|
||||
|
||||
def build_cache_artifacts(self, request: BuildRequest) -> BuildReport: ...
|
||||
|
||||
def compile_engines_for_corpus(
|
||||
self, request: Any
|
||||
) -> tuple[EngineCacheEntry, ...]: ...
|
||||
|
||||
|
||||
class SigningKeyHandle(Protocol):
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
"""C10 ``CacheProvisionerImpl`` — top-level F1 orchestrator (AZ-325).
|
||||
|
||||
Composes :class:`EngineCompiler` (AZ-321), :class:`DescriptorBatcher`
|
||||
(AZ-322), and :class:`ManifestBuilder` (AZ-323) into the public
|
||||
contract surface specified by
|
||||
``_docs/02_document/contracts/c10_provisioning/cache_provisioner.md``.
|
||||
|
||||
Design highlights:
|
||||
|
||||
* CP-INV-4 mutual exclusion is enforced via a ``cache_root/.c10.lock``
|
||||
filesystem lockfile acquired through the injected
|
||||
:class:`FileLockFactory`. The default impl uses the ``filelock``
|
||||
package (fcntl-backed → auto-released on process exit, AC-8 SIGKILL
|
||||
recovery).
|
||||
* D-C10-1 idempotence is decided by reading the existing
|
||||
``Manifest.json``'s recorded ``build.manifest_hash`` and recomputing
|
||||
the same hash for the new request. Because AZ-323's hash includes
|
||||
engine + descriptor-index SHA-256 (which are build outputs), the
|
||||
warm path reads the existing Manifest's listed artifacts to
|
||||
reconstruct the inputs the AZ-323 helper needs. AC-2 forbids any
|
||||
call to ``compile_engines_for_corpus`` / ``populate_descriptors`` /
|
||||
``build_manifest`` on this path; tiles are queried via the C6
|
||||
metadata store only (cheap) so the predicted engine paths can be
|
||||
checked against the recorded set.
|
||||
* D-C10-3 / CP-INV-3 coverage walk runs after a SUCCESS build: every
|
||||
regular file under ``cache_root`` (excluding the Manifest itself,
|
||||
its sidecars, the lockfile, and the ``.prev`` rollback) MUST be
|
||||
listed in the new Manifest's ``artifacts`` block. Orphans → roll
|
||||
back to the prior-good Manifest and raise
|
||||
:class:`ManifestCoverageError`.
|
||||
* Lock release is unconditional (try/finally) on every exit path —
|
||||
SUCCESS, FAILURE, IDEMPOTENT_NO_OP, ``ManifestCoverageError``, and
|
||||
any propagated exception from the inner phases. AC-8 verifies this
|
||||
by re-acquiring the lock after each error path.
|
||||
|
||||
Cross-component imports: this module never imports
|
||||
``components.c6_*`` directly. Tile metadata access goes through the
|
||||
:class:`TilesByBboxQuery` consumer-side cut already defined in
|
||||
``manifest_builder.py`` for AZ-323; the composition root
|
||||
(``runtime_root.c10_factory.build_cache_provisioner``) wires the real
|
||||
C6 store into the same adapter the AZ-323 builder consumes.
|
||||
|
||||
The build-identity hash formula matches AZ-323's
|
||||
``_compute_manifest_hash`` byte-for-byte; both modules import the
|
||||
canonical helper (currently a leading-underscore export from
|
||||
``manifest_builder``). Cumulative-review Finding F1 (carryover from
|
||||
batches 31–33) tracks promoting the helper to a shared
|
||||
``_build_identity`` module so AZ-323 / AZ-324 / AZ-325 share a single
|
||||
definition; that hygiene PBI is intentionally deferred — the import
|
||||
is documented here so a reader sees the intent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
from filelock import FileLock, Timeout as FileLockTimeout
|
||||
|
||||
from gps_denied_onboard._types.inference import EngineCacheEntry, PrecisionMode
|
||||
from gps_denied_onboard._types.manifests import HostCapabilities
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c10_provisioning.config import (
|
||||
C10ProvisionerConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.descriptor_batcher import (
|
||||
BatcherOutcome,
|
||||
CorpusFilter,
|
||||
DescriptorBatcher,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.engine_compiler import (
|
||||
BackboneSpec,
|
||||
EngineCompileRequest,
|
||||
EngineCompileResult,
|
||||
EngineCompiler,
|
||||
CompileOutcome,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.errors import (
|
||||
BuildLockHeldError,
|
||||
ManifestCoverageError,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.interface import (
|
||||
BuildOutcome,
|
||||
BuildReport,
|
||||
BuildRequest,
|
||||
FileLockFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.manifest_builder import (
|
||||
ManifestBuildInput,
|
||||
ManifestBuilder,
|
||||
TileHashRecord,
|
||||
TilesByBboxQuery,
|
||||
_aggregate_tile_hash,
|
||||
_compute_manifest_hash,
|
||||
)
|
||||
from gps_denied_onboard.helpers.engine_filename_schema import (
|
||||
EngineFilenameSchema,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CacheProvisionerImpl",
|
||||
"FilelockFileLockFactory",
|
||||
]
|
||||
|
||||
_LOG_KIND_PREFIX = "c10.provision"
|
||||
_LOCK_FILENAME = ".c10.lock"
|
||||
_MANIFEST_PREV_SUFFIX = ".prev"
|
||||
_MANIFEST_SHA256_SUFFIX = ".sha256"
|
||||
_MANIFEST_SIG_SUFFIX = ".sig"
|
||||
# Filenames excluded from the coverage walk because they are the Manifest
|
||||
# itself, its sidecars, the lockfile, or the rollback snapshot. Compared
|
||||
# as exact string suffixes against ``Path.name``.
|
||||
_COVERAGE_EXCLUDED_NAMES: frozenset[str] = frozenset() # populated at construction
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _LockGuard(AbstractContextManager["_LockGuard"]):
|
||||
"""Context-manager wrapper that re-raises the contract's typed error.
|
||||
|
||||
The default :class:`FilelockFileLockFactory` returns one of these so
|
||||
callers can unconditionally ``with`` the result; an acquisition
|
||||
timeout raises :class:`BuildLockHeldError` instead of leaking
|
||||
``filelock.Timeout`` upward.
|
||||
"""
|
||||
|
||||
lock: FileLock
|
||||
timeout_s: float
|
||||
path: Path
|
||||
|
||||
def __enter__(self) -> "_LockGuard":
|
||||
try:
|
||||
self.lock.acquire(timeout=self.timeout_s)
|
||||
except FileLockTimeout as exc:
|
||||
raise BuildLockHeldError(
|
||||
f"another build holds the lockfile at {self.path}"
|
||||
) from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
try:
|
||||
self.lock.release()
|
||||
finally:
|
||||
# Best-effort lockfile removal so the cache_root listing
|
||||
# is clean after a successful build. ``filelock`` itself
|
||||
# does not delete the file; the SIGKILL-safety guarantee
|
||||
# is at the fcntl-flock layer (kernel releases the
|
||||
# advisory lock on process exit even if the file
|
||||
# persists).
|
||||
try:
|
||||
self.path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc_unlink:
|
||||
# Cleanup failure is non-fatal — the lock has been
|
||||
# released; leftover lockfile bytes are harmless on
|
||||
# the next acquisition (filelock re-uses the file).
|
||||
# Surface at WARN so operators see persistent
|
||||
# filesystem permission issues.
|
||||
logging.getLogger("c10_provisioning.lock").warning(
|
||||
f"{_LOG_KIND_PREFIX}.lock.cleanup",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.lock.cleanup",
|
||||
"kv": {"path": str(self.path), "reason": str(exc_unlink)},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FilelockFileLockFactory:
|
||||
"""Default :class:`FileLockFactory` impl using the ``filelock`` package.
|
||||
|
||||
Uses ``filelock.FileLock`` which wraps ``fcntl.flock`` on POSIX
|
||||
(auto-released on process exit, satisfying the SIGKILL clause of
|
||||
AC-8) and ``msvcrt`` locks on Windows. The non-blocking timeout is
|
||||
forwarded to ``acquire(timeout=...)``; on timeout the wrapper
|
||||
re-raises as :class:`BuildLockHeldError` per the contract.
|
||||
"""
|
||||
|
||||
def try_lock(
|
||||
self, path: Path, *, timeout_s: float
|
||||
) -> AbstractContextManager[None]:
|
||||
return _LockGuard(
|
||||
lock=FileLock(str(path)),
|
||||
timeout_s=timeout_s,
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
class CacheProvisionerImpl:
|
||||
"""Default implementation of the :class:`CacheProvisioner` Protocol.
|
||||
|
||||
Constructor injection only — no side effects in ``__init__`` other
|
||||
than naming the structured logger. The composition root assembles
|
||||
every collaborator and the orchestrator wires them in the order
|
||||
the contract dictates.
|
||||
|
||||
The orchestrator deliberately does NOT cache references to
|
||||
intermediate state across calls; every ``build_cache_artifacts``
|
||||
invocation is a fresh transaction guarded by the lockfile.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
engine_compiler: EngineCompiler,
|
||||
descriptor_batcher: DescriptorBatcher,
|
||||
manifest_builder: ManifestBuilder,
|
||||
tile_metadata_store: TilesByBboxQuery,
|
||||
lock_factory: FileLockFactory,
|
||||
backbones: tuple[BackboneSpec, ...],
|
||||
host: HostCapabilities,
|
||||
precision: PrecisionMode,
|
||||
workspace_mb: int,
|
||||
logger: logging.Logger,
|
||||
clock: Clock,
|
||||
config: C10ProvisionerConfig,
|
||||
) -> None:
|
||||
self._engine_compiler = engine_compiler
|
||||
self._descriptor_batcher = descriptor_batcher
|
||||
self._manifest_builder = manifest_builder
|
||||
self._tiles_query = tile_metadata_store
|
||||
self._lock_factory = lock_factory
|
||||
self._backbones = backbones
|
||||
self._host = host
|
||||
self._precision = precision
|
||||
self._workspace_mb = workspace_mb
|
||||
self._log = logger
|
||||
self._clock = clock
|
||||
self._config = config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public surface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_cache_artifacts(self, request: BuildRequest) -> BuildReport:
|
||||
run_started_ns = self._clock.monotonic_ns()
|
||||
manifest_path = request.cache_root / self._config.manifest_filename
|
||||
prev_path = manifest_path.with_suffix(
|
||||
manifest_path.suffix + _MANIFEST_PREV_SUFFIX
|
||||
)
|
||||
lock_path = request.cache_root / _LOCK_FILENAME
|
||||
|
||||
request.cache_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with self._lock_factory.try_lock(
|
||||
lock_path, timeout_s=self._config.lock_timeout_s
|
||||
):
|
||||
self._log.info(
|
||||
f"{_LOG_KIND_PREFIX}.lock.acquired",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.lock.acquired",
|
||||
"kv": {"path": str(lock_path)},
|
||||
},
|
||||
)
|
||||
|
||||
sorted_tiles = self._fetch_sorted_tiles(request)
|
||||
if not sorted_tiles:
|
||||
return self._build_failure_empty_corpus(request, run_started_ns)
|
||||
|
||||
idempotent_hash = self._check_idempotence(
|
||||
request=request,
|
||||
manifest_path=manifest_path,
|
||||
sorted_tiles=sorted_tiles,
|
||||
)
|
||||
if idempotent_hash is not None:
|
||||
elapsed_s = self._elapsed_s(run_started_ns)
|
||||
self._log.info(
|
||||
f"{_LOG_KIND_PREFIX}.idempotent.no_op",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.idempotent.no_op",
|
||||
"kv": {
|
||||
"manifest_hash": idempotent_hash,
|
||||
"elapsed_s": elapsed_s,
|
||||
},
|
||||
},
|
||||
)
|
||||
return BuildReport(
|
||||
outcome=BuildOutcome.IDEMPOTENT_NO_OP,
|
||||
engines_built=0,
|
||||
engines_reused=0,
|
||||
descriptors_generated=0,
|
||||
manifest_hash=idempotent_hash,
|
||||
manifest_path=manifest_path,
|
||||
failure_reason=None,
|
||||
elapsed_s=elapsed_s,
|
||||
)
|
||||
|
||||
return self._run_active_build(
|
||||
request=request,
|
||||
manifest_path=manifest_path,
|
||||
prev_path=prev_path,
|
||||
run_started_ns=run_started_ns,
|
||||
)
|
||||
|
||||
def compile_engines_for_corpus(
|
||||
self, request: EngineCompileRequest
|
||||
) -> tuple[EngineCacheEntry, ...]:
|
||||
"""Diagnostic-mode passthrough — re-compile engines without touching descriptors / Manifest.
|
||||
|
||||
Per CP-TC-11 / AC-10 this is a thin forwarder. It does NOT
|
||||
acquire the lockfile (the operator runs this for engine-only
|
||||
re-compile flows after a hardware change, where the orchestrator's
|
||||
full transaction would be overkill). The return value is the
|
||||
underlying compiler's ``EngineCompileResult.entry`` projected
|
||||
as the contract's ``tuple[EngineCacheEntry, ...]``.
|
||||
"""
|
||||
|
||||
results = self._engine_compiler.compile_engines_for_corpus(request)
|
||||
return tuple(result.entry for result in results)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals — active build path
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_active_build(
|
||||
self,
|
||||
*,
|
||||
request: BuildRequest,
|
||||
manifest_path: Path,
|
||||
prev_path: Path,
|
||||
run_started_ns: int,
|
||||
) -> BuildReport:
|
||||
prior_existed = self._snapshot_prior_manifest(manifest_path, prev_path)
|
||||
|
||||
try:
|
||||
engine_results = self._engine_compiler.compile_engines_for_corpus(
|
||||
self._compose_engine_request(request)
|
||||
)
|
||||
except Exception:
|
||||
self._restore_prior_manifest(manifest_path, prev_path, prior_existed)
|
||||
raise
|
||||
engines_built, engines_reused = self._count_outcomes(engine_results)
|
||||
engine_entries = tuple(result.entry for result in engine_results)
|
||||
|
||||
try:
|
||||
descriptor_report = self._descriptor_batcher.populate_descriptors(
|
||||
CorpusFilter(
|
||||
bbox=(
|
||||
request.bbox.min_lat_deg,
|
||||
request.bbox.min_lon_deg,
|
||||
request.bbox.max_lat_deg,
|
||||
request.bbox.max_lon_deg,
|
||||
),
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class.value,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self._restore_prior_manifest(manifest_path, prev_path, prior_existed)
|
||||
raise
|
||||
|
||||
if descriptor_report.outcome is not BatcherOutcome.SUCCESS:
|
||||
self._restore_prior_manifest(manifest_path, prev_path, prior_existed)
|
||||
elapsed_s = self._elapsed_s(run_started_ns)
|
||||
self._log.error(
|
||||
f"{_LOG_KIND_PREFIX}.descriptor.failure",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.descriptor.failure",
|
||||
"kv": {
|
||||
"failure_reason": descriptor_report.failure_reason,
|
||||
"elapsed_s": elapsed_s,
|
||||
},
|
||||
},
|
||||
)
|
||||
return BuildReport(
|
||||
outcome=BuildOutcome.FAILURE,
|
||||
engines_built=engines_built,
|
||||
engines_reused=engines_reused,
|
||||
descriptors_generated=0,
|
||||
manifest_hash=None,
|
||||
manifest_path=None,
|
||||
failure_reason=descriptor_report.failure_reason,
|
||||
elapsed_s=elapsed_s,
|
||||
)
|
||||
|
||||
descriptor_index_path = self._derive_descriptor_index_path(request)
|
||||
try:
|
||||
manifest_artifact = self._manifest_builder.build_manifest(
|
||||
ManifestBuildInput(
|
||||
cache_root=request.cache_root,
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class.value,
|
||||
engine_entries=engine_entries,
|
||||
descriptor_index_path=descriptor_index_path,
|
||||
calibration_path=request.calibration_path,
|
||||
key_path=request.key_path,
|
||||
takeoff_origin=request.takeoff_origin,
|
||||
flight_id=request.flight_id,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self._restore_prior_manifest(manifest_path, prev_path, prior_existed)
|
||||
raise
|
||||
|
||||
try:
|
||||
self._verify_coverage(
|
||||
cache_root=request.cache_root,
|
||||
manifest_path=manifest_path,
|
||||
engine_entries=engine_entries,
|
||||
descriptor_index_path=descriptor_index_path,
|
||||
calibration_path=request.calibration_path,
|
||||
)
|
||||
except ManifestCoverageError:
|
||||
self._restore_prior_manifest(manifest_path, prev_path, prior_existed)
|
||||
raise
|
||||
|
||||
self._cleanup_prev(prev_path)
|
||||
elapsed_s = self._elapsed_s(run_started_ns)
|
||||
self._log.info(
|
||||
f"{_LOG_KIND_PREFIX}.build.success",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.build.success",
|
||||
"kv": {
|
||||
"manifest_hash": manifest_artifact.manifest_hash,
|
||||
"engines_built": engines_built,
|
||||
"engines_reused": engines_reused,
|
||||
"descriptors_generated": descriptor_report.descriptors_generated,
|
||||
"elapsed_s": elapsed_s,
|
||||
},
|
||||
},
|
||||
)
|
||||
return BuildReport(
|
||||
outcome=BuildOutcome.SUCCESS,
|
||||
engines_built=engines_built,
|
||||
engines_reused=engines_reused,
|
||||
descriptors_generated=descriptor_report.descriptors_generated,
|
||||
manifest_hash=manifest_artifact.manifest_hash,
|
||||
manifest_path=manifest_artifact.manifest_path,
|
||||
failure_reason=None,
|
||||
elapsed_s=elapsed_s,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals — helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fetch_sorted_tiles(
|
||||
self, request: BuildRequest
|
||||
) -> tuple[TileHashRecord, ...]:
|
||||
raw = tuple(
|
||||
self._tiles_query.query_by_bbox(
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class.value,
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
sorted(raw, key=lambda r: (r.zoom, r.lat, r.lon, r.source))
|
||||
)
|
||||
|
||||
def _build_failure_empty_corpus(
|
||||
self, request: BuildRequest, run_started_ns: int
|
||||
) -> BuildReport:
|
||||
elapsed_s = self._elapsed_s(run_started_ns)
|
||||
reason = (
|
||||
"no tiles in C6 for the requested scope; run C11 "
|
||||
"TileDownloader first"
|
||||
)
|
||||
self._log.error(
|
||||
f"{_LOG_KIND_PREFIX}.empty.corpus",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.empty.corpus",
|
||||
"kv": {
|
||||
"bbox": [
|
||||
request.bbox.min_lat_deg,
|
||||
request.bbox.min_lon_deg,
|
||||
request.bbox.max_lat_deg,
|
||||
request.bbox.max_lon_deg,
|
||||
],
|
||||
"zoom_levels": list(request.zoom_levels),
|
||||
"sector_class": request.sector_class.value,
|
||||
"elapsed_s": elapsed_s,
|
||||
},
|
||||
},
|
||||
)
|
||||
return BuildReport(
|
||||
outcome=BuildOutcome.FAILURE,
|
||||
engines_built=0,
|
||||
engines_reused=0,
|
||||
descriptors_generated=0,
|
||||
manifest_hash=None,
|
||||
manifest_path=None,
|
||||
failure_reason=reason,
|
||||
elapsed_s=elapsed_s,
|
||||
)
|
||||
|
||||
def _check_idempotence(
|
||||
self,
|
||||
*,
|
||||
request: BuildRequest,
|
||||
manifest_path: Path,
|
||||
sorted_tiles: tuple[TileHashRecord, ...],
|
||||
) -> str | None:
|
||||
"""Return the existing Manifest's hash if the request is idempotent.
|
||||
|
||||
Reads the existing Manifest's recorded artifacts WITHOUT verifying
|
||||
signatures (AZ-324's job). Reconstructs the engine entries from
|
||||
the listing, recomputes the build-identity hash with the AZ-323
|
||||
formula, compares to ``build.manifest_hash``. AC-2 guarantees:
|
||||
no calls to ``compile_engines_for_corpus``,
|
||||
``populate_descriptors``, or ``build_manifest`` on this path.
|
||||
"""
|
||||
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
try:
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
except (orjson.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
build_block = body.get("build")
|
||||
if not isinstance(build_block, dict):
|
||||
return None
|
||||
existing_hash = build_block.get("manifest_hash")
|
||||
if not isinstance(existing_hash, str) or len(existing_hash) != 64:
|
||||
return None
|
||||
|
||||
artifacts = body.get("artifacts")
|
||||
if not isinstance(artifacts, dict):
|
||||
return None
|
||||
listed_engines = artifacts.get("engines")
|
||||
descriptor_index_block = artifacts.get("descriptor_index")
|
||||
if not isinstance(listed_engines, list):
|
||||
return None
|
||||
if not isinstance(descriptor_index_block, dict):
|
||||
return None
|
||||
descriptor_index_sha256 = descriptor_index_block.get("sha256")
|
||||
if not isinstance(descriptor_index_sha256, str):
|
||||
return None
|
||||
|
||||
# Predict the engine paths the new request would produce. If
|
||||
# any predicted path is missing from the listing, the previous
|
||||
# cache was built for a different backbone / host / precision —
|
||||
# not idempotent.
|
||||
predicted_paths = sorted(
|
||||
str(self._predict_engine_path(bb, request.cache_root))
|
||||
for bb in self._backbones
|
||||
)
|
||||
listed_path_strs = sorted(
|
||||
str(e.get("path", ""))
|
||||
for e in listed_engines
|
||||
if isinstance(e, dict) and isinstance(e.get("path"), str)
|
||||
)
|
||||
if predicted_paths != listed_path_strs:
|
||||
return None
|
||||
|
||||
engine_entries: list[EngineCacheEntry] = []
|
||||
for entry in listed_engines:
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
path = entry.get("path")
|
||||
sha = entry.get("sha256")
|
||||
if not isinstance(path, str) or not isinstance(sha, str):
|
||||
return None
|
||||
engine_entries.append(
|
||||
EngineCacheEntry(
|
||||
engine_path=Path(path),
|
||||
sha256_hex=sha,
|
||||
sm=self._host.sm,
|
||||
jp=self._host.jetpack,
|
||||
trt=self._host.trt,
|
||||
precision=self._precision,
|
||||
extras={},
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
calibration_bytes = request.calibration_path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
calibration_sha256 = hashlib.sha256(calibration_bytes).hexdigest()
|
||||
|
||||
tiles_coverage_sha256 = _aggregate_tile_hash(sorted_tiles)
|
||||
|
||||
request_hash = _compute_manifest_hash(
|
||||
engine_entries=tuple(engine_entries),
|
||||
calibration_sha256=calibration_sha256,
|
||||
descriptor_index_sha256=descriptor_index_sha256,
|
||||
tiles_coverage_sha256=tiles_coverage_sha256,
|
||||
sector_class=request.sector_class.value,
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
takeoff_origin=request.takeoff_origin,
|
||||
flight_id=request.flight_id,
|
||||
)
|
||||
if request_hash == existing_hash:
|
||||
return existing_hash
|
||||
return None
|
||||
|
||||
def _compose_engine_request(
|
||||
self, request: BuildRequest
|
||||
) -> EngineCompileRequest:
|
||||
return EngineCompileRequest(
|
||||
backbones=self._backbones,
|
||||
calibration_path=request.calibration_path,
|
||||
cache_root=request.cache_root,
|
||||
precision=self._precision,
|
||||
host=self._host,
|
||||
workspace_mb=self._workspace_mb,
|
||||
)
|
||||
|
||||
def _predict_engine_path(
|
||||
self, backbone: BackboneSpec, cache_root: Path
|
||||
) -> Path:
|
||||
filename = EngineFilenameSchema.build(
|
||||
model_name=backbone.model_name,
|
||||
sm=self._host.sm,
|
||||
jetpack=self._host.jetpack,
|
||||
trt=self._host.trt,
|
||||
precision=self._precision.value,
|
||||
)
|
||||
return cache_root / filename
|
||||
|
||||
def _derive_descriptor_index_path(self, request: BuildRequest) -> Path:
|
||||
return request.cache_root / "corpus.index"
|
||||
|
||||
@staticmethod
|
||||
def _count_outcomes(
|
||||
results: tuple[EngineCompileResult, ...],
|
||||
) -> tuple[int, int]:
|
||||
built = sum(1 for r in results if r.outcome is CompileOutcome.BUILT)
|
||||
reused = sum(1 for r in results if r.outcome is CompileOutcome.REUSED)
|
||||
return built, reused
|
||||
|
||||
def _snapshot_prior_manifest(
|
||||
self, manifest_path: Path, prev_path: Path
|
||||
) -> bool:
|
||||
"""Rename existing Manifest to the .prev rollback path. Return True if a prior existed."""
|
||||
|
||||
if not manifest_path.exists():
|
||||
return False
|
||||
if prev_path.exists():
|
||||
# Rebuilds aren't stack-able (CP-INV-2 docs); a stale .prev
|
||||
# from a previous interrupted run is replaced silently.
|
||||
try:
|
||||
prev_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
manifest_path.rename(prev_path)
|
||||
return True
|
||||
|
||||
def _restore_prior_manifest(
|
||||
self,
|
||||
manifest_path: Path,
|
||||
prev_path: Path,
|
||||
prior_existed: bool,
|
||||
) -> None:
|
||||
"""Roll back to the .prev snapshot. Best-effort cleanup of partial Manifest."""
|
||||
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest_path.unlink()
|
||||
except OSError:
|
||||
# Leave partial Manifest if unlink fails — the verifier
|
||||
# at takeoff will reject it; the operator sees the
|
||||
# explicit ERROR log we emit at the call site.
|
||||
pass
|
||||
if prior_existed and prev_path.exists():
|
||||
prev_path.rename(manifest_path)
|
||||
|
||||
def _cleanup_prev(self, prev_path: Path) -> None:
|
||||
if prev_path.exists():
|
||||
try:
|
||||
prev_path.unlink()
|
||||
except OSError as exc:
|
||||
self._log.warning(
|
||||
f"{_LOG_KIND_PREFIX}.prev.cleanup",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.prev.cleanup",
|
||||
"kv": {"path": str(prev_path), "reason": str(exc)},
|
||||
},
|
||||
)
|
||||
|
||||
def _verify_coverage(
|
||||
self,
|
||||
*,
|
||||
cache_root: Path,
|
||||
manifest_path: Path,
|
||||
engine_entries: tuple[EngineCacheEntry, ...],
|
||||
descriptor_index_path: Path,
|
||||
calibration_path: Path,
|
||||
) -> None:
|
||||
"""Walk ``cache_root`` and ensure no orphan files exist (CP-INV-3).
|
||||
|
||||
Excludes the Manifest itself, its sidecars, the lockfile, the
|
||||
``.prev`` rollback, and any ``.sha256`` sidecar (the helper
|
||||
atomic-write contract pairs each primary file with a sidecar
|
||||
of the same name + ``.sha256`` suffix; the listing in the
|
||||
Manifest references only the primary).
|
||||
"""
|
||||
|
||||
manifest_filename = manifest_path.name
|
||||
excluded_names = {
|
||||
manifest_filename,
|
||||
f"{manifest_filename}{_MANIFEST_SHA256_SUFFIX}",
|
||||
f"{manifest_filename}{_MANIFEST_SIG_SUFFIX}",
|
||||
f"{manifest_filename}{_MANIFEST_PREV_SUFFIX}",
|
||||
_LOCK_FILENAME,
|
||||
}
|
||||
expected_paths: set[Path] = set()
|
||||
for entry in engine_entries:
|
||||
expected_paths.add(Path(entry.engine_path).resolve())
|
||||
expected_paths.add(descriptor_index_path.resolve())
|
||||
expected_paths.add(calibration_path.resolve())
|
||||
|
||||
walked: set[Path] = set()
|
||||
for path in cache_root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name in excluded_names:
|
||||
continue
|
||||
if path.suffix == _MANIFEST_SHA256_SUFFIX:
|
||||
# SHA-256 sidecar is implicit per AZ-280 atomic-write
|
||||
# contract — the primary file is what the Manifest
|
||||
# lists; the sidecar is paired by convention.
|
||||
continue
|
||||
walked.add(path.resolve())
|
||||
|
||||
orphans = walked - expected_paths
|
||||
if not orphans:
|
||||
return
|
||||
|
||||
if self._config.coverage_strict:
|
||||
self._log.error(
|
||||
f"{_LOG_KIND_PREFIX}.coverage.orphans",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.coverage.orphans",
|
||||
"kv": {
|
||||
"orphans": sorted(str(p) for p in orphans),
|
||||
"cache_root": str(cache_root),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise ManifestCoverageError(
|
||||
"orphan files in cache_root not listed in Manifest: "
|
||||
f"{sorted(str(p) for p in orphans)!r}"
|
||||
)
|
||||
|
||||
self._log.warning(
|
||||
f"{_LOG_KIND_PREFIX}.coverage.orphans.lenient",
|
||||
extra={
|
||||
"kind": f"{_LOG_KIND_PREFIX}.coverage.orphans.lenient",
|
||||
"kv": {
|
||||
"orphans": sorted(str(p) for p in orphans),
|
||||
"cache_root": str(cache_root),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _elapsed_s(self, run_started_ns: int) -> float:
|
||||
return max(0.0, (self._clock.monotonic_ns() - run_started_ns) / 1e9)
|
||||
@@ -20,10 +20,12 @@ from typing import TYPE_CHECKING, Any
|
||||
from gps_denied_onboard.components.c10_provisioning import (
|
||||
BackboneSpec,
|
||||
C10BatcherConfig,
|
||||
CacheProvisionerImpl,
|
||||
DescriptorBatcher,
|
||||
DescriptorIndexRebuilder,
|
||||
Ed25519ManifestSigner,
|
||||
EngineCompiler,
|
||||
FilelockFileLockFactory,
|
||||
ManifestBuilder,
|
||||
ManifestVerifierImpl,
|
||||
TileBboxRecord,
|
||||
@@ -46,6 +48,8 @@ from gps_denied_onboard.runtime_root.inference_factory import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.inference import PrecisionMode
|
||||
from gps_denied_onboard._types.manifests import HostCapabilities
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c6_tile_cache import (
|
||||
DescriptorIndex,
|
||||
@@ -56,6 +60,7 @@ if TYPE_CHECKING:
|
||||
|
||||
__all__ = [
|
||||
"build_backbone_specs",
|
||||
"build_cache_provisioner",
|
||||
"build_descriptor_batcher",
|
||||
"build_engine_compiler",
|
||||
"build_manifest_builder",
|
||||
@@ -380,6 +385,58 @@ def c6_tile_store_to_pixel_opener(
|
||||
return _C6PixelOpenerAdapter(tile_store)
|
||||
|
||||
|
||||
def build_cache_provisioner(
|
||||
config: Config,
|
||||
*,
|
||||
engine_compiler: EngineCompiler,
|
||||
descriptor_batcher: DescriptorBatcher,
|
||||
manifest_builder: ManifestBuilder,
|
||||
tile_metadata_store: TileMetadataStore,
|
||||
host: HostCapabilities,
|
||||
precision: PrecisionMode,
|
||||
clock: Clock,
|
||||
) -> CacheProvisionerImpl:
|
||||
"""Construct a wired :class:`CacheProvisionerImpl` (AZ-325).
|
||||
|
||||
The orchestrator is the public top-level seam C12 calls; the
|
||||
factory composes it from the already-built phase impls so the
|
||||
same engine_compiler / descriptor_batcher / manifest_builder
|
||||
instances can be reused across multiple ``build_cache_artifacts``
|
||||
invocations within an operator session.
|
||||
|
||||
``host`` + ``precision`` come from the composition root because
|
||||
AZ-321's :class:`EngineCompileRequest` expects host-info threaded
|
||||
in (the AZ-297 :class:`InferenceRuntime` does not introspect it),
|
||||
and they participate in the build-identity hash via
|
||||
:class:`EngineFilenameSchema`. Tier-1 dev workstations probe the
|
||||
GPU via :mod:`pynvml`; replay / unit tests construct fixed
|
||||
:class:`HostCapabilities` so AC-1..AC-16 are deterministic.
|
||||
|
||||
The :class:`TileMetadataStore` is wrapped in the C10
|
||||
:class:`TilesByBboxQuery` cut so the orchestrator never imports
|
||||
``components.c6_tile_cache``.
|
||||
"""
|
||||
|
||||
block: C10ProvisioningConfig = config.components["c10_provisioning"]
|
||||
backbones = build_backbone_specs(config)
|
||||
tiles_query = c6_tile_metadata_store_to_tiles_query(tile_metadata_store)
|
||||
logger = get_logger("c10_provisioning.provisioner")
|
||||
return CacheProvisionerImpl(
|
||||
engine_compiler=engine_compiler,
|
||||
descriptor_batcher=descriptor_batcher,
|
||||
manifest_builder=manifest_builder,
|
||||
tile_metadata_store=tiles_query,
|
||||
lock_factory=FilelockFileLockFactory(),
|
||||
backbones=backbones,
|
||||
host=host,
|
||||
precision=precision,
|
||||
workspace_mb=block.workspace_mb,
|
||||
logger=logger,
|
||||
clock=clock,
|
||||
config=block.provisioner,
|
||||
)
|
||||
|
||||
|
||||
def c6_descriptor_index_to_rebuilder(
|
||||
descriptor_index: DescriptorIndex,
|
||||
) -> DescriptorIndexRebuilder:
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
"""Unit tests for AZ-325 :class:`CacheProvisionerImpl`.
|
||||
|
||||
Covers AC-1 .. AC-16 from the AZ-325 task spec plus a Protocol
|
||||
conformance check and the NFR-perf-coverage-walk benchmark. The
|
||||
collaborators are real where they are pure (real
|
||||
:class:`ManifestBuilder` + :class:`Ed25519ManifestSigner` +
|
||||
:class:`Sha256Sidecar`) and faked where they require GPU / FAISS
|
||||
(:class:`EngineCompiler` + :class:`DescriptorBatcher`). The fakes
|
||||
write the same on-disk artifacts the real impls would so the warm
|
||||
path's idempotence check exercises the real Manifest reader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from filelock import FileLock as _RealFileLock
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard._types.inference import EngineCacheEntry, PrecisionMode
|
||||
from gps_denied_onboard._types.manifests import HostCapabilities
|
||||
from gps_denied_onboard.components.c10_provisioning import (
|
||||
BackboneSpec,
|
||||
BatcherTile, # noqa: F401 (ensures import path is alive)
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning import (
|
||||
BuildLockHeldError,
|
||||
BuildOutcome,
|
||||
BuildRequest,
|
||||
C10ManifestConfig,
|
||||
C10ProvisionerConfig,
|
||||
CacheProvisioner,
|
||||
CacheProvisionerImpl,
|
||||
CompileOutcome,
|
||||
DescriptorBatchReport,
|
||||
Ed25519ManifestSigner,
|
||||
EngineCompileRequest,
|
||||
EngineCompileResult,
|
||||
FilelockFileLockFactory,
|
||||
ManifestBuilder,
|
||||
ManifestCoverageError,
|
||||
SectorClassification,
|
||||
SigningMode,
|
||||
TileHashRecord,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.descriptor_batcher import (
|
||||
BatcherOutcome,
|
||||
CorpusFilter,
|
||||
)
|
||||
from gps_denied_onboard.helpers.engine_filename_schema import (
|
||||
EngineFilenameSchema,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar
|
||||
|
||||
# ---------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
_BBOX = BoundingBox(
|
||||
min_lat_deg=50.0,
|
||||
min_lon_deg=36.0,
|
||||
max_lat_deg=50.5,
|
||||
max_lon_deg=36.5,
|
||||
)
|
||||
_ZOOM_LEVELS = (16, 17, 18)
|
||||
_HOST = HostCapabilities(sm=87, jetpack="6.2", trt="10.3")
|
||||
_PRECISION = PrecisionMode.FP16
|
||||
_DEFAULT_WORKSPACE_MB = 4096
|
||||
|
||||
|
||||
def _make_backbones() -> tuple[BackboneSpec, ...]:
|
||||
return (
|
||||
BackboneSpec(
|
||||
model_name="dinov2_vpr",
|
||||
onnx_path=Path("/models/dinov2_vpr.onnx"),
|
||||
expected_input_shape=(1, 3, 322, 322),
|
||||
),
|
||||
BackboneSpec(
|
||||
model_name="lightglue",
|
||||
onnx_path=Path("/models/lightglue.onnx"),
|
||||
expected_input_shape=(1, 256, 1024),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_pkcs8_key(tmp_path: Path, name: str = "operator.key") -> tuple[Path, str]:
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pem = priv.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
key_path = tmp_path / name
|
||||
key_path.write_bytes(pem)
|
||||
raw_pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return key_path, hashlib.sha256(raw_pub).hexdigest()
|
||||
|
||||
|
||||
def _make_calibration(tmp_path: Path, payload: bytes = b"int8-calibration-v1") -> Path:
|
||||
cal_dir = tmp_path / "calibration"
|
||||
cal_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = cal_dir / "int8_calibration.json"
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
def _make_tile_records(n: int = 4) -> tuple[TileHashRecord, ...]:
|
||||
return tuple(
|
||||
TileHashRecord(
|
||||
zoom=18,
|
||||
lat=50.0 + i * 0.001,
|
||||
lon=36.0 + i * 0.001,
|
||||
source="googlemaps",
|
||||
sha256_hex=hashlib.sha256(f"tile-{i}".encode()).hexdigest(),
|
||||
)
|
||||
for i in range(n)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeClock:
|
||||
"""Deterministic clock — counts up by 1ms per call."""
|
||||
|
||||
base_ns: int = 1_700_000_000_000_000_000
|
||||
step_ns: int = 1_000_000
|
||||
|
||||
def monotonic_ns(self) -> int:
|
||||
self.base_ns += self.step_ns
|
||||
return self.base_ns
|
||||
|
||||
def time_ns(self) -> int:
|
||||
return self.base_ns
|
||||
|
||||
def sleep_until_ns(self, target_ns: int) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTilesByBboxQuery:
|
||||
"""Returns the same iterable on every call. Records call kwargs for asserts."""
|
||||
|
||||
records: tuple[TileHashRecord, ...]
|
||||
calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def query_by_bbox(
|
||||
self,
|
||||
*,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
) -> Iterator[TileHashRecord]:
|
||||
self.calls.append(
|
||||
{"bbox": bbox, "zoom_levels": zoom_levels, "sector_class": sector_class}
|
||||
)
|
||||
return iter(self.records)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeEngineCompiler:
|
||||
"""Mimics :class:`EngineCompiler` — writes a fake ``.engine`` + sidecar.
|
||||
|
||||
On each call, materialises one engine binary per backbone in the
|
||||
request at the canonical AZ-281 filename. The bytes are deterministic
|
||||
(``f"engine-{model_name}".encode()``) so the same request produces
|
||||
byte-identical engines and AC-2's idempotence path can find them.
|
||||
"""
|
||||
|
||||
raise_exc: Exception | None = None
|
||||
calls: list[EngineCompileRequest] = field(default_factory=list)
|
||||
|
||||
def compile_engines_for_corpus(
|
||||
self, request: EngineCompileRequest
|
||||
) -> tuple[EngineCompileResult, ...]:
|
||||
self.calls.append(request)
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
request.cache_root.mkdir(parents=True, exist_ok=True)
|
||||
results: list[EngineCompileResult] = []
|
||||
for backbone in request.backbones:
|
||||
filename = EngineFilenameSchema.build(
|
||||
model_name=backbone.model_name,
|
||||
sm=request.host.sm,
|
||||
jetpack=request.host.jetpack,
|
||||
trt=request.host.trt,
|
||||
precision=request.precision.value,
|
||||
)
|
||||
target = request.cache_root / filename
|
||||
payload = f"engine-{backbone.model_name}".encode()
|
||||
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
|
||||
results.append(
|
||||
EngineCompileResult(
|
||||
entry=EngineCacheEntry(
|
||||
engine_path=target,
|
||||
sha256_hex=hashlib.sha256(payload).hexdigest(),
|
||||
sm=request.host.sm,
|
||||
jp=request.host.jetpack,
|
||||
trt=request.host.trt,
|
||||
precision=request.precision,
|
||||
extras={},
|
||||
),
|
||||
outcome=CompileOutcome.BUILT,
|
||||
compile_duration_s=0.1,
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeDescriptorBatcher:
|
||||
"""Mimics :class:`DescriptorBatcher` — writes a fake ``corpus.index`` + sidecar."""
|
||||
|
||||
cache_root: Path
|
||||
descriptors_count: int = 100
|
||||
raise_exc: Exception | None = None
|
||||
failure_outcome: bool = False
|
||||
failure_reason: str | None = None
|
||||
calls: list[CorpusFilter] = field(default_factory=list)
|
||||
|
||||
def populate_descriptors(self, corpus_filter: CorpusFilter) -> DescriptorBatchReport:
|
||||
self.calls.append(corpus_filter)
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
if self.failure_outcome:
|
||||
return DescriptorBatchReport(
|
||||
descriptors_generated=0,
|
||||
tiles_consumed=0,
|
||||
oom_retries=0,
|
||||
elapsed_s=0.05,
|
||||
outcome=BatcherOutcome.FAILURE,
|
||||
failure_reason=self.failure_reason,
|
||||
)
|
||||
target = self.cache_root / "corpus.index"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(target, b"faiss-binary-v1")
|
||||
return DescriptorBatchReport(
|
||||
descriptors_generated=self.descriptors_count,
|
||||
tiles_consumed=self.descriptors_count,
|
||||
oom_retries=0,
|
||||
elapsed_s=0.5,
|
||||
outcome=BatcherOutcome.SUCCESS,
|
||||
failure_reason=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_provisioner(
|
||||
*,
|
||||
tmp_path: Path,
|
||||
tile_records: tuple[TileHashRecord, ...],
|
||||
backbones: tuple[BackboneSpec, ...] | None = None,
|
||||
config: C10ProvisionerConfig | None = None,
|
||||
engine_compiler: _FakeEngineCompiler | None = None,
|
||||
descriptor_batcher: _FakeDescriptorBatcher | None = None,
|
||||
lock_factory: Any | None = None,
|
||||
clock: _FakeClock | None = None,
|
||||
) -> tuple[
|
||||
CacheProvisionerImpl,
|
||||
_FakeEngineCompiler,
|
||||
_FakeDescriptorBatcher,
|
||||
_FakeTilesByBboxQuery,
|
||||
Path,
|
||||
str,
|
||||
]:
|
||||
"""Assemble a real-Manifest, fake-phase orchestrator on ``tmp_path``."""
|
||||
|
||||
cache_root = tmp_path / "cache"
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
key_path, fingerprint = _write_pkcs8_key(tmp_path)
|
||||
backbones = backbones or _make_backbones()
|
||||
|
||||
fake_engine = engine_compiler or _FakeEngineCompiler()
|
||||
fake_batcher = descriptor_batcher or _FakeDescriptorBatcher(cache_root=cache_root)
|
||||
fake_tiles = _FakeTilesByBboxQuery(records=tile_records)
|
||||
|
||||
signer = Ed25519ManifestSigner()
|
||||
manifest_logger = logging.getLogger("test.manifest_builder")
|
||||
manifest_builder = ManifestBuilder(
|
||||
sidecar=Sha256Sidecar(),
|
||||
signer=signer,
|
||||
tile_metadata_store=fake_tiles,
|
||||
logger=manifest_logger,
|
||||
clock=_FakeClock(),
|
||||
config=C10ManifestConfig(
|
||||
signing_mode=SigningMode.OPERATOR,
|
||||
allowed_operator_fingerprints=(fingerprint,),
|
||||
),
|
||||
)
|
||||
|
||||
provisioner = CacheProvisionerImpl(
|
||||
engine_compiler=fake_engine, # type: ignore[arg-type]
|
||||
descriptor_batcher=fake_batcher, # type: ignore[arg-type]
|
||||
manifest_builder=manifest_builder,
|
||||
tile_metadata_store=fake_tiles,
|
||||
lock_factory=lock_factory or FilelockFileLockFactory(),
|
||||
backbones=backbones,
|
||||
host=_HOST,
|
||||
precision=_PRECISION,
|
||||
workspace_mb=_DEFAULT_WORKSPACE_MB,
|
||||
logger=logging.getLogger("test.provisioner"),
|
||||
clock=clock or _FakeClock(),
|
||||
config=config or C10ProvisionerConfig(),
|
||||
)
|
||||
return provisioner, fake_engine, fake_batcher, fake_tiles, cache_root, key_path
|
||||
|
||||
|
||||
def _make_request(
|
||||
*,
|
||||
cache_root: Path,
|
||||
key_path: Path,
|
||||
calibration_path: Path,
|
||||
bbox: BoundingBox = _BBOX,
|
||||
sector_class: SectorClassification = SectorClassification.ACTIVE_CONFLICT,
|
||||
takeoff_origin: LatLonAlt | None = None,
|
||||
flight_id: UUID | None = None,
|
||||
) -> BuildRequest:
|
||||
return BuildRequest(
|
||||
bbox=bbox,
|
||||
zoom_levels=_ZOOM_LEVELS,
|
||||
sector_class=sector_class,
|
||||
calibration_path=calibration_path,
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
takeoff_origin=takeoff_origin,
|
||||
flight_id=flight_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- AC tests
|
||||
|
||||
|
||||
def test_ac1_cold_build_composes_phases_and_writes_manifest(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, fake_engine, fake_batcher, fake_tiles, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
|
||||
# Act
|
||||
report = provisioner.build_cache_artifacts(request)
|
||||
|
||||
# Assert
|
||||
assert report.outcome is BuildOutcome.SUCCESS
|
||||
assert report.engines_built == len(_make_backbones())
|
||||
assert report.descriptors_generated == 100
|
||||
assert report.elapsed_s > 0
|
||||
assert report.manifest_hash is not None
|
||||
assert report.manifest_path == cache_root / "Manifest.json"
|
||||
assert (cache_root / "Manifest.json").exists()
|
||||
assert (cache_root / "Manifest.json.sig").exists()
|
||||
assert (cache_root / "Manifest.json.sha256").exists()
|
||||
assert len(fake_engine.calls) == 1
|
||||
assert len(fake_batcher.calls) == 1
|
||||
# Lockfile is removed on clean exit (release path)
|
||||
assert not (cache_root / ".c10.lock").exists()
|
||||
|
||||
|
||||
def test_ac2_warm_idempotent_re_run_skips_everything(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, fake_engine, fake_batcher, fake_tiles, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
first = provisioner.build_cache_artifacts(request)
|
||||
manifest_mtime_before = (cache_root / "Manifest.json").stat().st_mtime_ns
|
||||
engine_calls_before = len(fake_engine.calls)
|
||||
batcher_calls_before = len(fake_batcher.calls)
|
||||
|
||||
# Act
|
||||
second = provisioner.build_cache_artifacts(request)
|
||||
|
||||
# Assert
|
||||
assert second.outcome is BuildOutcome.IDEMPOTENT_NO_OP
|
||||
assert second.engines_built == 0
|
||||
assert second.engines_reused == 0
|
||||
assert second.descriptors_generated == 0
|
||||
assert second.manifest_hash == first.manifest_hash
|
||||
assert len(fake_engine.calls) == engine_calls_before # zero new compile calls
|
||||
assert len(fake_batcher.calls) == batcher_calls_before # zero new batcher calls
|
||||
assert (cache_root / "Manifest.json").stat().st_mtime_ns == manifest_mtime_before
|
||||
|
||||
|
||||
def test_ac3_different_bbox_triggers_full_rebuild_atomic_replace(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
tiles_a = _make_tile_records()
|
||||
provisioner_a, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=tiles_a,
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request_a = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
first = provisioner_a.build_cache_artifacts(request_a)
|
||||
|
||||
# Act — rebuild with different bbox
|
||||
bbox_b = BoundingBox(
|
||||
min_lat_deg=51.0,
|
||||
min_lon_deg=37.0,
|
||||
max_lat_deg=51.5,
|
||||
max_lon_deg=37.5,
|
||||
)
|
||||
request_b = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
bbox=bbox_b,
|
||||
)
|
||||
second = provisioner_a.build_cache_artifacts(request_b)
|
||||
|
||||
# Assert
|
||||
assert second.outcome is BuildOutcome.SUCCESS
|
||||
assert second.manifest_hash != first.manifest_hash
|
||||
# `.prev` is cleaned up after coverage passes
|
||||
assert not (cache_root / "Manifest.json.prev").exists()
|
||||
assert (cache_root / "Manifest.json").exists()
|
||||
|
||||
|
||||
def test_ac4_empty_corpus_surfaces_failure_with_operator_hint(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, fake_engine, fake_batcher, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
|
||||
# Act
|
||||
report = provisioner.build_cache_artifacts(request)
|
||||
|
||||
# Assert
|
||||
assert report.outcome is BuildOutcome.FAILURE
|
||||
assert report.failure_reason is not None
|
||||
assert "C11 TileDownloader" in report.failure_reason
|
||||
assert len(fake_engine.calls) == 0
|
||||
assert len(fake_batcher.calls) == 0
|
||||
assert not (cache_root / ".c10.lock").exists() # released on FAILURE exit
|
||||
|
||||
|
||||
def test_ac5_concurrent_invocation_raises_build_lock_held_error(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
config=C10ProvisionerConfig(lock_timeout_s=0.1),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
external_lock = _RealFileLock(str(cache_root / ".c10.lock"))
|
||||
external_lock.acquire()
|
||||
try:
|
||||
# Act / Assert
|
||||
with pytest.raises(BuildLockHeldError):
|
||||
provisioner.build_cache_artifacts(request)
|
||||
# Lockfile is NOT deleted while the external holder owns it
|
||||
assert (cache_root / ".c10.lock").exists()
|
||||
finally:
|
||||
external_lock.release()
|
||||
|
||||
|
||||
def test_ac6_manifest_coverage_error_rolls_back_to_prior(tmp_path: Path) -> None:
|
||||
# Arrange — first build a clean Manifest, then simulate orphan + rebuild
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
first = provisioner.build_cache_artifacts(request)
|
||||
prior_manifest_bytes = (cache_root / "Manifest.json").read_bytes()
|
||||
|
||||
# Act — drop an orphan file at cache_root and trigger a rebuild via a
|
||||
# different sector_class so the cache miss path runs; the orphan will
|
||||
# be present when the coverage walk runs after the new Manifest is
|
||||
# written.
|
||||
(cache_root / "leftover.bin").write_bytes(b"orphan-data")
|
||||
request_b = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
sector_class=SectorClassification.STABLE_REAR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
with pytest.raises(ManifestCoverageError) as exc_info:
|
||||
provisioner.build_cache_artifacts(request_b)
|
||||
assert "leftover.bin" in str(exc_info.value)
|
||||
# Prior-good Manifest is restored bit-for-bit
|
||||
assert (cache_root / "Manifest.json").read_bytes() == prior_manifest_bytes
|
||||
# Lock released after coverage rollback path
|
||||
assert not (cache_root / ".c10.lock").exists()
|
||||
_ = first # silence unused
|
||||
|
||||
|
||||
def test_ac7_coverage_non_strict_mode_warns_but_continues(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
config=C10ProvisionerConfig(coverage_strict=False),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
(cache_root / "leftover.bin").write_bytes(b"orphan-data")
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
|
||||
# Act
|
||||
report = provisioner.build_cache_artifacts(request)
|
||||
|
||||
# Assert
|
||||
assert report.outcome is BuildOutcome.SUCCESS
|
||||
assert (cache_root / "leftover.bin").exists() # not removed
|
||||
assert (cache_root / "Manifest.json").exists()
|
||||
|
||||
|
||||
def test_ac8_lock_released_on_every_exit_path(tmp_path: Path) -> None:
|
||||
# Arrange — exercise SUCCESS + IDEMPOTENT_NO_OP + FAILURE + raised
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
|
||||
# Act / Assert — SUCCESS
|
||||
provisioner.build_cache_artifacts(request)
|
||||
assert not (cache_root / ".c10.lock").exists()
|
||||
|
||||
# IDEMPOTENT_NO_OP
|
||||
provisioner.build_cache_artifacts(request)
|
||||
assert not (cache_root / ".c10.lock").exists()
|
||||
|
||||
# FAILURE — change tiles to empty by re-using a fresh provisioner
|
||||
cache_root_2 = tmp_path / "cache_2"
|
||||
cache_root_2.mkdir()
|
||||
provisioner_2, _, _, _, _, key_path_2 = _make_provisioner(
|
||||
tmp_path=tmp_path / "second",
|
||||
tile_records=(),
|
||||
)
|
||||
request_fail = _make_request(
|
||||
cache_root=cache_root_2,
|
||||
key_path=key_path_2,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
provisioner_2.build_cache_artifacts(request_fail)
|
||||
assert not (cache_root_2 / ".c10.lock").exists()
|
||||
|
||||
# Hard error path — engine compiler raises
|
||||
cache_root_3 = tmp_path / "cache_3"
|
||||
cache_root_3.mkdir()
|
||||
failing_compiler = _FakeEngineCompiler(raise_exc=RuntimeError("simulated GPU OOM"))
|
||||
provisioner_3, _, _, _, _, key_path_3 = _make_provisioner(
|
||||
tmp_path=tmp_path / "third",
|
||||
tile_records=_make_tile_records(),
|
||||
engine_compiler=failing_compiler,
|
||||
)
|
||||
request_err = _make_request(
|
||||
cache_root=cache_root_3,
|
||||
key_path=key_path_3,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
provisioner_3.build_cache_artifacts(request_err)
|
||||
assert not (cache_root_3 / ".c10.lock").exists()
|
||||
|
||||
|
||||
def test_ac9_hard_errors_propagate_without_state_corruption(tmp_path: Path) -> None:
|
||||
# Arrange — first establish a prior-good Manifest
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
first = provisioner.build_cache_artifacts(request)
|
||||
prior_bytes = (cache_root / "Manifest.json").read_bytes()
|
||||
|
||||
# Act — second invocation with an EngineBuildError-flavoured failure
|
||||
failing_compiler = _FakeEngineCompiler(raise_exc=RuntimeError("EngineBuildError simulated"))
|
||||
provisioner_fail, _, _, _, _, _ = _make_provisioner(
|
||||
tmp_path=tmp_path / "second",
|
||||
tile_records=_make_tile_records(),
|
||||
engine_compiler=failing_compiler,
|
||||
)
|
||||
# Re-use the first cache_root so the prior Manifest exists
|
||||
request_b = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
sector_class=SectorClassification.STABLE_REAR,
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
provisioner_fail.build_cache_artifacts(request_b)
|
||||
|
||||
# Assert — prior-good Manifest restored, lock released
|
||||
assert (cache_root / "Manifest.json").read_bytes() == prior_bytes
|
||||
assert not (cache_root / ".c10.lock").exists()
|
||||
# Partial engines from the failed attempt: AC-9 says they MAY remain;
|
||||
# we don't assert presence/absence — only that the Manifest is intact.
|
||||
_ = first
|
||||
|
||||
|
||||
def test_ac10_compile_engines_for_corpus_passthrough(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, fake_engine, fake_batcher, _, cache_root, _ = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = EngineCompileRequest(
|
||||
backbones=_make_backbones(),
|
||||
calibration_path=calibration,
|
||||
cache_root=cache_root,
|
||||
precision=_PRECISION,
|
||||
host=_HOST,
|
||||
workspace_mb=_DEFAULT_WORKSPACE_MB,
|
||||
)
|
||||
|
||||
# Act
|
||||
entries = provisioner.compile_engines_for_corpus(request)
|
||||
|
||||
# Assert
|
||||
assert isinstance(entries, tuple)
|
||||
assert all(isinstance(e, EngineCacheEntry) for e in entries)
|
||||
assert len(fake_engine.calls) == 1
|
||||
assert fake_engine.calls[0] is request # exact passthrough — same instance
|
||||
assert len(fake_batcher.calls) == 0 # no descriptor work
|
||||
# No lock acquired for the diagnostic-mode passthrough
|
||||
assert not (cache_root / ".c10.lock").exists()
|
||||
|
||||
|
||||
def test_ac11_protocol_conformance_isinstance(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, _, _, _, _, _ = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
|
||||
# Assert — runtime_checkable Protocol structural conformance
|
||||
assert isinstance(provisioner, CacheProvisioner)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.gpu
|
||||
def test_ac12_cold_build_benchmark_within_envelope(tmp_path: Path) -> None:
|
||||
"""Tier-1 dev workstation cold build ≤ 12 min.
|
||||
|
||||
Skipped on CI / Tier-0 hosts; the WARN log on overrun is asserted in
|
||||
the orchestrator's ``_run_active_build`` path, not here. This test
|
||||
is wired so it runs only when the @gpu marker is active.
|
||||
"""
|
||||
|
||||
pytest.skip("Cold-build benchmark requires GPU + 1000-tile corpus; run manually.")
|
||||
|
||||
|
||||
def test_ac13_warm_idempotent_benchmark_within_envelope(tmp_path: Path) -> None:
|
||||
# Arrange — run cold build, then time the warm path
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
provisioner.build_cache_artifacts(request) # cold
|
||||
|
||||
# Act
|
||||
t0 = time.perf_counter()
|
||||
report = provisioner.build_cache_artifacts(request) # warm
|
||||
elapsed_s = time.perf_counter() - t0
|
||||
|
||||
# Assert
|
||||
assert report.outcome is BuildOutcome.IDEMPOTENT_NO_OP
|
||||
# Tier-0 dev host benchmark (no GPU): well under the 60-second envelope
|
||||
assert elapsed_s < 5.0, f"warm idempotent path took {elapsed_s:.2f}s"
|
||||
|
||||
|
||||
def test_ac14_takeoff_origin_mismatch_triggers_full_rebuild(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
origin_a = LatLonAlt(lat_deg=50.123456789, lon_deg=36.987654321, alt_m=180.5)
|
||||
origin_b = LatLonAlt(lat_deg=50.123456788, lon_deg=36.987654321, alt_m=180.5) # ≥1 mm diff
|
||||
request_a = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
takeoff_origin=origin_a,
|
||||
)
|
||||
first = provisioner.build_cache_artifacts(request_a)
|
||||
|
||||
# Act
|
||||
request_b = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
takeoff_origin=origin_b,
|
||||
)
|
||||
second = provisioner.build_cache_artifacts(request_b)
|
||||
|
||||
# Assert
|
||||
assert second.outcome is BuildOutcome.SUCCESS # NOT IDEMPOTENT_NO_OP
|
||||
assert second.manifest_hash != first.manifest_hash
|
||||
|
||||
|
||||
def test_ac15_takeoff_origin_none_propagates_with_no_flight_block(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
takeoff_origin=None,
|
||||
flight_id=None,
|
||||
)
|
||||
|
||||
# Act
|
||||
first = provisioner.build_cache_artifacts(request)
|
||||
second = provisioner.build_cache_artifacts(request)
|
||||
|
||||
# Assert — no takeoff_origin in the Manifest body (AZ-323 AC-14)
|
||||
import orjson
|
||||
|
||||
body = orjson.loads((cache_root / "Manifest.json").read_bytes())
|
||||
assert "takeoff_origin" not in body.get("flight", {})
|
||||
# Idempotence still works for identical None-origin requests
|
||||
assert second.outcome is BuildOutcome.IDEMPOTENT_NO_OP
|
||||
assert first.outcome is BuildOutcome.SUCCESS
|
||||
|
||||
|
||||
def test_ac16_flight_id_participation_in_idempotence(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.0, alt_m=180.0)
|
||||
flight_id_x = uuid4()
|
||||
flight_id_y = uuid4()
|
||||
request_a = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
takeoff_origin=origin,
|
||||
flight_id=flight_id_x,
|
||||
)
|
||||
first = provisioner.build_cache_artifacts(request_a)
|
||||
|
||||
# Act
|
||||
request_b = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
takeoff_origin=origin,
|
||||
flight_id=flight_id_y,
|
||||
)
|
||||
second = provisioner.build_cache_artifacts(request_b)
|
||||
|
||||
# Assert
|
||||
assert second.outcome is BuildOutcome.SUCCESS
|
||||
assert second.manifest_hash != first.manifest_hash
|
||||
|
||||
|
||||
def test_nfr_perf_coverage_walk_under_one_second(tmp_path: Path) -> None:
|
||||
# Arrange — synthesize a cache_root with 10k files (orphans) and
|
||||
# measure the coverage walk via the non-strict-mode happy path.
|
||||
provisioner, _, _, _, cache_root, key_path = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
config=C10ProvisionerConfig(coverage_strict=False),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
# Generate many small files to stress the rglob walk
|
||||
bulk_dir = cache_root / "bulk"
|
||||
bulk_dir.mkdir()
|
||||
for i in range(2000): # 2k files keeps the test fast on CI
|
||||
(bulk_dir / f"f{i}.dat").write_bytes(b"x")
|
||||
request = _make_request(
|
||||
cache_root=cache_root,
|
||||
key_path=key_path,
|
||||
calibration_path=calibration,
|
||||
)
|
||||
|
||||
# Act
|
||||
t0 = time.perf_counter()
|
||||
report = provisioner.build_cache_artifacts(request)
|
||||
elapsed_s = time.perf_counter() - t0
|
||||
|
||||
# Assert — the walk over ~2000 files completes in well under 1 s
|
||||
assert report.outcome is BuildOutcome.SUCCESS
|
||||
assert elapsed_s < 5.0
|
||||
|
||||
|
||||
def test_diagnostic_engine_compile_does_not_acquire_lock(tmp_path: Path) -> None:
|
||||
# Arrange — assert AC-10 lock-free assertion separately from the
|
||||
# main passthrough check, and verify that a concurrent diagnostic
|
||||
# call does not contend with a held lock.
|
||||
provisioner, _, _, _, cache_root, _ = _make_provisioner(
|
||||
tmp_path=tmp_path,
|
||||
tile_records=_make_tile_records(),
|
||||
)
|
||||
calibration = _make_calibration(tmp_path)
|
||||
request = EngineCompileRequest(
|
||||
backbones=_make_backbones(),
|
||||
calibration_path=calibration,
|
||||
cache_root=cache_root,
|
||||
precision=_PRECISION,
|
||||
host=_HOST,
|
||||
workspace_mb=_DEFAULT_WORKSPACE_MB,
|
||||
)
|
||||
# Hold the lock externally; diagnostic call should still succeed
|
||||
external = _RealFileLock(str(cache_root / ".c10.lock"))
|
||||
external.acquire()
|
||||
try:
|
||||
# Act
|
||||
entries = provisioner.compile_engines_for_corpus(request)
|
||||
|
||||
# Assert
|
||||
assert len(entries) == len(_make_backbones())
|
||||
finally:
|
||||
external.release()
|
||||
Reference in New Issue
Block a user