mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
Fix event-loop starvation, MeshCore stability, config-page hardening
- mesh_data_store.py / env/store.py: make refresh() async, offload blocking polls via asyncio.to_thread/gather so 7 lockstep sources no longer starve the shared event loop. - main.py: gather pollers concurrently + set_default_executor thread pool. - Dockerfile / docker-compose.yml: healthcheck now curls the dashboard for a real liveness signal instead of a process-exists check. - transport/meshcore_transport.py: MeshCore keepalive loop (get_time() every 120s), reconnect re-arm (_post_reconnect_setup_async from _on_connect_event), and MC channel-name normalization (_resolve_mc_channel_idx strips a leading #). - dashboard-frontend: MeshCoreConnection.tsx config-page hardening, new ErrorBoundary component, wired into App.tsx. - tests: fix ~40 call sites broken by refresh() becoming async ( test_generic_http.py, test_store_received_delta.py, test_store_wzdx_persist.py) by wrapping with asyncio.run(), matching this suite's existing convention for calling async code from sync test functions. Verified: all 40 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c281c96e2
commit
c5aa0e1f42
12 changed files with 462 additions and 129 deletions
|
|
@ -8,6 +8,7 @@ Ported-behavior coverage:
|
|||
* geometry-path Point -> centroid extraction
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
import json
|
||||
|
||||
|
|
@ -203,7 +204,7 @@ def test_cold_start_silent_first_poll_seeds_persists_no_emit():
|
|||
# Stub the network fetch with one active outage.
|
||||
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
|
||||
|
||||
store.refresh() # poll 1 == pre-existing backlog
|
||||
asyncio.run(store.refresh()) # poll 1 == pre-existing backlog
|
||||
|
||||
# Nothing broadcast on the cold-start poll...
|
||||
assert captured == [], "first poll must broadcast NOTHING (cold-start seed)"
|
||||
|
|
@ -220,14 +221,14 @@ def test_cold_start_silent_first_poll_seeds_persists_no_emit():
|
|||
def test_later_poll_broadcasts_newly_received_item():
|
||||
store, adapter, captured = _make_store_with_generic()
|
||||
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
|
||||
store.refresh() # poll 1 — seed silently
|
||||
asyncio.run(store.refresh()) # poll 1 — seed silently
|
||||
assert captured == []
|
||||
|
||||
# A genuinely NEW outage appears on a later poll -> it must broadcast.
|
||||
new_item = dict(IDAHO_POWER_ITEM, omsOutageId="456", omsCustomerCount=99)
|
||||
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM, new_item])
|
||||
adapter._last_poll.clear() # force cadence to elapse
|
||||
store.refresh() # poll 2
|
||||
asyncio.run(store.refresh()) # poll 2
|
||||
|
||||
assert len(captured) == 1, "only the newly-received outage broadcasts"
|
||||
assert captured[0].category == "power_outage"
|
||||
|
|
@ -367,7 +368,7 @@ def test_build_generic_detail_reader():
|
|||
from meshai.notifications.env_reporter import EnvReporter
|
||||
store, adapter, captured = _make_store_with_generic()
|
||||
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
|
||||
text = EnvReporter().build_generic_detail()
|
||||
assert "idaho_power" in text
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ These tests drive the real EnvironmentalStore + EventBus with a fake adapter
|
|||
whose per-poll batch we control, and assert exactly which events reach the bus.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
from meshai.env.store import EnvironmentalStore, _key_ext
|
||||
from meshai.config import EnvironmentalConfig
|
||||
|
|
@ -79,7 +80,7 @@ def test_first_poll_seeds_and_broadcasts_nothing():
|
|||
store, adapter, captured = _make_store()
|
||||
adapter.set_batch(["A", "B", "C"])
|
||||
|
||||
store.refresh() # poll 1 — the backlog
|
||||
asyncio.run(store.refresh()) # poll 1 — the backlog
|
||||
|
||||
assert captured == [], "first poll must broadcast NOTHING (backlog seed)"
|
||||
|
||||
|
|
@ -88,11 +89,11 @@ def test_second_poll_emits_only_newly_received():
|
|||
store, adapter, captured = _make_store()
|
||||
|
||||
adapter.set_batch(["A", "B", "C"])
|
||||
store.refresh() # poll 1: seed
|
||||
asyncio.run(store.refresh()) # poll 1: seed
|
||||
assert _emitted_ids(captured) == []
|
||||
|
||||
adapter.set_batch(["A", "B", "C", "D"])
|
||||
store.refresh() # poll 2: only D is new
|
||||
asyncio.run(store.refresh()) # poll 2: only D is new
|
||||
assert _emitted_ids(captured) == ["D"]
|
||||
|
||||
|
||||
|
|
@ -100,11 +101,11 @@ def test_unchanged_poll_emits_nothing():
|
|||
store, adapter, captured = _make_store()
|
||||
|
||||
adapter.set_batch(["A", "B", "C"])
|
||||
store.refresh() # poll 1: seed
|
||||
asyncio.run(store.refresh()) # poll 1: seed
|
||||
adapter.set_batch(["A", "B", "C", "D"])
|
||||
store.refresh() # poll 2: D
|
||||
asyncio.run(store.refresh()) # poll 2: D
|
||||
adapter.set_batch(["A", "B", "C", "D"])
|
||||
store.refresh() # poll 3: nothing new
|
||||
asyncio.run(store.refresh()) # poll 3: nothing new
|
||||
|
||||
assert _emitted_ids(captured) == ["D"], "poll 3 has no new items"
|
||||
|
||||
|
|
@ -113,21 +114,21 @@ def test_restart_reseeds_and_never_rebroadcasts_backlog():
|
|||
# Process 1 sees A,B,C,D and broadcasts D.
|
||||
store1, adapter1, cap1 = _make_store()
|
||||
adapter1.set_batch(["A", "B", "C"])
|
||||
store1.refresh()
|
||||
asyncio.run(store1.refresh())
|
||||
adapter1.set_batch(["A", "B", "C", "D"])
|
||||
store1.refresh()
|
||||
asyncio.run(store1.refresh())
|
||||
assert _emitted_ids(cap1) == ["D"]
|
||||
|
||||
# RESTART: a fresh store has an empty seen-set. The SAME backlog [A,B,C,D]
|
||||
# arriving on its first poll must be re-seeded silently, not re-broadcast.
|
||||
store2, adapter2, cap2 = _make_store()
|
||||
adapter2.set_batch(["A", "B", "C", "D"])
|
||||
store2.refresh()
|
||||
asyncio.run(store2.refresh())
|
||||
assert cap2 == [], "restart must NEVER re-broadcast the existing backlog"
|
||||
|
||||
# And a genuinely new item after the restart still broadcasts once.
|
||||
adapter2.set_batch(["A", "B", "C", "D", "E"])
|
||||
store2.refresh()
|
||||
asyncio.run(store2.refresh())
|
||||
assert _emitted_ids(cap2) == ["E"]
|
||||
|
||||
|
||||
|
|
@ -135,9 +136,9 @@ def test_stable_key_prevents_reemit_when_batch_reorders():
|
|||
# The same real-world items in a different order are NOT "newly received".
|
||||
store, adapter, captured = _make_store()
|
||||
adapter.set_batch(["A", "B", "C"])
|
||||
store.refresh() # seed
|
||||
asyncio.run(store.refresh()) # seed
|
||||
adapter.set_batch(["C", "A", "B"]) # reordered, same items
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert captured == [], "reordering the same items emits nothing"
|
||||
|
||||
|
||||
|
|
@ -147,12 +148,12 @@ def test_disabled_for_days_then_backlog_is_not_broadcast():
|
|||
store, adapter, captured = _make_store()
|
||||
backlog = [f"evt{i}" for i in range(200)]
|
||||
adapter.set_batch(backlog)
|
||||
store.refresh() # first poll after re-enable
|
||||
asyncio.run(store.refresh()) # first poll after re-enable
|
||||
assert captured == [], "a days-old backlog is seeded silently, never sent"
|
||||
|
||||
# Only a truly new arrival afterward is announced.
|
||||
adapter.set_batch(backlog + ["fresh"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert _emitted_ids(captured) == ["fresh"]
|
||||
|
||||
|
||||
|
|
@ -290,11 +291,11 @@ def test_persistent_preseed_known_suppressed_new_emitted():
|
|||
assert len(store._seen["wzdx"]) == 5
|
||||
|
||||
adapter.set_batch(known)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert captured == [], "all 5 are durably-known → zero broadcast"
|
||||
|
||||
adapter.set_batch(known + ["z_new"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert _emitted_ids(captured) == ["z_new"], "only the not-in-table id broadcasts"
|
||||
|
||||
|
||||
|
|
@ -307,9 +308,9 @@ def test_persistent_preseed_cross_tick_staging_no_leak():
|
|||
store, captured = _build_store(_GENERIC_NAME, adapter)
|
||||
|
||||
adapter.set_batch(["A"])
|
||||
store.refresh() # tick 1: only A present
|
||||
asyncio.run(store.refresh()) # tick 1: only A present
|
||||
adapter.set_batch(["A", "B"])
|
||||
store.refresh() # tick 2: B appears (backlog)
|
||||
asyncio.run(store.refresh()) # tick 2: B appears (backlog)
|
||||
assert captured == [], "B is durably-known — must NOT leak on a later tick"
|
||||
|
||||
# CONTROL: identical staging but NO durable rows → B leaks (proves the
|
||||
|
|
@ -325,11 +326,11 @@ def test_persistent_preseed_cross_tick_staging_no_leak():
|
|||
# Re-point ctrl events to a fresh source with no durable rows.
|
||||
for e in ctrl._batch:
|
||||
e["source"] = "wzdx_ctrl"
|
||||
store2.refresh()
|
||||
asyncio.run(store2.refresh())
|
||||
ctrl.set_batch(["A", "B"])
|
||||
for e in ctrl._batch:
|
||||
e["source"] = "wzdx_ctrl"
|
||||
store2.refresh()
|
||||
asyncio.run(store2.refresh())
|
||||
assert [e.title for e in cap2] == ["B"], "without a durable record, B leaks"
|
||||
|
||||
|
||||
|
|
@ -343,11 +344,11 @@ def test_incremental_empty_first_tick_then_only_new_broadcasts():
|
|||
store, captured = _build_store(_GENERIC_NAME, adapter)
|
||||
|
||||
adapter.set_batch([]) # empty first tick
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert captured == [], "empty tick emits nothing"
|
||||
|
||||
adapter.set_batch(["A", "B", "C"]) # backlog A,B + new C
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert _emitted_ids(captured) == ["C"], "only the never-received C broadcasts"
|
||||
|
||||
|
||||
|
|
@ -360,18 +361,18 @@ def test_restart_against_same_persistent_db_never_rebroadcasts():
|
|||
a1 = _FakeWZDx()
|
||||
store1, cap1 = _build_store(_GENERIC_NAME, a1)
|
||||
a1.set_batch(backlog)
|
||||
store1.refresh()
|
||||
asyncio.run(store1.refresh())
|
||||
assert cap1 == [], "process 1: durable backlog is silent"
|
||||
|
||||
# RESTART: brand-new store, same persistent DB → pre-seed reloads.
|
||||
a2 = _FakeWZDx()
|
||||
store2, cap2 = _build_store(_GENERIC_NAME, a2)
|
||||
a2.set_batch(backlog)
|
||||
store2.refresh()
|
||||
asyncio.run(store2.refresh())
|
||||
assert cap2 == [], "restart must NEVER re-broadcast the durable backlog"
|
||||
|
||||
a2.set_batch(backlog + ["E"])
|
||||
store2.refresh()
|
||||
asyncio.run(store2.refresh())
|
||||
assert _emitted_ids(cap2) == ["E"], "a genuinely-new item still broadcasts once"
|
||||
|
||||
|
||||
|
|
@ -385,11 +386,11 @@ def test_persistent_preseed_quake_by_event_id():
|
|||
assert len(store._seen["usgs_quake"]) == 2
|
||||
|
||||
adapter.set_batch(["us1000aaaa", "us1000bbbb"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert captured == [], "both quakes already received → zero broadcast"
|
||||
|
||||
adapter.set_batch(["us1000aaaa", "us1000bbbb", "us1000cccc"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert _emitted_ids(captured) == ["us1000cccc"], "only the new quake broadcasts"
|
||||
|
||||
|
||||
|
|
@ -402,11 +403,11 @@ def test_no_durable_rows_falls_back_to_silent_first_poll():
|
|||
assert "wzdx" not in store._seeded, "0 durable rows → not pre-marked seeded"
|
||||
|
||||
adapter.set_batch(["A", "B"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert captured == [], "first non-empty poll on a fresh DB is silent"
|
||||
|
||||
adapter.set_batch(["A", "B", "C"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert _emitted_ids(captured) == ["C"]
|
||||
|
||||
|
||||
|
|
@ -486,9 +487,9 @@ def test_persistent_preseed_roads511_by_external_id():
|
|||
assert len(store._seen["511"]) == 4
|
||||
|
||||
adapter.set_batch(known)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert captured == [], "all 4 durably-known 511 rows → zero broadcast"
|
||||
|
||||
adapter.set_batch(known + ["511_99"])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert _emitted_ids(captured) == ["511_99"], "only the not-in-table id broadcasts"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ adapter whose per-poll coalesced set we control, then assert directly against
|
|||
traffic_events AND against the bus (nothing must ever be dispatched).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
from meshai.env.store import EnvironmentalStore
|
||||
from meshai.config import EnvironmentalConfig
|
||||
|
|
@ -147,7 +148,7 @@ def test_first_poll_persists_current_set_and_broadcasts_nothing():
|
|||
store, captured = _build_store(adapter)
|
||||
adapter.set_zones(ZONES3)
|
||||
|
||||
store.refresh() # first (cold-start) poll
|
||||
asyncio.run(store.refresh()) # first (cold-start) poll
|
||||
|
||||
rows = _wzdx_rows()
|
||||
exts = {r["external_id"] for r in rows}
|
||||
|
|
@ -171,7 +172,7 @@ def test_columns_match_summary_and_dm_queries():
|
|||
adapter = _FakeWZDx()
|
||||
store, _ = _build_store(adapter)
|
||||
adapter.set_zones([ZONES3[1]]) # the full_closure I-84 zone
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
|
||||
r = _wzdx_rows()[0]
|
||||
assert r["road"] == "I-84"
|
||||
|
|
@ -188,12 +189,12 @@ def test_subsequent_poll_reconciles_removed_zone():
|
|||
adapter = _FakeWZDx()
|
||||
store, captured = _build_store(adapter)
|
||||
adapter.set_zones(ZONES3)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert len(_wzdx_rows()) == 3
|
||||
|
||||
# Next poll: US-20 dropped out; I-84 + ID-55 remain.
|
||||
adapter.set_zones([ZONES3[1], ZONES3[2]])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
|
||||
exts = {r["external_id"] for r in _wzdx_rows()}
|
||||
assert exts == {ZONES3[1]["ext"], ZONES3[2]["ext"]}, (
|
||||
|
|
@ -207,11 +208,11 @@ def test_empty_or_failed_fetch_does_not_wipe_existing_rows():
|
|||
adapter = _FakeWZDx()
|
||||
store, _ = _build_store(adapter)
|
||||
adapter.set_zones(ZONES3)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert len(_wzdx_rows()) == 3
|
||||
|
||||
adapter.set_raw([]) # empty/failed poll
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert len(_wzdx_rows()) == 3, (
|
||||
"an empty fetch must NEVER wipe the existing active set")
|
||||
|
||||
|
|
@ -225,7 +226,7 @@ def test_upsert_preserves_first_seen_at_and_refreshes_end_at():
|
|||
|
||||
z = dict(ZONES3[0]); z["end_at"] = 1000
|
||||
adapter.set_zones([z])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
r1 = _wzdx_rows()[0]
|
||||
first_seen = r1["first_seen_at"]
|
||||
assert r1["end_at"] == 1000
|
||||
|
|
@ -233,7 +234,7 @@ def test_upsert_preserves_first_seen_at_and_refreshes_end_at():
|
|||
# Same zone reappears with a LATER end_at.
|
||||
z2 = dict(ZONES3[0]); z2["end_at"] = 5000
|
||||
adapter.set_zones([z2])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
r2 = _wzdx_rows()[0]
|
||||
assert r2["first_seen_at"] == first_seen, "first_seen_at must be preserved"
|
||||
assert r2["end_at"] == 5000, "end_at must refresh from the feed"
|
||||
|
|
@ -254,7 +255,7 @@ def test_expiry_end_at_preserved_for_not_expired_filter():
|
|||
"sub_type": "x", "impact": "partial", "end_at": now - 10_000}, # expired
|
||||
]
|
||||
adapter.set_zones(zones)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
|
||||
# All 3 persisted (ingest does not itself drop expired rows) ...
|
||||
assert len(_wzdx_rows()) == 3
|
||||
|
|
@ -269,7 +270,7 @@ def test_id_less_zone_is_skipped_not_fatal():
|
|||
store, _ = _build_store(adapter)
|
||||
good = ZONES3[0]
|
||||
adapter.set_zones([good])
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
assert len(_wzdx_rows()) == 1
|
||||
|
||||
# Poll with the good zone plus an id-less junk event.
|
||||
|
|
@ -277,7 +278,7 @@ def test_id_less_zone_is_skipped_not_fatal():
|
|||
junk = {"source": "wzdx", "event_id": None, "external_id": None,
|
||||
"lat": 5.0, "lon": 5.0, "normalized": {}, "fetched_at": 0}
|
||||
adapter._batch.append(junk)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
|
||||
rows = _wzdx_rows()
|
||||
assert {r["external_id"] for r in rows} == {good["ext"]}, (
|
||||
|
|
@ -296,7 +297,7 @@ def test_bulk_current_set_persists_all_like_the_real_127():
|
|||
for i in range(127)
|
||||
]
|
||||
adapter.set_zones(zones)
|
||||
store.refresh()
|
||||
asyncio.run(store.refresh())
|
||||
|
||||
assert len(_wzdx_rows()) == 127
|
||||
assert _summary_visible_count(now=0) == 127, (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue