meshai/work/tests/test_native_fire_pacer.py

201 lines
7.4 KiB
Python
Raw Permalink Normal View History

fix(fires): wire FirePacer into the native fire broadcast path (#123) Native fire adapters (env/fires.py source="nifc", env/firms.py source="firms") emitted straight to the EventBus from EnvironmentalStore._emit_event with no rate limiting of their own. FirePacer was only ever attached to CentralConsumer (main.py), which never runs in the actual production deployment (central.enabled=False, all adapters feed_source=native) -- so the <=1/60s throttle + immediate head-of-line behavior fixed for Central in #120 (issue #119) was completely inert in production. A poll that produces several distinct fires/clusters at once (a lightning outbreak, or several tracked fires crossing a satellite-pass boundary together) would dump all of them on the mesh back-to-back instead of at the intended cadence. _emit_event() now routes fire-family Events (source in {"nifc","firms"}, severity in {"priority","immediate"}) through an attached FirePacer, mirroring the exact gate CentralConsumer._handle applies. main.py attaches the same FirePacer instance to env_store right after constructing it. "routine"-severity fire events, non-fire native adapters, and the Central path are all unaffected; a paced event cannot re-enter either gate (native vs Central are mutually exclusive per feed_source), so nothing can be paced twice. Added tests/test_native_fire_pacer.py covering: native fire events route through the pacer, an immediate event jumps an already-queued priority queue with nothing dropped, routine-severity fire events and non-fire native events are NOT paced, and the no-pacer-attached fallback is unchanged. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:46:13 -06:00
"""Native fire-family events must be paced too, not just Central ones.
FirePacer (notifications/pipeline/pacer.py) was wired ONLY into the Central
NATS consumer path (central/consumer.py._handle routes fire-family events to
`self._pacer.enqueue(event)` instead of `self._bus.emit(event)` -- see issue
#119). In an all-native deployment (central.enabled=False, the actual
production configuration on CT 108) that consumer never receives any NATS
message, so the pacer sat completely unused: every native fire adapter
(env/fires.py, source="nifc"; env/firms.py, source="firms") emitted straight
to the EventBus from EnvironmentalStore._emit_event with no rate limiting at
all. A FIRMS poll (or a WFIGS poll) that produces several distinct
fires/clusters at once -- a lightning outbreak forming multiple new-fire
clusters, or several already-tracked fires crossing a satellite-pass boundary
in the same fetch -- would dump all of them onto the mesh essentially
back-to-back instead of at the intended <=1/60s cadence.
These tests exercise the fix: store._emit_event() now routes fire-family
Events (source in {"nifc", "firms"}, severity in {"priority", "immediate"})
through an attached FirePacer, mirroring the exact gate CentralConsumer._handle
applies, and leaves everything else (other native adapters, "routine"-severity
fire events, and the case where no pacer is attached at all) unchanged.
"""
from __future__ import annotations
import asyncio
import pytest
from meshai.config import EnvironmentalConfig
from meshai.env.store import EnvironmentalStore
from meshai.notifications.cutover import _clear_cache
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.bus import EventBus
from meshai.notifications.pipeline.pacer import FirePacer
@pytest.fixture(autouse=True)
def _no_cutover(monkeypatch):
"""None of the categories used below are meant to hit the real gating
deciders (this file is only exercising the pacer-routing gate); clearing
cutover keeps store._emit_event's decider hook a no-op regardless of
what earlier tests left in the environment / lru_cache."""
monkeypatch.delenv("MESHAI_CUTOVER_CATEGORIES", raising=False)
_clear_cache()
yield
_clear_cache()
class _StubAdapter:
"""Minimal adapter stand-in: to_event() always returns the same
caller-controlled Event, independent of the raw dict passed in."""
def __init__(self, source: str, severity: str, category: str):
self._source = source
self._severity = severity
self._category = category
def to_event(self, raw_evt: dict):
eid = raw_evt["event_id"]
return make_event(
source=self._source,
category=self._category,
severity=self._severity,
title=eid,
summary=eid,
group_key=eid,
)
def _make_store():
bus = EventBus()
captured: list = []
bus.subscribe(lambda e: captured.append(e))
store = EnvironmentalStore(EnvironmentalConfig(), event_bus=bus)
return store, bus, captured
class _FakePacer:
"""Records enqueue() calls without any real draining."""
def __init__(self):
self.calls: list = []
def enqueue(self, event) -> None:
self.calls.append(event)
def test_native_firms_immediate_event_routes_through_pacer():
"""source=firms, severity=immediate (a FIRMS growth/spotting broadcast)
with a pacer attached must be enqueued on the pacer, and must NOT also
reach the bus directly (no double-delivery)."""
store, bus, captured = _make_store()
pacer = _FakePacer()
store._fire_pacer = pacer
adapter = _StubAdapter(source="firms", severity="immediate",
category="wildfire_growth")
store._emit_event(adapter, {"event_id": "g1"})
assert len(pacer.calls) == 1
assert pacer.calls[0].source == "firms"
assert pacer.calls[0].severity == "immediate"
assert captured == [], "must not ALSO be emitted straight to the bus"
def test_native_nifc_priority_event_routes_through_pacer():
"""source=nifc (WFIGS incident), severity=priority is also paced."""
store, bus, captured = _make_store()
pacer = _FakePacer()
store._fire_pacer = pacer
adapter = _StubAdapter(source="nifc", severity="priority",
category="unattributed_hotspot_cluster")
store._emit_event(adapter, {"event_id": "n1"})
assert len(pacer.calls) == 1
assert pacer.calls[0].source == "nifc"
assert captured == []
def test_native_fire_routine_severity_not_paced():
"""A fire-family event at "routine" severity (e.g. a FIRMS halt) is
excluded from pacing -- exactly like the Central-path gate, which only
covers priority/immediate."""
store, bus, captured = _make_store()
pacer = _FakePacer()
store._fire_pacer = pacer
adapter = _StubAdapter(source="firms", severity="routine",
category="wildfire_halted")
store._emit_event(adapter, {"event_id": "h1"})
assert pacer.calls == []
assert len(captured) == 1
assert captured[0].severity == "routine"
def test_non_fire_native_event_never_paced():
"""A non-fire native adapter (e.g. roads511) at immediate severity must
go straight to the bus even with a pacer attached -- pacing is
fire-family-only, keyed on event.source."""
store, bus, captured = _make_store()
pacer = _FakePacer()
store._fire_pacer = pacer
adapter = _StubAdapter(source="roads511", severity="immediate",
category="road_closure")
store._emit_event(adapter, {"event_id": "r1"})
assert pacer.calls == []
assert len(captured) == 1
assert captured[0].source == "roads511"
def test_no_pacer_attached_falls_back_to_direct_emit():
"""Pre-existing behavior is unaffected when no pacer is attached (e.g.
notifications disabled, or the brief startup window in main.py before
the pacer is constructed and wired in)."""
store, bus, captured = _make_store()
assert store._fire_pacer is None
adapter = _StubAdapter(source="firms", severity="immediate",
category="wildfire_growth")
store._emit_event(adapter, {"event_id": "g1"})
assert len(captured) == 1
assert captured[0].source == "firms"
def test_native_immediate_event_jumps_ahead_of_already_queued_priority_events():
"""End-to-end through a REAL FirePacer: two native "priority" fires
queued first must not block a later native "immediate" one (head-of-line,
issue #119's fix -- now reachable from the native ingest path), and
nothing is ever dropped."""
store, bus, emitted = _make_store()
pacer = FirePacer(bus=bus, interval_seconds=0.01)
store._fire_pacer = pacer
p1 = _StubAdapter(source="nifc", severity="priority",
category="unattributed_hotspot_cluster")
p2 = _StubAdapter(source="nifc", severity="priority",
category="unattributed_hotspot_cluster")
imm = _StubAdapter(source="firms", severity="immediate",
category="wildfire_spotting")
store._emit_event(p1, {"event_id": "priority-1"})
store._emit_event(p2, {"event_id": "priority-2"})
store._emit_event(imm, {"event_id": "immediate-1"})
assert pacer.pending_count() == 3
assert emitted == []
async def _drive():
await pacer.start()
await asyncio.sleep(0.2)
await pacer.stop()
asyncio.run(_drive())
assert [e.title for e in emitted] == [
"immediate-1", "priority-1", "priority-2"]