mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.6-2): dispatcher state persistence -- cold-start, cooldowns, dedup LRU to SQLite
Closes Rule-20 dispatcher gap from audit doc v0.6-phase1-audit.md finding #1. Pre-this-commit the cold-start anchor, 4 drop counters, per-toggle cooldown map, and dedup OrderedDict all lived in Dispatcher instance memory and were lost on every container restart. v5.sql adds three tables: - dispatcher_state (singleton id=1): cold_start_anchor + 4 drop counters - dispatcher_cooldowns ((toggle,category,region) keyed): last_fired_at - dispatcher_dedup ((source,event_id) keyed): seen_at Dispatcher refactor: - __init__ calls _restore_from_db -- counters, cold-start anchor, cooldown map, and dedup LRU (most-recent 10k by seen_at) all rehydrated from the three new tables - write-through on every mutation: _persist_state for counter/anchor, _persist_cooldown for cooldown UPSERT + 2*cooldown_s prune, _persist_dedup for dedup INSERT OR REPLACE + 7-day cleanup - in-memory caches stay authoritative on the fast read path - cumulative-since-install counters (NOT since-boot); LLM will be able to answer "we have dropped 47 stale events this week" after commit #5 (env_reporter) lands - graceful degrade: missing v5 tables / persistence outage falls back to fresh in-memory state without crashing the constructor Tests: - tests/test_dispatcher_persistence.py (17 tests): state restore on init, counter+cooldown+dedup survival across simulated restart, cooldown rearm within 2x window, dedup LRU rebuild caps at 10k, 7-day cleanup on insert, INSERT OR REPLACE on duplicate source+event_id, v5 migration idempotent, synthetic storm (50 events) -> restart -> replay (5 incl 1 duplicate) with the duplicate dedup-rejected and counters NOT resetting - tests/conftest.py (new): autouse MESHAI_DB_PATH redirection to per-test tmp file, so the dispatcher_* tables on production /data dont get polluted by tests that construct Dispatcher() without an explicit fixture - tests/test_notification_toggles.py: _dispatch helper wipes dedup/cooldown/ state tables between calls (per-call independence preserved; pre-v0.6-2 in-memory-only Dispatcher reset naturally per instance) Test count: 680 -> 697 (+17 new, 0 regressions). Refs audit doc v0.6-phase1-audit.md finding #1.
This commit is contained in:
parent
b2c4d53b14
commit
c333a97344
6 changed files with 815 additions and 7 deletions
494
tests/test_dispatcher_persistence.py
Normal file
494
tests/test_dispatcher_persistence.py
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
"""v0.6-2 dispatcher state persistence tests (audit doc finding #1).
|
||||
|
||||
Covers:
|
||||
- state row restored on dispatcher __init__
|
||||
- counter increments survive restart
|
||||
- cold-start anchor survives restart
|
||||
- cooldown map rebuilt from dispatcher_cooldowns on init
|
||||
- dedup LRU rebuilt from dispatcher_dedup (most-recent 10k) on init
|
||||
- 7-day dedup cleanup runs on insert
|
||||
- 2*cooldown_s cooldown prune runs on insert
|
||||
- v5 migration is idempotent
|
||||
- synthetic storm + restart + replay probe (commit spec step 5)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time as _time
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.config import Config, NotificationToggle
|
||||
from meshai.notifications.events import make_event
|
||||
from meshai.notifications.pipeline.dispatcher import (
|
||||
Dispatcher, _DEDUP_LRU_MAX, _DEDUP_DB_RETENTION_S,
|
||||
)
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
from meshai.persistence import db as persistence_db
|
||||
|
||||
|
||||
# ---------- fixtures --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path, monkeypatch):
|
||||
p = str(tmp_path / "disp-test.sqlite")
|
||||
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
||||
persistence_db._initialised.clear()
|
||||
close_thread_connection()
|
||||
init_db()
|
||||
yield p
|
||||
close_thread_connection()
|
||||
persistence_db._initialised.discard(p)
|
||||
|
||||
|
||||
def _build_config(*, cold_start_grace=60,
|
||||
fire_enabled=True, fire_cooldown=300,
|
||||
fire_freshness=600,
|
||||
fire_severity_channels=None):
|
||||
"""Minimal Config with the `fire` family toggle enabled. The toggle uses
|
||||
a fake `broadcast_channel=1` + mesh_broadcast severity-channel mapping so
|
||||
dispatch() reaches the channel.deliver() path."""
|
||||
cfg = Config()
|
||||
cfg.notifications.cold_start_grace_seconds = cold_start_grace
|
||||
cfg.notifications.toggles = {
|
||||
"fire": NotificationToggle(
|
||||
name="fire", enabled=fire_enabled,
|
||||
freshness_seconds=fire_freshness,
|
||||
cooldown_seconds=fire_cooldown,
|
||||
severity_channels=(fire_severity_channels or
|
||||
{"routine": ["mesh_broadcast"],
|
||||
"priority": ["mesh_broadcast"],
|
||||
"immediate": ["mesh_broadcast"]}),
|
||||
broadcast_channel=1,
|
||||
),
|
||||
}
|
||||
return cfg
|
||||
|
||||
|
||||
def _mk_channel_factory(deliver_outcome=True):
|
||||
"""Returns (factory, channel_mock_list). Each call appends one mock and
|
||||
returns it so tests can assert call counts."""
|
||||
created = []
|
||||
|
||||
def _factory(rule, connector):
|
||||
ch = MagicMock()
|
||||
ch.deliver = AsyncMock(return_value=deliver_outcome)
|
||||
created.append(ch)
|
||||
return ch
|
||||
return _factory, created
|
||||
|
||||
|
||||
def _fire_event(*, source="fires", category="wildfire_incident",
|
||||
event_id=None, severity="priority", region="US-ID",
|
||||
timestamp=None):
|
||||
"""Build a wildfire Event whose category maps to the `fire` toggle."""
|
||||
e = make_event(
|
||||
source=source, category=category, severity=severity,
|
||||
region=region,
|
||||
title="🔥 Test fire", lat=43.6, lon=-116.2,
|
||||
)
|
||||
if event_id is not None:
|
||||
e.id = event_id
|
||||
if timestamp is not None:
|
||||
e.timestamp = timestamp
|
||||
return e
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schema + table baseline
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_v5_tables_present_after_migration(db_path):
|
||||
"""v5.sql ran during init_db. All three tables + the singleton row."""
|
||||
conn = persistence_db.get_db(db_path)
|
||||
tables = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
assert "dispatcher_state" in tables
|
||||
assert "dispatcher_cooldowns" in tables
|
||||
assert "dispatcher_dedup" in tables
|
||||
|
||||
row = conn.execute("SELECT * FROM dispatcher_state").fetchone()
|
||||
assert row is not None
|
||||
assert row["id"] == 1
|
||||
assert row["cold_start_anchor"] is None
|
||||
assert row["stale_dropped"] == 0
|
||||
assert row["cooldown_dropped"] == 0
|
||||
assert row["dedup_dropped"] == 0
|
||||
assert row["cold_start_dropped"] == 0
|
||||
|
||||
|
||||
def test_v5_migration_idempotent(db_path):
|
||||
"""Re-running migrations is a no-op (only one singleton row, no errors)."""
|
||||
# Force a second migration pass via the public path.
|
||||
persistence_db._initialised.discard(db_path)
|
||||
persistence_db._apply_migrations(persistence_db.get_db(db_path))
|
||||
conn = persistence_db.get_db(db_path)
|
||||
n = conn.execute("SELECT COUNT(*) FROM dispatcher_state").fetchone()[0]
|
||||
assert n == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Construction: restore from empty DB = clean defaults
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_init_restores_empty_state(db_path):
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
assert d._stale_dropped == 0
|
||||
assert d._cooldown_dropped == 0
|
||||
assert d._dedup_dropped == 0
|
||||
assert d._cold_start_dropped == 0
|
||||
assert d._first_event_at is None
|
||||
assert d._toggle_cooldown == {}
|
||||
assert len(d._dedup_lru) == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Counters survive restart
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_counter_increments_persist_across_restart(db_path):
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d1 = Dispatcher(cfg, factory)
|
||||
|
||||
# Forcibly bump each counter via the persistence helper. Mirrors what
|
||||
# _dispatch_toggles does on a drop.
|
||||
d1._stale_dropped = 5
|
||||
d1._cooldown_dropped = 3
|
||||
d1._dedup_dropped = 7
|
||||
d1._cold_start_dropped = 2
|
||||
d1._first_event_at = 12345.678
|
||||
d1._persist_state()
|
||||
|
||||
# Fresh dispatcher, same DB.
|
||||
d2 = Dispatcher(cfg, factory)
|
||||
assert d2._stale_dropped == 5
|
||||
assert d2._cooldown_dropped == 3
|
||||
assert d2._dedup_dropped == 7
|
||||
assert d2._cold_start_dropped == 2
|
||||
assert d2._first_event_at == pytest.approx(12345.678)
|
||||
|
||||
|
||||
def test_dispatch_stats_reflects_persisted_state(db_path):
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d1 = Dispatcher(cfg, factory)
|
||||
d1._stale_dropped = 99
|
||||
d1._persist_state()
|
||||
|
||||
d2 = Dispatcher(cfg, factory)
|
||||
stats = d2.dispatch_stats()
|
||||
assert stats["stale_dropped"] == 99
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cooldown survives restart, re-arms on boot
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_cooldown_persists_across_restart(db_path):
|
||||
cfg = _build_config(fire_cooldown=300)
|
||||
factory, _ = _mk_channel_factory()
|
||||
d1 = Dispatcher(cfg, factory)
|
||||
|
||||
key = ("fire", "wildfire_incident", "US-ID")
|
||||
now = _time.time()
|
||||
d1._toggle_cooldown[key] = now
|
||||
d1._persist_cooldown(key, now, 300)
|
||||
|
||||
d2 = Dispatcher(cfg, factory)
|
||||
assert key in d2._toggle_cooldown
|
||||
assert d2._toggle_cooldown[key] == pytest.approx(now)
|
||||
|
||||
|
||||
def test_cooldown_rearms_on_boot_within_window(db_path):
|
||||
"""Cooldown was set 100s before restart; check 100s after restart
|
||||
(so 200s after the fire) with cooldown_s=300 => still in cooldown
|
||||
(200 < 300)."""
|
||||
cfg = _build_config(fire_cooldown=300)
|
||||
factory, _ = _mk_channel_factory()
|
||||
d1 = Dispatcher(cfg, factory)
|
||||
|
||||
fire_t = 10_000.0
|
||||
key = ("fire", "wildfire_incident", "US-ID")
|
||||
d1._toggle_cooldown[key] = fire_t
|
||||
d1._persist_cooldown(key, fire_t, 300)
|
||||
|
||||
# Restart -- fresh dispatcher reads from DB.
|
||||
d2 = Dispatcher(cfg, factory)
|
||||
assert key in d2._toggle_cooldown
|
||||
last = d2._toggle_cooldown[key]
|
||||
now = fire_t + 200 # 200s elapsed
|
||||
assert (now - last) < 300, "must still be in cooldown window"
|
||||
|
||||
|
||||
def test_cooldown_prune_2x_window(db_path):
|
||||
"""Inserting a cooldown deletes rows whose last_fired_at < (now - 2*cooldown_s)."""
|
||||
cfg = _build_config(fire_cooldown=300)
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
now = _time.time()
|
||||
very_old_t = now - 1000.0 # > 2*300=600s old
|
||||
fresh_t = now - 100.0
|
||||
|
||||
d._persist_cooldown(("oldfam", "cat", "*"), very_old_t, 300)
|
||||
d._persist_cooldown(("freshfam", "cat", "*"), fresh_t, 300)
|
||||
# The 2nd call's prune deletes the old row.
|
||||
# Issue one more recent insert to make sure prune runs again with `now`.
|
||||
d._persist_cooldown(("nowfam", "cat", "*"), now, 300)
|
||||
|
||||
conn = persistence_db.get_db(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT toggle FROM dispatcher_cooldowns ORDER BY toggle"
|
||||
).fetchall()
|
||||
toggles = [r["toggle"] for r in rows]
|
||||
assert "oldfam" not in toggles, "stale cooldown should be pruned"
|
||||
assert "freshfam" in toggles
|
||||
assert "nowfam" in toggles
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dedup survives restart
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_dedup_lru_rebuilt_on_restart(db_path):
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d1 = Dispatcher(cfg, factory)
|
||||
|
||||
now = _time.time()
|
||||
for i in range(5):
|
||||
key = ("firms", f"event_{i}")
|
||||
d1._dedup_lru[key] = True
|
||||
d1._persist_dedup(key, now + i)
|
||||
|
||||
d2 = Dispatcher(cfg, factory)
|
||||
assert len(d2._dedup_lru) == 5
|
||||
for i in range(5):
|
||||
assert ("firms", f"event_{i}") in d2._dedup_lru
|
||||
|
||||
|
||||
def test_dedup_lru_rebuild_caps_at_10k(db_path):
|
||||
"""If on-disk has > 10k rows, in-memory restores only the most recent 10k."""
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
conn = persistence_db.get_db(db_path)
|
||||
base = _time.time() - 10000
|
||||
rows = [("firms", f"e{i}", base + i)
|
||||
for i in range(_DEDUP_LRU_MAX + 50)]
|
||||
conn.executemany(
|
||||
"INSERT INTO dispatcher_dedup(source, event_id, seen_at) VALUES (?,?,?)",
|
||||
rows,
|
||||
)
|
||||
|
||||
d2 = Dispatcher(cfg, factory)
|
||||
assert len(d2._dedup_lru) == _DEDUP_LRU_MAX
|
||||
# Most recent IDs survived (e.g. e_(_DEDUP_LRU_MAX+49)) but oldest didn't (e_0).
|
||||
assert ("firms", "e0") not in d2._dedup_lru
|
||||
assert ("firms", f"e{_DEDUP_LRU_MAX + 49}") in d2._dedup_lru
|
||||
|
||||
|
||||
def test_dedup_7_day_cleanup(db_path):
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
now = _time.time()
|
||||
# Need ancient < cutoff strictly. cutoff = now - retention; ancient =
|
||||
# now - retention - 1000 leaves 1000s of headroom.
|
||||
too_old = now - (_DEDUP_DB_RETENTION_S + 1000)
|
||||
|
||||
# Seed an ancient row directly to bypass the prune-on-insert guard.
|
||||
conn = persistence_db.get_db(db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO dispatcher_dedup(source, event_id, seen_at) VALUES (?,?,?)",
|
||||
("firms", "ancient", too_old),
|
||||
)
|
||||
# Now an insert via the helper triggers the cleanup: cutoff = now - retention.
|
||||
d._persist_dedup(("firms", "fresh"), now)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT event_id FROM dispatcher_dedup ORDER BY event_id"
|
||||
).fetchall()
|
||||
ids = [r["event_id"] for r in rows]
|
||||
assert "ancient" not in ids
|
||||
assert "fresh" in ids
|
||||
|
||||
|
||||
def test_dedup_same_key_updates_seen_at(db_path):
|
||||
"""Re-seeing the same (source, event_id) refreshes its seen_at without
|
||||
creating a second row -- the INSERT OR REPLACE on the composite PK."""
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
t1 = _time.time()
|
||||
t2 = t1 + 50
|
||||
|
||||
d._persist_dedup(("firms", "abc"), t1)
|
||||
d._persist_dedup(("firms", "abc"), t2)
|
||||
|
||||
conn = persistence_db.get_db(db_path)
|
||||
rows = conn.execute("SELECT seen_at FROM dispatcher_dedup WHERE event_id='abc'").fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["seen_at"] == pytest.approx(t2)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# End-to-end: counters via real _dispatch_toggles
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_real_dispatch_arms_cold_start_anchor(db_path):
|
||||
"""First event to reach the toggle anchors the grace window and the
|
||||
anchor lands in dispatcher_state."""
|
||||
cfg = _build_config(cold_start_grace=60)
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
ev = _fire_event(event_id="fire_anchor_001", timestamp=_time.time())
|
||||
asyncio.run(d._dispatch_toggles(ev))
|
||||
|
||||
assert d._first_event_at is not None
|
||||
# And in SQLite.
|
||||
conn = persistence_db.get_db(db_path)
|
||||
row = conn.execute("SELECT cold_start_anchor FROM dispatcher_state").fetchone()
|
||||
assert row["cold_start_anchor"] is not None
|
||||
|
||||
|
||||
def test_real_dispatch_stale_drop_persists(db_path):
|
||||
"""A stale event increments stale_dropped on disk."""
|
||||
cfg = _build_config(cold_start_grace=0, fire_freshness=60)
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
ev = _fire_event(event_id="fire_stale_001",
|
||||
timestamp=_time.time() - 600) # 10 min old vs 60s window
|
||||
asyncio.run(d._dispatch_toggles(ev))
|
||||
|
||||
assert d._stale_dropped == 1
|
||||
conn = persistence_db.get_db(db_path)
|
||||
row = conn.execute("SELECT stale_dropped FROM dispatcher_state").fetchone()
|
||||
assert row["stale_dropped"] == 1
|
||||
|
||||
|
||||
def test_real_dispatch_dedup_writes_through(db_path):
|
||||
"""A successful first-sighting writes a dedup row; a second sighting
|
||||
drops at dedup AND refreshes seen_at."""
|
||||
cfg = _build_config(cold_start_grace=0, fire_freshness=86400, fire_cooldown=0)
|
||||
factory, _ = _mk_channel_factory()
|
||||
d = Dispatcher(cfg, factory)
|
||||
|
||||
ev1 = _fire_event(event_id="fire_dup_001", timestamp=_time.time())
|
||||
asyncio.run(d._dispatch_toggles(ev1))
|
||||
|
||||
conn = persistence_db.get_db(db_path)
|
||||
rows = conn.execute("SELECT source, event_id FROM dispatcher_dedup").fetchall()
|
||||
assert ("fires", "fire_dup_001") in [(r["source"], r["event_id"]) for r in rows]
|
||||
|
||||
# second sighting
|
||||
ev2 = _fire_event(event_id="fire_dup_001", timestamp=_time.time())
|
||||
asyncio.run(d._dispatch_toggles(ev2))
|
||||
|
||||
assert d._dedup_dropped == 1
|
||||
row = conn.execute("SELECT dedup_dropped FROM dispatcher_state").fetchone()
|
||||
assert row["dedup_dropped"] == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Synthetic storm -> restart -> replay (commit spec step 5)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_synthetic_storm_restart_replay(db_path):
|
||||
"""Phase 1: 50 distinct events. Phase 2: new Dispatcher, same DB. Phase 3:
|
||||
5 more events including one duplicate from phase 1. Assertions:
|
||||
- Phase 3 duplicate is dropped by dedup
|
||||
- Counters in phase 2/3 inherit phase 1 values (not reset)
|
||||
- Dedup LRU + cooldown carries across
|
||||
"""
|
||||
cfg = _build_config(cold_start_grace=0, fire_freshness=86400, fire_cooldown=0)
|
||||
factory_a, _ = _mk_channel_factory()
|
||||
d1 = Dispatcher(cfg, factory_a)
|
||||
|
||||
# ---- Phase 1: 50 distinct events ----
|
||||
base_ts = _time.time()
|
||||
for i in range(50):
|
||||
ev = _fire_event(event_id=f"storm_{i}", timestamp=base_ts)
|
||||
asyncio.run(d1._dispatch_toggles(ev))
|
||||
|
||||
p1_dedup_rows = persistence_db.get_db(db_path).execute(
|
||||
"SELECT COUNT(*) FROM dispatcher_dedup"
|
||||
).fetchone()[0]
|
||||
p1_dedup_dropped = d1._dedup_dropped
|
||||
p1_stale_dropped = d1._stale_dropped
|
||||
|
||||
assert p1_dedup_rows == 50
|
||||
# All 50 were first-sightings -> no dedup_dropped increments.
|
||||
assert p1_dedup_dropped == 0
|
||||
|
||||
# ---- Phase 2: simulated restart, fresh Dispatcher, same DB ----
|
||||
factory_b, _ = _mk_channel_factory()
|
||||
d2 = Dispatcher(cfg, factory_b)
|
||||
# Carry-over from phase 1:
|
||||
assert d2._dedup_dropped == p1_dedup_dropped
|
||||
assert d2._stale_dropped == p1_stale_dropped
|
||||
assert len(d2._dedup_lru) == 50
|
||||
|
||||
# ---- Phase 3: 5 new events incl one phase-1 duplicate ----
|
||||
for i in range(50, 54):
|
||||
asyncio.run(d2._dispatch_toggles(
|
||||
_fire_event(event_id=f"storm_{i}", timestamp=base_ts)
|
||||
))
|
||||
# The duplicate:
|
||||
dup_ev = _fire_event(event_id="storm_3", timestamp=base_ts)
|
||||
asyncio.run(d2._dispatch_toggles(dup_ev))
|
||||
|
||||
# Now: dedup_dropped must have incremented by exactly 1 (the duplicate).
|
||||
assert d2._dedup_dropped == p1_dedup_dropped + 1, (
|
||||
f"expected +1 dedup drop, got {d2._dedup_dropped - p1_dedup_dropped}"
|
||||
)
|
||||
|
||||
# 50 phase-1 + 4 phase-3-new = 54 distinct rows on disk.
|
||||
final_rows = persistence_db.get_db(db_path).execute(
|
||||
"SELECT COUNT(*) FROM dispatcher_dedup"
|
||||
).fetchone()[0]
|
||||
assert final_rows == 54
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Graceful degrade: dispatcher works even when persistence is broken
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_dispatcher_construct_when_db_unavailable(monkeypatch, tmp_path):
|
||||
"""If get_db() raises on construct, the dispatcher must still init
|
||||
with fresh in-memory state (no crash, no abort)."""
|
||||
cfg = _build_config()
|
||||
factory, _ = _mk_channel_factory()
|
||||
|
||||
def _broken_get_db(*a, **kw):
|
||||
raise RuntimeError("persistence layer down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"meshai.notifications.pipeline.dispatcher.__name__", "<patched>"
|
||||
)
|
||||
# Patch the import target: persistence.get_db.
|
||||
import meshai.persistence as p
|
||||
monkeypatch.setattr(p, "get_db", _broken_get_db)
|
||||
|
||||
d = Dispatcher(cfg, factory) # must not raise
|
||||
assert d._stale_dropped == 0
|
||||
assert d._first_event_at is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue