Merge pull request 'fix(vessels): protect Hormuz VesselAPI rows from AISStream eviction' (#25) from fix/vesselapi-hormuz-eviction into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 3m7s

This commit is contained in:
sirius 2026-08-29 13:04:28 -04:00
commit b6da7b7bd4
2 changed files with 45 additions and 1 deletions

View file

@ -993,8 +993,18 @@ async def upsert_vessel(marker: dict) -> None:
vessel_last_known[vid] = stored vessel_last_known[vid] = stored
if len(vessel_last_known) > _MAX_VESSELS: if len(vessel_last_known) > _MAX_VESSELS:
excess = len(vessel_last_known) - int(_MAX_VESSELS * 0.9) 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( oldest = sorted(
vessel_last_known, evictable,
key=lambda k: vessel_last_known[k].get("seen_at", ""), key=lambda k: vessel_last_known[k].get("seen_at", ""),
)[:excess] )[:excess]
for k in oldest: for k in oldest:

View file

@ -298,6 +298,40 @@ def _make_get_client(client):
return _get_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 ─────────────────────────────────── # ── Independent-provider idle behaviour ───────────────────────────────────
# VesselAPI must never be gated on AISStream (or vice versa): a missing key on # 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 # one provider leaves the other running. ``_StopLoop`` is a BaseException so