mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.5.8b): persistence foundation + WFIGS handler + universal cold-start grace
Three integrated pieces that ship together because they were designed as one safety story: (1) PERSISTENCE FOUNDATION -- new meshai/persistence/ module with SQLite db.py, schema migration framework (v1), 13 tables covering all adapter event shapes (traffic_events, fires, firms_pixels, quake_events, nws_alerts, gauge_readings, swpc_events) + mesh state (mesh_nodes, mesh_telemetry, mesh_positions, mesh_messages_in, mesh_broadcasts_out, mesh_health_events) + cross-cutting event_log + schema_meta. WAL mode for reader concurrency, single-writer pattern, MESHAI_DB_PATH env var, mounted at /data/meshai.sqlite via existing docker-compose meshai_data volume. .gitignore updated. (2) WFIGS HANDLER -- meshai/central/wfigs_handler.py implements the first per-adapter handler that uses the persistence layer. Format: MEDIUM style with town/landclass/county fallback chain, lat/lon at 3-decimal precision, New:/Update: prefix. 8h-rate-limited change-detection per IRWIN via fires.last_broadcast_at. Skips tombstones and perimeters silently (logged to event_log with handled=0). Acres fallback chain DailyAcres -> IncidentSize -> raw.DiscoveryAcres -> raw.FinalAcres -> N/A. Pass-through Initial Attack auto-numbered names (IA 1, IA 2). (3) UNIVERSAL COLD-START GRACE -- meshai/notifications/pipeline/dispatcher.py grows a configurable grace window (cold_start_grace_seconds, default 60s, GUI-editable per Rule 17). Anchored to first-event-seen (not container boot), so the grace activates the moment broadcasts could fire. Suppresses mesh delivery during the window; handler-side persistence (fires UPSERT, event_log) still happens normally. New _cold_start_dropped counter exposed in dispatch_stats(). Designed to protect against JetStream backlog spam at toggle-flip time, applies universally to ALL adapters. (4) WFIGS HANDLER CALLBACK REFACTOR -- New:/Update: prefix now keys on fires.last_broadcast_at IS NULL (not row-missing), and last_broadcast_* field updates moved to a post-broadcast commit callback that the dispatcher invokes ONLY on successful delivery. This means: cold-start-suppressed events leave fires.last_broadcast_at NULL, so when they eventually broadcast post-grace, they correctly render as New: (first ACTUAL delivery for that IRWIN), not Update:. event_log.handled and mesh_broadcasts_out audit row also gated on the same callback -- decoupling persistence rows from broadcast rows for an honest audit trail. New tests: 15 in test_wfigs_handler.py, 15 in test_persistence.py, additional cold-start grace tests in test_dispatcher.py (+4 WFIGS callback scenarios). Synthetic probes wfigs-cleaned-samples.md (initial) and wfigs-cleaned-samples-v2.md (cold-start verification) generated against isolated temp SQLite databases. CT108 /data/meshai.sqlite untouched during build. Master stays off. No live toggle flips. Test count: was 535 (v0.5.7 baseline) -> 566 (persistence) -> 581 (wfigs handler) -> 589 expected (cold-start grace). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7751a40c6c
commit
053d67db6e
16 changed files with 2652 additions and 1 deletions
136
tests/test_cold_start_grace.py
Normal file
136
tests/test_cold_start_grace.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""v0.5.8b — cold-start grace + post-broadcast commit hook in Dispatcher.
|
||||
|
||||
The grace window suppresses mesh broadcasts for N seconds after the FIRST
|
||||
event the dispatcher sees through an enabled toggle. The persistence layer
|
||||
(handler-side) has already run by then, so fires/event_log rows exist;
|
||||
only the broadcast (and the mesh_broadcasts_out audit + last_broadcast_*
|
||||
callback) is gated.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.config import Config
|
||||
from meshai.notifications.events import make_event
|
||||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||
|
||||
|
||||
# ---------- helpers -------------------------------------------------------
|
||||
|
||||
|
||||
class RecChannel:
|
||||
def __init__(self, rec):
|
||||
self.rec = rec
|
||||
|
||||
async def deliver(self, payload, rule):
|
||||
self.rec.append({
|
||||
"name": rule.name,
|
||||
"message": payload.message,
|
||||
"delivery_type": rule.delivery_type,
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def _cfg(*, cold_start_grace_seconds=60, toggle_name="weather"):
|
||||
cfg = Config()
|
||||
cfg.notifications.rules = []
|
||||
cfg.notifications.cold_start_grace_seconds = cold_start_grace_seconds
|
||||
t = cfg.notifications.toggles[toggle_name]
|
||||
t.enabled = True
|
||||
t.min_severity = "routine"
|
||||
t.severity_channels = {"routine": ["mesh_broadcast"]}
|
||||
# Wide-open v0.5.2 gates so the cold-start gate is the only thing
|
||||
# that can drop these events.
|
||||
t.freshness_seconds = 0
|
||||
t.cooldown_seconds = 0
|
||||
return cfg
|
||||
|
||||
|
||||
def _ev(*, source="nws", category="weather_warning",
|
||||
severity="routine", title="t", **kw):
|
||||
return make_event(
|
||||
source=source, category=category, severity=severity,
|
||||
title=title, timestamp=time.time(), **kw,
|
||||
)
|
||||
|
||||
|
||||
def _make(cfg):
|
||||
rec: list = []
|
||||
d = Dispatcher(cfg, lambda r, c: RecChannel(rec), connector=None)
|
||||
return d, rec
|
||||
|
||||
|
||||
# ---------- (a) first event during grace ----------------------------------
|
||||
|
||||
|
||||
def test_cold_start_grace_drops_first_event_inside_window():
|
||||
cfg = _cfg(cold_start_grace_seconds=60)
|
||||
d, rec = _make(cfg)
|
||||
|
||||
fake_now = 1_000_000.0
|
||||
with patch("meshai.notifications.pipeline.dispatcher.time.time",
|
||||
return_value=fake_now):
|
||||
asyncio.run(d.dispatch(_ev()))
|
||||
|
||||
assert rec == [], "broadcast must be suppressed inside grace window"
|
||||
stats = d.dispatch_stats()
|
||||
assert stats["cold_start_dropped"] == 1
|
||||
assert stats["cold_start_anchor_at"] == fake_now
|
||||
|
||||
|
||||
# ---------- (b) event arriving 30s into grace -- still dropped -----------
|
||||
|
||||
|
||||
def test_cold_start_grace_drops_event_partway_through_window():
|
||||
cfg = _cfg(cold_start_grace_seconds=60)
|
||||
d, rec = _make(cfg)
|
||||
|
||||
t0 = 2_000_000.0
|
||||
# First event anchors the window.
|
||||
with patch("meshai.notifications.pipeline.dispatcher.time.time",
|
||||
return_value=t0):
|
||||
asyncio.run(d.dispatch(_ev()))
|
||||
# 30s in, still inside the 60s window.
|
||||
with patch("meshai.notifications.pipeline.dispatcher.time.time",
|
||||
return_value=t0 + 30):
|
||||
asyncio.run(d.dispatch(_ev()))
|
||||
|
||||
assert rec == []
|
||||
assert d.dispatch_stats()["cold_start_dropped"] == 2
|
||||
|
||||
|
||||
# ---------- (c) event 61s after first -- broadcasts ----------------------
|
||||
|
||||
|
||||
def test_cold_start_grace_passes_event_after_window():
|
||||
cfg = _cfg(cold_start_grace_seconds=60)
|
||||
d, rec = _make(cfg)
|
||||
|
||||
t0 = 3_000_000.0
|
||||
with patch("meshai.notifications.pipeline.dispatcher.time.time",
|
||||
return_value=t0):
|
||||
asyncio.run(d.dispatch(_ev())) # dropped
|
||||
with patch("meshai.notifications.pipeline.dispatcher.time.time",
|
||||
return_value=t0 + 61):
|
||||
asyncio.run(d.dispatch(_ev())) # broadcasts
|
||||
|
||||
assert len(rec) == 1
|
||||
stats = d.dispatch_stats()
|
||||
assert stats["cold_start_dropped"] == 1
|
||||
|
||||
|
||||
# ---------- (d) grace = 0 disables the feature ----------------------------
|
||||
|
||||
|
||||
def test_cold_start_grace_zero_disables_feature():
|
||||
cfg = _cfg(cold_start_grace_seconds=0)
|
||||
d, rec = _make(cfg)
|
||||
asyncio.run(d.dispatch(_ev()))
|
||||
assert len(rec) == 1
|
||||
stats = d.dispatch_stats()
|
||||
assert stats["cold_start_dropped"] == 0
|
||||
# Anchor not set when grace disabled (no gate ran).
|
||||
assert stats["cold_start_anchor_at"] is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue