mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
4fd431f907
commit
8b4826f8db
5 changed files with 539 additions and 71 deletions
|
|
@ -18,23 +18,28 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["config"])
|
router = APIRouter(tags=["config"])
|
||||||
|
|
||||||
# Sections that require restart when changed.
|
# Sections that require restart when changed.
|
||||||
# v0.6-tail-3: environmental added. Per Central v0.10.2 OR-not-AND
|
# v0.16-env-hot-reload: "environmental" and "generic_sources" REMOVED.
|
||||||
# verification (Spokane fix), env_store rebuild and CentralConsumer
|
# Central is retired (all-native deployments, central.enabled=false,
|
||||||
# subscribe both happen only at boot. A live PUT to
|
# CentralConsumer inert), so the transient AND-mode this rule originally
|
||||||
# environmental.<adapter>.feed_source / enabled writes to disk but the
|
# guarded against (env_store rebuild + CentralConsumer subscribe both
|
||||||
# running process keeps polling the existing native adapters AND newly
|
# boot-only, per Central v0.10.2's OR-not-AND / Spokane fix) can no longer
|
||||||
# subscribing to Central until the container restarts -- a transient
|
# happen in practice -- there is no live Central subscription to race. A PUT
|
||||||
# AND-mode that violates the architecture for as long as the user
|
# to environmental/generic_sources is now hot-applied via
|
||||||
# delays the restart.
|
# EnvironmentalStore.apply_config() (see _refresh_environmental below):
|
||||||
|
# unchanged per-adapter configs are left alone, changed native adapters are
|
||||||
|
# rebuilt in place, and store-level dedup/seen state survives because it is
|
||||||
|
# keyed by event source, not by adapter object. The ONE case that still
|
||||||
|
# needs a restart is a single adapter's feed_source flipping to/from
|
||||||
|
# "central" -- CentralConsumer's (un)subscribe is still boot-only -- and
|
||||||
|
# that is now reported per-adapter (see apply_config()'s "restart_required"
|
||||||
|
# result), not by blanket section membership here.
|
||||||
RESTART_REQUIRED_SECTIONS = {
|
RESTART_REQUIRED_SECTIONS = {
|
||||||
"connection",
|
"connection",
|
||||||
"llm",
|
"llm",
|
||||||
"mesh_sources",
|
"mesh_sources",
|
||||||
"meshmonitor",
|
"meshmonitor",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"environmental",
|
|
||||||
"coverage",
|
"coverage",
|
||||||
"generic_sources",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Valid config section names
|
# Valid config section names
|
||||||
|
|
@ -172,6 +177,7 @@ async def update_config_section(section: str, request: Request):
|
||||||
# actually restarts -- otherwise the runtime would silently
|
# actually restarts -- otherwise the runtime would silently
|
||||||
# switch into the transient AND-mode this commit exists to
|
# switch into the transient AND-mode this commit exists to
|
||||||
# prevent.
|
# prevent.
|
||||||
|
adapter_results = None
|
||||||
if not restart_required and getattr(request.app.state, "config", None) is not None:
|
if not restart_required and getattr(request.app.state, "config", None) is not None:
|
||||||
try:
|
try:
|
||||||
setattr(request.app.state.config, section, new_value)
|
setattr(request.app.state.config, section, new_value)
|
||||||
|
|
@ -179,17 +185,35 @@ async def update_config_section(section: str, request: Request):
|
||||||
pass
|
pass
|
||||||
if section == "context":
|
if section == "context":
|
||||||
_refresh_mesh_context(request.app, new_value)
|
_refresh_mesh_context(request.app, new_value)
|
||||||
|
elif section == "environmental":
|
||||||
|
adapter_results = _refresh_environmental(request.app, new_value)
|
||||||
|
elif section == "generic_sources":
|
||||||
|
current_env_cfg = getattr(request.app.state.config, "environmental", None)
|
||||||
|
adapter_results = _refresh_environmental(
|
||||||
|
request.app, current_env_cfg, generic_sources=new_value)
|
||||||
|
|
||||||
|
# A specific adapter's feed_source flip to/from "central" still
|
||||||
|
# needs a restart (CentralConsumer (un)subscribe is boot-only) --
|
||||||
|
# apply_config() reports it per-adapter rather than by blanket
|
||||||
|
# section membership, so surface it here.
|
||||||
|
if adapter_results and any(
|
||||||
|
v == "restart_required" for v in adapter_results.values()):
|
||||||
|
restart_required = True
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Config section %r updated, restart_required=%s changed_keys=%s",
|
"Config section %r updated, restart_required=%s changed_keys=%s%s",
|
||||||
section, restart_required, changed_keys,
|
section, restart_required, changed_keys,
|
||||||
|
f" adapter_results={adapter_results}" if adapter_results is not None else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
response = {
|
||||||
"saved": True,
|
"saved": True,
|
||||||
"restart_required": restart_required,
|
"restart_required": restart_required,
|
||||||
"changed_keys": changed_keys,
|
"changed_keys": changed_keys,
|
||||||
}
|
}
|
||||||
|
if adapter_results is not None:
|
||||||
|
response["adapter_results"] = adapter_results
|
||||||
|
return response
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=422, detail=str(e))
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
|
@ -261,6 +285,34 @@ def _refresh_toggle_filter(app) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_environmental(app, new_env_cfg, generic_sources=None):
|
||||||
|
"""Best-effort live hot-reload of the running EnvironmentalStore after an
|
||||||
|
"environmental" or "generic_sources" config PUT. Delegates the actual
|
||||||
|
diff-and-swap to ``EnvironmentalStore.apply_config()`` (env/store.py):
|
||||||
|
only adapters whose OWN config changed are rebuilt in place; everything
|
||||||
|
else -- including store-level dedup/seen state -- is left untouched.
|
||||||
|
|
||||||
|
``generic_sources``, when omitted, defaults to the live config's current
|
||||||
|
list (an "environmental" PUT doesn't touch generic_sources); a
|
||||||
|
"generic_sources" PUT passes its own new value explicitly.
|
||||||
|
|
||||||
|
Returns the ``{adapter_name: "reloaded"|"unchanged"|"restart_required"}``
|
||||||
|
map from ``apply_config()``, or ``None`` when the store isn't up yet
|
||||||
|
(environmental feeds disabled, or early startup/tests). Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
store = getattr(app.state, "env_store", None)
|
||||||
|
if store is None or new_env_cfg is None:
|
||||||
|
return None
|
||||||
|
if generic_sources is None:
|
||||||
|
config = getattr(app.state, "config", None)
|
||||||
|
generic_sources = getattr(config, "generic_sources", None) if config else None
|
||||||
|
return store.apply_config(new_env_cfg, generic_sources=generic_sources)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("environmental store refresh failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _refresh_mesh_context(app, new_ctx_cfg) -> bool:
|
def _refresh_mesh_context(app, new_ctx_cfg) -> bool:
|
||||||
"""Best-effort live refresh of the running MeshContext after a context
|
"""Best-effort live refresh of the running MeshContext after a context
|
||||||
config PUT. Returns True when the refresh actually fired, False if the
|
config PUT. Returns True when the refresh actually fired, False if the
|
||||||
|
|
|
||||||
285
work/meshai/env/store.py
vendored
285
work/meshai/env/store.py
vendored
|
|
@ -155,64 +155,33 @@ class EnvironmentalStore:
|
||||||
# on a LATER poll broadcast. Keyed by the bare configured source name.
|
# on a LATER poll broadcast. Keyed by the bare configured source name.
|
||||||
self._generic_seeded: set[str] = set()
|
self._generic_seeded: set[str] = set()
|
||||||
|
|
||||||
# Create adapter instances with error isolation
|
# Per-adapter config snapshot, keyed by the EnvironmentalConfig field
|
||||||
self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
|
# name (e.g. "nws", "fires", "satpass" -- NOT the adapter registration
|
||||||
lambda cfg: (cfg, self._coverage_for("nws")))
|
# name; "satpass" backs BOTH the satpass_tle and satpass adapters).
|
||||||
self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter",
|
# Captured for every registry entry regardless of enabled/feed_source
|
||||||
lambda cfg: (cfg,))
|
# so a later apply_config() call can detect what changed, including
|
||||||
self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter",
|
# an adapter that starts out disabled/central and is later flipped
|
||||||
lambda cfg: (cfg, self._coverage_for("ducting")))
|
# on. Read by apply_config()'s diff-and-swap hot-reload path.
|
||||||
self._register_adapter("nifc", config.fires, ".fires", "NICFFiresAdapter",
|
self._configs: dict = {}
|
||||||
lambda cfg: (cfg, self._region_anchors, self._coverage_for("fires")))
|
|
||||||
self._register_adapter("avalanche", config.avalanche, ".avalanche", "AvalancheAdapter",
|
# Create adapter instances with error isolation. The registry table
|
||||||
lambda cfg: (cfg, self._coverage_for("avalanche")))
|
# (name, config field, module, class, arg-builder) is shared with
|
||||||
self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter",
|
# apply_config()'s hot-reload path so both go through the exact same
|
||||||
lambda cfg: (cfg, self._coverage_for("usgs")))
|
# construction logic.
|
||||||
self._register_adapter("usgs_quake", config.usgs_quake, ".usgs_quake", "USGSQuakeAdapter",
|
for _name, _field, _mod, _cls, _args_fn in self._adapter_registry():
|
||||||
lambda cfg: (cfg, self._coverage_for("usgs_quake")))
|
_cfg = getattr(config, _field)
|
||||||
self._register_adapter("traffic", config.traffic, ".traffic", "TomTomTrafficAdapter",
|
self._configs[_field] = _cfg
|
||||||
lambda cfg: (cfg, self._coverage_for("traffic")))
|
self._register_adapter(_name, _cfg, _mod, _cls, _args_fn)
|
||||||
self._register_adapter("roads511", config.roads511, ".roads511", "Roads511Adapter",
|
|
||||||
lambda cfg: (cfg, self._coverage_for("roads511")))
|
|
||||||
self._register_adapter("wzdx", config.wzdx, ".wzdx", "WZDxAdapter",
|
|
||||||
lambda cfg: (cfg, self._coverage_for("wzdx")))
|
|
||||||
# Native satpass TLE fetcher (storage-only: populates sat_tles, emits
|
|
||||||
# no events). Gated on satpass.feed_source=="native" like the rest.
|
|
||||||
self._register_adapter("satpass_tle", config.satpass, ".tle_fetch", "TLEFetchAdapter",
|
|
||||||
lambda cfg: (cfg,))
|
|
||||||
# Native SGP4 pass predictor (broadcasts consolidated passes locally,
|
|
||||||
# no Central dependency). SEPARATE from the satpass_tle fetcher above;
|
|
||||||
# both are gated on satpass.enabled and feed_source=="native".
|
|
||||||
self._register_adapter("satpass", config.satpass, ".satpass", "SatpassAdapter",
|
|
||||||
lambda cfg: (cfg,))
|
|
||||||
|
|
||||||
# FIRMS needs reference to NIFC adapter for cross-referencing
|
# FIRMS needs reference to NIFC adapter for cross-referencing
|
||||||
if config.firms.enabled and config.firms.feed_source == "native":
|
self._configs["firms"] = config.firms
|
||||||
try:
|
self._construct_firms(config.firms)
|
||||||
from .firms import FIRMSAdapter
|
|
||||||
fires_adapter = self._adapters.get("nifc")
|
|
||||||
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter, coverage=self._coverage_for("firms"))
|
|
||||||
self._adapters["firms"] = self._firms
|
|
||||||
except Exception as e:
|
|
||||||
err_msg = f"{type(e).__name__}: {e}"
|
|
||||||
logger.warning("Failed to initialize firms adapter: %s", err_msg)
|
|
||||||
self._failed_adapters["firms"] = err_msg
|
|
||||||
|
|
||||||
# Universal config-driven REST/GeoJSON sources. ONE adapter instance
|
# Universal config-driven REST/GeoJSON sources. ONE adapter instance
|
||||||
# handles every source in config.generic_sources; construct it only
|
# handles every source in config.generic_sources; construct it only
|
||||||
# when at least one source is configured. Guarded like firms so a bad
|
# when at least one source is configured. Guarded like firms so a bad
|
||||||
# import/config never takes the whole store down.
|
# import/config never takes the whole store down.
|
||||||
if self._generic_sources:
|
self._construct_generic(self._generic_sources)
|
||||||
try:
|
|
||||||
from .generic_http import GenericHttpAdapter
|
|
||||||
self._generic = GenericHttpAdapter(
|
|
||||||
self._generic_sources,
|
|
||||||
self._coverage_for("generic_http"))
|
|
||||||
self._adapters["generic_http"] = self._generic
|
|
||||||
except Exception as e:
|
|
||||||
err_msg = f"{type(e).__name__}: {e}"
|
|
||||||
logger.warning("Failed to initialize generic_http adapter: %s", err_msg)
|
|
||||||
self._failed_adapters["generic_http"] = err_msg
|
|
||||||
|
|
||||||
_central = [n for n in ("nws", "swpc", "ducting", "fires", "avalanche", "usgs", "usgs_quake", "traffic", "roads511", "wzdx", "firms", "satpass")
|
_central = [n for n in ("nws", "swpc", "ducting", "fires", "avalanche", "usgs", "usgs_quake", "traffic", "roads511", "wzdx", "firms", "satpass")
|
||||||
if getattr(getattr(config, n, None), "feed_source", "native") == "central"]
|
if getattr(getattr(config, n, None), "feed_source", "native") == "central"]
|
||||||
|
|
@ -228,6 +197,56 @@ class EnvironmentalStore:
|
||||||
self._seed_from_persistent()
|
self._seed_from_persistent()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter_registry(self):
|
||||||
|
"""(name, config field, module path, class name, arg-builder) table
|
||||||
|
driving BOTH __init__'s adapter construction and apply_config()'s
|
||||||
|
diff-and-swap hot-reload -- the single source of truth for how each
|
||||||
|
named adapter is built from its config. Not a module-level constant:
|
||||||
|
several arg-builders close over ``self`` (``self._coverage_for``,
|
||||||
|
``self._region_anchors``), which must reflect the store's OWN
|
||||||
|
coverage/region state (not a stale copy) on every call, including a
|
||||||
|
rebuild long after __init__.
|
||||||
|
|
||||||
|
``config field`` is the ``EnvironmentalConfig`` attribute name the
|
||||||
|
adapter is built from -- e.g. adapter "nifc" is built from
|
||||||
|
``config.fires``, and BOTH the "satpass_tle" and "satpass" adapters
|
||||||
|
are built from the single ``config.satpass`` field (a change there
|
||||||
|
must rebuild both).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
("nws", "nws", ".nws", "NWSAlertsAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("nws"))),
|
||||||
|
("swpc", "swpc", ".swpc", "SWPCAdapter",
|
||||||
|
lambda cfg: (cfg,)),
|
||||||
|
("ducting", "ducting", ".ducting", "DuctingAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("ducting"))),
|
||||||
|
("nifc", "fires", ".fires", "NICFFiresAdapter",
|
||||||
|
lambda cfg: (cfg, self._region_anchors, self._coverage_for("fires"))),
|
||||||
|
("avalanche", "avalanche", ".avalanche", "AvalancheAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("avalanche"))),
|
||||||
|
("usgs", "usgs", ".usgs", "USGSStreamsAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("usgs"))),
|
||||||
|
("usgs_quake", "usgs_quake", ".usgs_quake", "USGSQuakeAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("usgs_quake"))),
|
||||||
|
("traffic", "traffic", ".traffic", "TomTomTrafficAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("traffic"))),
|
||||||
|
("roads511", "roads511", ".roads511", "Roads511Adapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("roads511"))),
|
||||||
|
("wzdx", "wzdx", ".wzdx", "WZDxAdapter",
|
||||||
|
lambda cfg: (cfg, self._coverage_for("wzdx"))),
|
||||||
|
# Native satpass TLE fetcher (storage-only: populates sat_tles,
|
||||||
|
# emits no events). Gated on satpass.feed_source=="native" like
|
||||||
|
# the rest.
|
||||||
|
("satpass_tle", "satpass", ".tle_fetch", "TLEFetchAdapter",
|
||||||
|
lambda cfg: (cfg,)),
|
||||||
|
# Native SGP4 pass predictor (broadcasts consolidated passes
|
||||||
|
# locally, no Central dependency). SEPARATE from the satpass_tle
|
||||||
|
# fetcher above; both are gated on satpass.enabled and
|
||||||
|
# feed_source=="native".
|
||||||
|
("satpass", "satpass", ".satpass", "SatpassAdapter",
|
||||||
|
lambda cfg: (cfg,)),
|
||||||
|
]
|
||||||
|
|
||||||
def _register_adapter(self, name: str, cfg, module_path: str, class_name: str, args_fn):
|
def _register_adapter(self, name: str, cfg, module_path: str, class_name: str, args_fn):
|
||||||
"""Register a single adapter with error isolation."""
|
"""Register a single adapter with error isolation."""
|
||||||
if not cfg.enabled or cfg.feed_source != "native":
|
if not cfg.enabled or cfg.feed_source != "native":
|
||||||
|
|
@ -241,6 +260,170 @@ class EnvironmentalStore:
|
||||||
logger.warning("Failed to initialize %s adapter: %s", name, err_msg)
|
logger.warning("Failed to initialize %s adapter: %s", name, err_msg)
|
||||||
self._failed_adapters[name] = err_msg
|
self._failed_adapters[name] = err_msg
|
||||||
|
|
||||||
|
def _construct_firms(self, cfg) -> None:
|
||||||
|
"""(Re)build the "firms" adapter from ``cfg`` (a FIRMSConfig), wiring
|
||||||
|
the CURRENT "nifc" adapter instance in as its cross-reference. Shared
|
||||||
|
by __init__ and apply_config()'s firms cascade so both paths build it
|
||||||
|
identically. No-ops (leaves ``self._adapters``/``self._failed_adapters``
|
||||||
|
untouched for "firms") when disabled or not native -- callers that
|
||||||
|
need a clean slot for a rebuild must pop it first.
|
||||||
|
"""
|
||||||
|
if not (cfg.enabled and cfg.feed_source == "native"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from .firms import FIRMSAdapter
|
||||||
|
fires_adapter = self._adapters.get("nifc")
|
||||||
|
self._firms = FIRMSAdapter(
|
||||||
|
cfg, self._region_anchors, fires_adapter,
|
||||||
|
coverage=self._coverage_for("firms"))
|
||||||
|
self._adapters["firms"] = self._firms
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = f"{type(e).__name__}: {e}"
|
||||||
|
logger.warning("Failed to initialize firms adapter: %s", err_msg)
|
||||||
|
self._failed_adapters["firms"] = err_msg
|
||||||
|
|
||||||
|
def _construct_generic(self, sources: list) -> None:
|
||||||
|
"""(Re)build the single "generic_http" adapter from ``sources`` (the
|
||||||
|
full ``config.generic_sources`` list) -- one instance handles every
|
||||||
|
configured source. Shared by __init__ and apply_config(). No-ops
|
||||||
|
(leaves any existing "generic_http" slot untouched) when ``sources``
|
||||||
|
is empty -- callers that need a clean slot for a rebuild must pop it
|
||||||
|
first.
|
||||||
|
"""
|
||||||
|
if not sources:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from .generic_http import GenericHttpAdapter
|
||||||
|
self._generic = GenericHttpAdapter(
|
||||||
|
sources, self._coverage_for("generic_http"))
|
||||||
|
self._adapters["generic_http"] = self._generic
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = f"{type(e).__name__}: {e}"
|
||||||
|
logger.warning("Failed to initialize generic_http adapter: %s", err_msg)
|
||||||
|
self._failed_adapters["generic_http"] = err_msg
|
||||||
|
|
||||||
|
def apply_config(self, new_cfg: "EnvironmentalConfig", *, generic_sources: list = None) -> dict:
|
||||||
|
"""Hot-reload the environmental config: rebuild only what CHANGED.
|
||||||
|
|
||||||
|
Called from the dashboard's config PUT handler in place of the old
|
||||||
|
restart-required rule. For each named adapter (per
|
||||||
|
``_adapter_registry()``):
|
||||||
|
|
||||||
|
* unchanged config -> the adapter object is left completely alone
|
||||||
|
(identity preserved); result ``"unchanged"``.
|
||||||
|
* changed config, still (or newly) ``feed_source == "native"`` ->
|
||||||
|
rebuilt in place via the SAME construction path __init__ uses;
|
||||||
|
result ``"reloaded"``. This covers both a field edit (e.g.
|
||||||
|
``feed_url``) on an already-native adapter AND flipping
|
||||||
|
``enabled`` off (the adapter is simply dropped) or on.
|
||||||
|
* ``feed_source`` toggled to/from ``"central"`` -> NOT hot-swapped
|
||||||
|
(Central (un)subscribe only happens at boot -- see
|
||||||
|
config_routes.RESTART_REQUIRED_SECTIONS' historical comment);
|
||||||
|
result ``"restart_required"``.
|
||||||
|
|
||||||
|
Store-level dedup/seen state (``self._seen``, ``self._seeded``,
|
||||||
|
``self._events``, ``self._fires_seeded``, ...) is keyed by event
|
||||||
|
SOURCE, not by adapter object, and is never touched here -- swapping
|
||||||
|
an adapter instance preserves it automatically.
|
||||||
|
|
||||||
|
The "firms" adapter holds a hard reference to the "nifc" adapter
|
||||||
|
instance, so whenever "nifc" is rebuilt, "firms" is ALSO rebuilt
|
||||||
|
(even if firms' own config is unchanged) to re-fetch a fresh
|
||||||
|
reference -- otherwise it would orphan a stale nifc object.
|
||||||
|
|
||||||
|
``generic_sources``, when given and different from the store's
|
||||||
|
current list, rebuilds the single GenericHttpAdapter wholesale (it
|
||||||
|
holds all generic sources as one instance).
|
||||||
|
|
||||||
|
Returns a ``{name: "reloaded"|"unchanged"|"restart_required"}`` map
|
||||||
|
covering every named adapter plus "firms" and "generic_http".
|
||||||
|
"""
|
||||||
|
old_configs = dict(self._configs) # snapshot BEFORE any mutation
|
||||||
|
result: dict = {}
|
||||||
|
nifc_reloaded = False
|
||||||
|
|
||||||
|
for name, field, mod, cls, args_fn in self._adapter_registry():
|
||||||
|
cfg_new = getattr(new_cfg, field)
|
||||||
|
cfg_old = old_configs.get(field)
|
||||||
|
outcome = self._apply_named_adapter(
|
||||||
|
name, field, mod, cls, args_fn, cfg_new, cfg_old)
|
||||||
|
result[name] = outcome
|
||||||
|
if name == "nifc" and outcome == "reloaded":
|
||||||
|
nifc_reloaded = True
|
||||||
|
|
||||||
|
result["firms"] = self._apply_firms(
|
||||||
|
new_cfg.firms, old_configs.get("firms"), force=nifc_reloaded)
|
||||||
|
|
||||||
|
if generic_sources is not None and list(generic_sources) != self._generic_sources:
|
||||||
|
self._adapters.pop("generic_http", None)
|
||||||
|
self._failed_adapters.pop("generic_http", None)
|
||||||
|
self._generic_sources = list(generic_sources)
|
||||||
|
self._construct_generic(self._generic_sources)
|
||||||
|
result["generic_http"] = "reloaded"
|
||||||
|
else:
|
||||||
|
result["generic_http"] = "unchanged"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _feed_source_of(cfg) -> str:
|
||||||
|
return getattr(cfg, "feed_source", "native") if cfg is not None else "native"
|
||||||
|
|
||||||
|
def _apply_named_adapter(self, name, field, mod, cls, args_fn, cfg_new, cfg_old) -> str:
|
||||||
|
"""Diff one registry entry's config and rebuild in place iff needed."""
|
||||||
|
if cfg_new == cfg_old:
|
||||||
|
return "unchanged"
|
||||||
|
|
||||||
|
old_source = self._feed_source_of(cfg_old)
|
||||||
|
new_source = self._feed_source_of(cfg_new)
|
||||||
|
if old_source != new_source and "central" in (old_source, new_source):
|
||||||
|
# Crossing the Central boundary either direction: CentralConsumer
|
||||||
|
# (un)subscribe only happens at boot. Leave both the adapter AND
|
||||||
|
# self._configs[field] untouched so this keeps reporting
|
||||||
|
# restart_required on every subsequent PUT until an actual
|
||||||
|
# restart resolves it.
|
||||||
|
return "restart_required"
|
||||||
|
|
||||||
|
self._configs[field] = cfg_new
|
||||||
|
if new_source != "native":
|
||||||
|
# Stayed central (or some other non-native source) both before
|
||||||
|
# and after -- no native adapter exists for this name and none
|
||||||
|
# should; nothing to hot-reload.
|
||||||
|
return "unchanged"
|
||||||
|
|
||||||
|
# Drop any existing instance first: a disable (enabled=False) or a
|
||||||
|
# config that otherwise no longer qualifies must not leave a stale
|
||||||
|
# adapter object running, and _register_adapter() itself is a no-op
|
||||||
|
# (doesn't touch self._adapters) when the new config isn't
|
||||||
|
# enabled+native.
|
||||||
|
self._adapters.pop(name, None)
|
||||||
|
self._failed_adapters.pop(name, None)
|
||||||
|
self._register_adapter(name, cfg_new, mod, cls, args_fn)
|
||||||
|
return "reloaded"
|
||||||
|
|
||||||
|
def _apply_firms(self, cfg_new, cfg_old, *, force: bool) -> str:
|
||||||
|
"""Diff+rebuild the "firms" adapter; ``force=True`` (nifc was just
|
||||||
|
rebuilt) always rebuilds so firms re-fetches a fresh nifc reference,
|
||||||
|
even when firms' own config is byte-identical."""
|
||||||
|
own_changed = cfg_new != cfg_old
|
||||||
|
if not (own_changed or force):
|
||||||
|
return "unchanged"
|
||||||
|
|
||||||
|
# Only a genuine change to firms' OWN config can cross the Central
|
||||||
|
# boundary -- a pure nifc-cascade rebuild (own_changed=False) must
|
||||||
|
# never spuriously demand a restart for an untouched firms config.
|
||||||
|
if own_changed:
|
||||||
|
old_source = self._feed_source_of(cfg_old)
|
||||||
|
new_source = self._feed_source_of(cfg_new)
|
||||||
|
if old_source != new_source and "central" in (old_source, new_source):
|
||||||
|
return "restart_required"
|
||||||
|
self._configs["firms"] = cfg_new
|
||||||
|
|
||||||
|
self._adapters.pop("firms", None)
|
||||||
|
self._failed_adapters.pop("firms", None)
|
||||||
|
self._construct_firms(cfg_new)
|
||||||
|
return "reloaded"
|
||||||
|
|
||||||
def _coverage_for(self, adapter: str):
|
def _coverage_for(self, adapter: str):
|
||||||
"""Derived coverage scope for a NATIVE adapter, or None to use its own config (override/fallback).
|
"""Derived coverage scope for a NATIVE adapter, or None to use its own config (override/fallback).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -706,6 +706,16 @@ class MeshAI:
|
||||||
else:
|
else:
|
||||||
self.env_store = None
|
self.env_store = None
|
||||||
|
|
||||||
|
# v0.16-env-hot-reload: expose the store to the dashboard API so a
|
||||||
|
# config PUT to /api/config/environmental (or /generic_sources) can
|
||||||
|
# hot-reload it live -- mirrors the .state.bus/.state.config stash
|
||||||
|
# above.
|
||||||
|
try:
|
||||||
|
from meshai.dashboard.server import app as _dash_app
|
||||||
|
_dash_app.state.env_store = self.env_store
|
||||||
|
except Exception:
|
||||||
|
logger.debug('dashboard app.state env_store stash skipped')
|
||||||
|
|
||||||
# Knowledge base (optional - Qdrant with SQLite fallback)
|
# Knowledge base (optional - Qdrant with SQLite fallback)
|
||||||
kb_cfg = self.config.knowledge
|
kb_cfg = self.config.knowledge
|
||||||
self.knowledge = None
|
self.knowledge = None
|
||||||
|
|
|
||||||
179
work/tests/test_env_hot_reload.py
Normal file
179
work/tests/test_env_hot_reload.py
Normal 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
|
||||||
|
|
@ -98,17 +98,42 @@ def config_app(tmp_path, monkeypatch):
|
||||||
return app
|
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
|
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):
|
def test_put_environmental_feed_source_to_central_still_requires_restart(tmp_path):
|
||||||
"""A PUT to environmental that changes feed_source returns
|
"""The one surviving restart case: flipping an adapter's feed_source to
|
||||||
restart_required=true + the dotted changed_keys list."""
|
"central" is boot-only (CentralConsumer subscribe). apply_config reports it
|
||||||
client = TestClient(config_app)
|
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")
|
cur = client.get("/api/config/environmental")
|
||||||
assert cur.status_code == 200
|
assert cur.status_code == 200
|
||||||
body = cur.json()
|
body = cur.json()
|
||||||
|
|
@ -120,12 +145,31 @@ def test_put_environmental_returns_restart_required_with_changed_keys(config_app
|
||||||
result = r.json()
|
result = r.json()
|
||||||
assert result["saved"] is True
|
assert result["saved"] is True
|
||||||
assert result["restart_required"] 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"]), (
|
assert any(k.endswith("firms.feed_source") for k in result["changed_keys"]), (
|
||||||
f"changed_keys missing firms.feed_source: {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):
|
def test_put_non_restart_section_returns_restart_required_false(config_app):
|
||||||
"""A PUT to bot (not restart-required) returns restart_required=false."""
|
"""A PUT to bot (not restart-required) returns restart_required=false."""
|
||||||
client = TestClient(config_app)
|
client = TestClient(config_app)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue