feat: hot-reload the environmental config section (no restart) (#135)

EnvironmentalStore.apply_config() rebuilds only the changed native
adapters in place on a config PUT -- dedup/seen state (store-level) is
preserved, unchanged adapters are untouched. Drops "environmental" from
RESTART_REQUIRED_SECTIONS; falls back to restart-required only for the
narrow feed_source->central case. Cascades nifc/fires -> firms. The old
restart requirement was a Central-era coupling, now moot (all-native,
central.enabled=false, CentralConsumer inert).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-15 01:21:12 -06:00 committed by GitHub
commit 8b4826f8db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 539 additions and 71 deletions

View file

@ -0,0 +1,179 @@
"""Config hot-reload tests for EnvironmentalStore.apply_config().
A config PUT to the "environmental" (or "generic_sources") section should
take effect LIVE -- only the adapter(s) whose own config actually changed are
rebuilt in place, on the SAME running store, with no container restart:
* an unchanged adapter's object identity is preserved (untouched);
* a changed adapter (still feed_source=="native") is rebuilt in place and
the NEW instance carries the new config values;
* store-level dedup/seen state (self._seen, self._seeded, ...) is keyed by
event source, not by adapter object, and survives a swap unchanged;
* flipping feed_source to/from "central" is the one case that still needs
a restart -- it must NOT hot-swap;
* rebuilding "nifc" cascades to also rebuild "firms" (it holds a hard
reference to the nifc adapter instance).
"""
from __future__ import annotations
import dataclasses
from meshai.config import EnvironmentalConfig
from meshai.env.store import EnvironmentalStore
def _cfg(**overrides) -> EnvironmentalConfig:
"""A minimal EnvironmentalConfig with usgs_quake enabled+native (has a
plain feed_url field, no network I/O at construction) so tests have a
real adapter to diff/swap, plus whatever overrides the test needs.
"""
cfg = EnvironmentalConfig()
cfg.usgs_quake = dataclasses.replace(
cfg.usgs_quake, enabled=True, feed_source="native",
feed_url="https://example.invalid/quakes-a.geojson",
)
for key, value in overrides.items():
setattr(cfg, key, value)
return cfg
def test_unchanged_adapter_config_is_a_noop():
store = EnvironmentalStore(_cfg())
nws_before = store._adapters["nws"]
usgs_quake_before = store._adapters["usgs_quake"]
same_cfg = _cfg() # byte-identical field values, fresh dataclass instances
result = store.apply_config(same_cfg)
assert result["nws"] == "unchanged"
assert result["usgs_quake"] == "unchanged"
assert store._adapters["nws"] is nws_before
assert store._adapters["usgs_quake"] is usgs_quake_before
def test_changed_feed_url_swaps_adapter_and_preserves_dedup_state():
store = EnvironmentalStore(_cfg())
nws_before = store._adapters["nws"]
usgs_quake_before = store._adapters["usgs_quake"]
assert usgs_quake_before._feed_url == "https://example.invalid/quakes-a.geojson"
# Store-level dedup state, keyed by event SOURCE -- must survive a swap.
store._seen["usgs_quake"] = {"usgs_quake\x1eeid:us1000aaaa"}
store._seeded.add("usgs_quake")
new_cfg = _cfg()
new_cfg.usgs_quake = dataclasses.replace(
new_cfg.usgs_quake, feed_url="https://example.invalid/quakes-b.geojson")
result = store.apply_config(new_cfg)
assert result["usgs_quake"] == "reloaded"
assert result["nws"] == "unchanged"
usgs_quake_after = store._adapters["usgs_quake"]
assert usgs_quake_after is not usgs_quake_before, "changed adapter must be a NEW object"
assert usgs_quake_after._feed_url == "https://example.invalid/quakes-b.geojson", \
"the new instance must carry the new config value"
# Sibling adapter untouched (identity preserved).
assert store._adapters["nws"] is nws_before
# Store-level dedup/seen state survives the swap unchanged.
assert store._seen["usgs_quake"] == {"usgs_quake\x1eeid:us1000aaaa"}
assert "usgs_quake" in store._seeded
def test_disabling_an_adapter_removes_it_live():
store = EnvironmentalStore(_cfg())
assert "usgs_quake" in store._adapters
new_cfg = _cfg()
new_cfg.usgs_quake = dataclasses.replace(new_cfg.usgs_quake, enabled=False)
result = store.apply_config(new_cfg)
assert result["usgs_quake"] == "reloaded"
assert "usgs_quake" not in store._adapters
def test_feed_source_flip_to_central_requires_restart_and_does_not_swap():
store = EnvironmentalStore(_cfg())
nws_before = store._adapters["nws"]
new_cfg = _cfg()
new_cfg.nws = dataclasses.replace(new_cfg.nws, feed_source="central")
result = store.apply_config(new_cfg)
assert result["nws"] == "restart_required"
assert store._adapters["nws"] is nws_before, "must NOT hot-swap across the Central boundary"
# Repeated PUTs with the same (still-unapplied) value keep reporting
# restart_required rather than silently settling to "unchanged".
result2 = store.apply_config(new_cfg)
assert result2["nws"] == "restart_required"
assert store._adapters["nws"] is nws_before
def test_feed_source_flip_from_central_to_native_requires_restart():
# satpass defaults feed_source="central"; flip it to native.
store = EnvironmentalStore(_cfg())
assert "satpass" not in store._adapters # central by default -> no native instance
new_cfg = _cfg()
new_cfg.satpass = dataclasses.replace(
new_cfg.satpass, enabled=True, feed_source="native")
result = store.apply_config(new_cfg)
assert result["satpass"] == "restart_required"
assert result["satpass_tle"] == "restart_required"
assert "satpass" not in store._adapters
assert "satpass_tle" not in store._adapters
def test_nifc_rebuild_cascades_to_firms():
cfg = _cfg()
cfg.fires = dataclasses.replace(cfg.fires, enabled=True, feed_source="native")
cfg.firms = dataclasses.replace(
cfg.firms, enabled=True, feed_source="native", map_key="test-key")
store = EnvironmentalStore(cfg)
nifc_before = store._adapters["nifc"]
firms_before = store._adapters["firms"]
assert firms_before._fires_adapter is nifc_before
# Change something on the "fires" (nifc) config only; firms' OWN config
# is untouched.
new_cfg = _cfg()
new_cfg.fires = dataclasses.replace(cfg.fires, state="US-NV")
new_cfg.firms = cfg.firms # byte-identical
result = store.apply_config(new_cfg)
assert result["nifc"] == "reloaded"
assert result["firms"] == "reloaded", \
"firms must cascade-rebuild even though its own config didn't change"
nifc_after = store._adapters["nifc"]
firms_after = store._adapters["firms"]
assert nifc_after is not nifc_before
assert firms_after is not firms_before
assert firms_after._fires_adapter is nifc_after, \
"firms must hold the FRESH nifc reference, not the stale one"
def test_generic_sources_change_rebuilds_generic_http_only():
store = EnvironmentalStore(
_cfg(), generic_sources=[{"name": "a", "url": "https://a.example"}])
generic_before = store._adapters.get("generic_http")
nws_before = store._adapters["nws"]
same_cfg = _cfg()
result = store.apply_config(
same_cfg, generic_sources=[{"name": "a", "url": "https://a.example"}])
assert result["generic_http"] == "unchanged"
assert store._adapters.get("generic_http") is generic_before
result2 = store.apply_config(
same_cfg, generic_sources=[{"name": "a", "url": "https://a-changed.example"}])
assert result2["generic_http"] == "reloaded"
assert result2["nws"] == "unchanged"
assert store._adapters["nws"] is nws_before

View file

@ -98,17 +98,42 @@ def config_app(tmp_path, monkeypatch):
return app
def test_environmental_in_restart_required_sections():
def _config_app_with_store(tmp_path):
"""config_routes app with a REAL EnvironmentalStore on app.state so the
hot-reload path (apply_config) actually runs on a PUT. Mirrors config_app
but adds the store main.py stashes at boot."""
from meshai.dashboard.api.config_routes import router as config_router
from meshai.env.store import EnvironmentalStore
from meshai.config import EnvironmentalConfig
cfg_dir = tmp_path / "cfg"
cfg_dir.mkdir()
(cfg_dir / "config.yaml").write_text("# stub\n")
app = FastAPI()
app.state.config = Config()
app.state.config_path = str(cfg_dir / "config.yaml")
app.state.env_store = EnvironmentalStore(EnvironmentalConfig(), event_bus=None)
app.include_router(config_router, prefix="/api")
return app
def test_environmental_not_in_restart_required_sections():
# v0.16-env-hot-reload: environmental (and generic_sources) are now
# hot-applied via EnvironmentalStore.apply_config() rather than demanding a
# restart. Only a per-adapter feed_source->central flip still needs one, and
# that is reported per-adapter (see below), not by section membership.
from meshai.dashboard.api.config_routes import RESTART_REQUIRED_SECTIONS
assert "environmental" in RESTART_REQUIRED_SECTIONS
assert "environmental" not in RESTART_REQUIRED_SECTIONS
assert "generic_sources" not in RESTART_REQUIRED_SECTIONS
def test_put_environmental_returns_restart_required_with_changed_keys(config_app):
"""A PUT to environmental that changes feed_source returns
restart_required=true + the dotted changed_keys list."""
client = TestClient(config_app)
def test_put_environmental_feed_source_to_central_still_requires_restart(tmp_path):
"""The one surviving restart case: flipping an adapter's feed_source to
"central" is boot-only (CentralConsumer subscribe). apply_config reports it
per-adapter as "restart_required", which the PUT handler surfaces."""
client = TestClient(_config_app_with_store(tmp_path))
# Fetch current environmental.
cur = client.get("/api/config/environmental")
assert cur.status_code == 200
body = cur.json()
@ -120,12 +145,31 @@ def test_put_environmental_returns_restart_required_with_changed_keys(config_app
result = r.json()
assert result["saved"] is True
assert result["restart_required"] is True
# The dotted key must be present.
assert result["adapter_results"]["firms"] == "restart_required"
assert any(k.endswith("firms.feed_source") for k in result["changed_keys"]), (
f"changed_keys missing firms.feed_source: {result['changed_keys']}"
)
def test_put_environmental_native_field_change_hot_reloads_no_restart(tmp_path):
"""A normal native-adapter field edit (nws.tick_seconds) hot-reloads that
adapter in place: restart_required=false, adapter reported "reloaded"."""
client = TestClient(_config_app_with_store(tmp_path))
cur = client.get("/api/config/environmental")
assert cur.status_code == 200
body = cur.json()
# nws is enabled+native by default; bump its poll cadence.
body["nws"]["tick_seconds"] = int(body["nws"].get("tick_seconds", 60)) + 60
r = client.put("/api/config/environmental", json=body)
assert r.status_code == 200
result = r.json()
assert result["restart_required"] is False
assert result["adapter_results"]["nws"] == "reloaded"
assert any(k.endswith("nws.tick_seconds") for k in result["changed_keys"])
def test_put_non_restart_section_returns_restart_required_false(config_app):
"""A PUT to bot (not restart-required) returns restart_required=false."""
client = TestClient(config_app)