mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
wildfire_spotting is the most urgent signal in the system (a fire
throwing embers past its own containment line), but every fire event
carries a group_key (= event_id), so the Grouper held spotting for the
full grouper_window_seconds (60s live) before it could even reach the
dispatcher -- a ~60s latency FLOOR, and ~120s+ once FirePacer queuing
is added on top.
Add a module-level _NEVER_COALESCE_CATEGORIES frozenset and bypass the
coalescing window for the categories in it. Currently: wildfire_spotting
only.
This is safe because spotting is already rate-limited AT THE SOURCE: a
per-fire 1h cooldown (adapter_config.fires.spotting_cooldown_seconds,
default 3600, latched on fires.last_spotting_broadcast_at per irwin_id
in gating/firms.py) gates spotting DETECTION itself, so N active fires
yield at most N spotting alerts per hour. FirePacer (60s interval, with
head-of-line ordering for immediate severity) and the dispatcher's
per-(toggle, category, region) cooldown still apply downstream.
The bypass is scoped by CATEGORY, never by severity. Commit 85d48ce3
deliberately removed a severity == "immediate" bypass from this exact
spot because ALL fire events carry _severity_override="immediate", so a
severity bypass exempts the entire fire family from rate control. The
comment on the constant spells that out so it does not get re-added.
Tests: spotting with a group_key passes straight through; wildfire_growth
and wildfire_incident at immediate severity are STILL held (proving no
severity bypass crept back in); spotting with no group_key still passes
through. PR #129's test_immediate_severity_is_also_coalesced_no_bypass is
untouched and still passes.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
6.4 KiB
Python
181 lines
6.4 KiB
Python
"""Grouper tests: coalescing (all severities) + periodic flush.
|
|
|
|
Note: there is deliberately NO immediate-severity bypass -- commit 85d48ce3
|
|
removed it so fire broadcasts obey rate control. The only pass-through is
|
|
"event has no group_key".
|
|
"""
|
|
|
|
from meshai.notifications.pipeline.grouper import Grouper
|
|
from meshai.notifications.events import make_event
|
|
|
|
|
|
class Recorder:
|
|
def __init__(self):
|
|
self.received = []
|
|
|
|
def handle(self, event):
|
|
self.received.append(event)
|
|
|
|
|
|
def _ev(severity, group_key="gk1"):
|
|
return make_event(
|
|
source="usgs_quake",
|
|
category="earthquake_event",
|
|
severity=severity,
|
|
title=f"test {severity}",
|
|
lat=42.6,
|
|
lon=-114.5,
|
|
group_key=group_key,
|
|
inhibit_keys=[group_key],
|
|
)
|
|
|
|
|
|
def test_immediate_severity_is_also_coalesced_no_bypass():
|
|
"""An immediate event WITH a group_key is held, like every other severity.
|
|
|
|
The grouper used to exempt severity == "immediate" from the coalescing
|
|
window. Commit 85d48ce3 ("fix(fire): remove immediate-severity exemption
|
|
from grouper + cooldown") DELETED that bypass on purpose: fire events
|
|
carry _severity_override="immediate", and the exemption meant they
|
|
skipped the coalescer and zeroed the dispatcher cooldown, leaving fire
|
|
with no rate control at all in normal live operation. Rate control now
|
|
applies to ALL severities; the drain-mode pacer covers reconnect bursts.
|
|
|
|
This test previously asserted the OLD bypass contract and had been red
|
|
ever since. Re-adding the bypass to make it pass would re-open the fire
|
|
broadcast-spam hole on a public-safety mesh -- so the test is what moves,
|
|
not the source.
|
|
"""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
g.handle(_ev("immediate"))
|
|
# Held for coalescing, NOT delivered straight through.
|
|
assert rec.received == []
|
|
assert g.held_count() == 1
|
|
# The periodic flush (start_pipeline's _grouper_flush_loop) is what
|
|
# eventually delivers it, once the window expires.
|
|
g2 = Grouper(next_handler=rec.handle, window_seconds=0.0)
|
|
g2.handle(_ev("immediate", group_key="gk2"))
|
|
assert g2.tick() == 1
|
|
assert len(rec.received) == 1
|
|
assert rec.received[0].severity == "immediate"
|
|
|
|
|
|
def test_no_group_key_still_passes_through_immediately():
|
|
"""The ONE remaining bypass: an event with no group_key isn't coalesced
|
|
(there's nothing to coalesce it against)."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
g.handle(_ev("immediate", group_key=None))
|
|
assert len(rec.received) == 1
|
|
assert g.held_count() == 0
|
|
|
|
|
|
def test_periodic_flush_drains_routine():
|
|
"""A routine event is held, then released by tick() once its window passes."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=0.0) # 0s window -> tick drains now
|
|
g.handle(_ev("routine"))
|
|
# Held on arrival, not yet delivered.
|
|
assert g.held_count() == 1
|
|
assert rec.received == []
|
|
# The periodic flush task calls tick(); simulate one tick.
|
|
drained = g.tick()
|
|
assert drained == 1
|
|
assert len(rec.received) == 1
|
|
assert rec.received[0].severity == "routine"
|
|
assert g.held_count() == 0
|
|
|
|
|
|
def test_priority_is_also_coalesced_not_bypassed():
|
|
"""Priority events still buffer (only immediate bypasses)."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
g.handle(_ev("priority"))
|
|
assert rec.received == []
|
|
assert g.held_count() == 1
|
|
|
|
|
|
def test_wildfire_spotting_bypasses_the_grouper_even_with_group_key():
|
|
"""wildfire_spotting is a CATEGORY-scoped bypass (owner-approved): it
|
|
skips the coalescing window entirely, even though it carries a
|
|
group_key that would otherwise hold it. This is the one narrow
|
|
exemption -- see _NEVER_COALESCE_CATEGORIES in grouper.py for why
|
|
it's safe (source-side 1h per-fire cooldown) and why it must stay
|
|
category-scoped, not severity-scoped."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
ev = make_event(
|
|
source="firms",
|
|
category="wildfire_spotting",
|
|
severity="immediate",
|
|
title="test spotting",
|
|
lat=42.6,
|
|
lon=-114.5,
|
|
group_key="fire-irwin-123",
|
|
)
|
|
g.handle(ev)
|
|
# Passed straight through -- NOT held for the coalescing window.
|
|
assert len(rec.received) == 1
|
|
assert rec.received[0].category == "wildfire_spotting"
|
|
assert g.held_count() == 0
|
|
|
|
|
|
def test_wildfire_growth_immediate_is_still_held_no_severity_bypass_crept_in():
|
|
"""A same-severity, same-fire-family event of a DIFFERENT category
|
|
(wildfire_growth, not wildfire_spotting) must still be coalesced.
|
|
This proves the new bypass is scoped to category and did not
|
|
accidentally reintroduce a severity-based bypass (the exact bug
|
|
commit 85d48ce3 removed)."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
ev = make_event(
|
|
source="wfigs",
|
|
category="wildfire_growth",
|
|
severity="immediate",
|
|
title="test growth",
|
|
lat=42.6,
|
|
lon=-114.5,
|
|
group_key="fire-irwin-123",
|
|
)
|
|
g.handle(ev)
|
|
assert rec.received == []
|
|
assert g.held_count() == 1
|
|
|
|
|
|
def test_wildfire_incident_immediate_is_still_held_no_severity_bypass_crept_in():
|
|
"""Same as above for wildfire_incident: only wildfire_spotting bypasses,
|
|
every other fire category (even at immediate severity) is coalesced."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
ev = make_event(
|
|
source="wfigs",
|
|
category="wildfire_incident",
|
|
severity="immediate",
|
|
title="test incident",
|
|
lat=42.6,
|
|
lon=-114.5,
|
|
group_key="fire-irwin-123",
|
|
)
|
|
g.handle(ev)
|
|
assert rec.received == []
|
|
assert g.held_count() == 1
|
|
|
|
|
|
def test_wildfire_spotting_with_no_group_key_still_passes_through():
|
|
"""Existing no-group_key behavior is preserved for spotting too --
|
|
the bypass condition is an `or`, not a replacement."""
|
|
rec = Recorder()
|
|
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
|
|
ev = make_event(
|
|
source="firms",
|
|
category="wildfire_spotting",
|
|
severity="immediate",
|
|
title="test spotting no key",
|
|
lat=42.6,
|
|
lon=-114.5,
|
|
group_key=None,
|
|
)
|
|
g.handle(ev)
|
|
assert len(rec.received) == 1
|
|
assert g.held_count() == 0
|