diff --git a/backend/main.py b/backend/main.py index 623bf475..ce0add12 100644 --- a/backend/main.py +++ b/backend/main.py @@ -156,7 +156,15 @@ async def lifespan(app: FastAPI): from services.region_lookup import warm_borders - await run_in_threadpool(warm_borders) + try: + await run_in_threadpool(warm_borders) + except Exception: + # Same policy as prime_pipeline_at_startup above: a missing or corrupt + # borders file costs /api/towers its region detection until the next + # restart (classify_region still loads on demand and surfaces the real + # error there); raising would abort the lifespan and take the whole + # API down with it. + logging.exception("Warming the border polygons failed; region lookup will load on demand") # Restore persisted state before accepting connections restored = restore_snapshot() diff --git a/backend/services/region_lookup.py b/backend/services/region_lookup.py index ed43c124..9a188a8b 100644 --- a/backend/services/region_lookup.py +++ b/backend/services/region_lookup.py @@ -42,11 +42,18 @@ def _load_borders() -> None: return with open(_BORDERS_PATH) as f: data = json.load(f) + loaded: dict[str, BaseGeometry] = {} for feature in data["features"]: admin = feature["properties"].get("ADMIN") source = _ADMIN_TO_SOURCE.get(admin) if source is not None: - _geoms[source] = shape(feature["geometry"]) + loaded[source] = shape(feature["geometry"]) + # Published complete or not at all: the fast path above reads _geoms + # without the lock, so filling it feature-by-feature would let a + # concurrent caller see {"us"} mid-parse, skip the load, and silently + # misclassify a Canadian point as unsupported. dict.update runs under + # one GIL hold, so readers see the border set whole or empty. + _geoms.update(loaded) def warm_borders() -> None: diff --git a/backend/tests/test_adsb_seed_backend.py b/backend/tests/test_adsb_seed_backend.py index 724bc153..d0c5dd39 100644 --- a/backend/tests/test_adsb_seed_backend.py +++ b/backend/tests/test_adsb_seed_backend.py @@ -241,6 +241,10 @@ def _assert_derived(hexn: str, raw: dict) -> None: ("ground", {"alt_baro": "ground", "gs": None, "track": "unknown"}), ] +# The sim push endpoint rejects non-transponder hexes (id_utils.is_transponder_hex), +# so its writer fixtures must look like real 24-bit addresses, not readable labels. +_SIM_CASE_HEX = {name: f"a1b2c{i}" for i, (name, _kin) in enumerate(_WRITE_CASES)} + class TestDerivedFieldsAtWriteTime: """Every path that writes state.adsb_aircraft stores the SI-unit fields @@ -284,7 +288,7 @@ async def test_sim_ingest_writer(self, name, kin): # what is under test, not the gate in front of it. from routes.sim_ingest import sim_push_adsb_positions - hexn = f"sim{name}" + hexn = _SIM_CASE_HEX[name] entry = {"hex": hexn, "lat": 33.9, "lon": -84.6, **kin} await sim_push_adsb_positions(body={"ts_ms": 4242, "aircraft": [entry]}, _key=None) diff --git a/backend/tests/test_region_lookup_warm.py b/backend/tests/test_region_lookup_warm.py index 9aa8e207..17a42e08 100644 --- a/backend/tests/test_region_lookup_warm.py +++ b/backend/tests/test_region_lookup_warm.py @@ -59,9 +59,7 @@ def test_a_warm_cache_is_never_re_parsed(self): _clear_cache() region_lookup.warm_borders() - with unittest.mock.patch.object( - region_lookup, "shape", side_effect=AssertionError("re-parsed after warm-up") - ): + with unittest.mock.patch.object(region_lookup, "shape", side_effect=AssertionError("re-parsed after warm-up")): assert region_lookup.classify_region(42.38708028093612, -71.24905416622781) == "us" assert region_lookup.classify_region(48.8566, 2.3522) is None