From 86b412dd37450f27980f6f9a0348c52f55fb007e Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Sat, 29 Aug 2026 13:05:57 -0400 Subject: [PATCH] fix(vessels): protect Hormuz VesselAPI rows from AISStream eviction AISStream's live US-coast feed crowds vessel_last_known past _MAX_VESSELS (6000) and the 'evict oldest seen_at' pass reaps the Hormuz VesselAPI rows, which are only refreshed every VESSELAPI_INTERVAL (~4.8h). Result: the poller was fetching 50 Hormuz vessels per 2xx but /api/vessels?bbox=Hormuz stayed empty. Eviction now skips src=vesselapi rows (falls back to evictable-only, clamped) so the blind-spot box stays resident between polls. --- app/live_layers.py | 12 +++++++++++- tests/test_vesselapi.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/live_layers.py b/app/live_layers.py index eebf6c1..7022387 100644 --- a/app/live_layers.py +++ b/app/live_layers.py @@ -993,8 +993,18 @@ async def upsert_vessel(marker: dict) -> None: vessel_last_known[vid] = stored if len(vessel_last_known) > _MAX_VESSELS: excess = len(vessel_last_known) - int(_MAX_VESSELS * 0.9) + # AISStream's live US-coast feed can crowd the shared store past + # _MAX_VESSELS and evict the Hormuz VesselAPI rows (refreshed only + # every VESSELAPI_INTERVAL) as "oldest". Keep src=vesselapi rows + # resident so the blind-spot box stays visible between polls. + evictable = [ + k for k, v in vessel_last_known.items() + if (v.get("extra") or {}).get("src") != "vesselapi" + ] + if len(evictable) < excess: + excess = len(evictable) oldest = sorted( - vessel_last_known, + evictable, key=lambda k: vessel_last_known[k].get("seen_at", ""), )[:excess] for k in oldest: diff --git a/tests/test_vesselapi.py b/tests/test_vesselapi.py index 292bed4..f7ec9c1 100644 --- a/tests/test_vesselapi.py +++ b/tests/test_vesselapi.py @@ -298,6 +298,40 @@ def _make_get_client(client): return _get_client +def test_eviction_keeps_vesselapi_rows(monkeypatch): + """AISStream crowding past _MAX_VESSELS must not reap Hormuz VesselAPI rows.""" + from ws_manager import manager + from live_layers import _MAX_VESSELS, upsert_vessel + + _patch_side_effects(monkeypatch) + monkeypatch.setattr("live_layers._MAX_VESSELS", 10) + vessel_last_known.clear() + manager._queues.clear() + manager._viewports.clear() + + # One Hormuz VesselAPI row with the oldest seen_at — the first thing the + # old "evict oldest" logic would reap — plus enough AISStream rows to + # exceed the cap. + vessel_last_known["422050100"] = { + "id": "422050100", "lat": 26.5, "lon": 56.3, "label": "HORMUZ STAR", + "extra": {"src": "vesselapi"}, "seen_at": "2026-08-29T00:00:00+00:00", + } + for i in range(10): + vid = f"3{i:08d}" + vessel_last_known[vid] = { + "id": vid, "lat": 35.0 + i * 0.01, "lon": -79.0, "label": vid, + "extra": {"src": "aisstream"}, + "seen_at": f"2026-08-29T0{i}:00:00+00:00", + } + + # One more AISStream marker pushes past the cap and triggers eviction. + asyncio.run(upsert_vessel({"id": "399999999", "lat": 36.0, "lon": -78.0, "label": "NEW"})) + + assert "422050100" in vessel_last_known # VesselAPI row survives + assert vessel_last_known["422050100"]["extra"]["src"] == "vesselapi" + assert len(vessel_last_known) <= 10 + + # ── Independent-provider idle behaviour ─────────────────────────────────── # VesselAPI must never be gated on AISStream (or vice versa): a missing key on # one provider leaves the other running. ``_StopLoop`` is a BaseException so -- 2.45.3