feat: best-effort mirror (dual-send) of heartbeats + events

Optional second destination so each heartbeat and event is posted to a
mirror server (the office NAS) alongside the primary — dual-write to
de-risk the migration. Default off (blank mirror URLs); existing installs
unchanged.

- event_forwarder: mirror_reachable() fast-fail probe + mirror_forward_pass()
  (reliable event mirror with its OWN state file, total exception isolation,
  never raises into the primary path).
- series4_ingest: mirror_api_url/mirror_sfm_url/mirror_sfm_state_file config;
  best-effort heartbeat mirror; isolated event-mirror pass after the primary.
- settings dialog: new 'Mirror' tab.
- 9 new tests incl. the isolation invariant (down mirror = fast no-op that
  never touches primary state). 42 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 23:10:50 +00:00
parent dae67afeb9
commit 7d22442bad
5 changed files with 328 additions and 1 deletions
+67
View File
@@ -47,6 +47,11 @@ DEFAULTS = {
"sfm_state_file": "",
"sfm_max_forwards_per_pass": 500,
"sfm_max_event_age_days": 365,
# Mirror (dual-send) — best-effort second destination, default OFF.
"mirror_api_url": "",
"mirror_sfm_url": "",
"mirror_sfm_state_file": "",
}
@@ -220,6 +225,14 @@ class SettingsDialog:
self.var_sfm_max_age_days = tk.StringVar(value=str(v.get("sfm_max_event_age_days", 365)))
self.var_sfm_state_file = tk.StringVar(value=str(v.get("sfm_state_file", "")))
# Mirror (dual-send) — best-effort second destination
raw_mirror = str(v.get("mirror_api_url", ""))
if raw_mirror.endswith(_suffix):
raw_mirror = raw_mirror[:-len(_suffix)]
self.var_mirror_api_url = tk.StringVar(value=raw_mirror)
self.var_mirror_sfm_url = tk.StringVar(value=str(v.get("mirror_sfm_url", "")))
self.var_mirror_sfm_state_file = tk.StringVar(value=str(v.get("mirror_sfm_state_file", "")))
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
@@ -245,6 +258,7 @@ class SettingsDialog:
self._build_tab_scanning(nb)
self._build_tab_logging(nb)
self._build_tab_forwarding(nb)
self._build_tab_mirror(nb)
self._build_tab_updates(nb)
btn_frame = tk.Frame(outer)
@@ -484,6 +498,49 @@ class SettingsDialog:
finally:
self._sfm_test_btn.config(state="normal")
def _build_tab_mirror(self, nb):
f = self._tab_frame(nb, "Mirror")
intro = (
"Optional dual-send: post each heartbeat and event to a SECOND\n"
"(\"mirror\") server in addition to the primary. Best-effort — the\n"
"mirror can never delay or fail the primary. Leave blank to disable."
)
tk.Label(f, text=intro, justify="left", fg="#1a5276", wraplength=420).grid(
row=0, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(6, 8)
)
_add_label_entry(f, 1, "Mirror Terra-View URL", self.var_mirror_api_url)
_add_label_entry(f, 2, "Mirror SFM URL", self.var_mirror_sfm_url)
def browse_mirror_state():
p = filedialog.asksaveasfilename(
title="Select Mirror State File",
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialfile=os.path.basename(
self.var_mirror_sfm_state_file.get() or "thor_forwarded_mirror.json"),
initialdir=os.path.dirname(self.var_mirror_sfm_state_file.get() or "C:\\"),
)
if p:
self.var_mirror_sfm_state_file.set(p.replace("/", "\\"))
_add_label_browse_entry(f, 3, "Mirror State File", self.var_mirror_sfm_state_file,
browse_mirror_state)
help_text = (
"Mirror Terra-View URL → base like http://10.0.0.x:8001 (heartbeats).\n"
"Mirror SFM URL → base like http://10.0.0.x:8200 (events).\n"
"The event mirror keeps its OWN state file, so nothing is lost while\n"
"the mirror is down — pending events are delivered once it returns.\n"
"A quick reachability check skips the mirror cleanly when unreachable,\n"
"so the primary is never slowed.\n"
"State file blank → defaults to <log_dir>\\thor_forwarded_mirror.json."
)
tk.Label(f, text=help_text, justify="left", fg="#555555", wraplength=420).grid(
row=4, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)
)
def _build_tab_updates(self, nb):
f = self._tab_frame(nb, "Updates")
@@ -599,6 +656,12 @@ class SettingsDialog:
sfm_url = ""
sfm_url = sfm_url.rstrip("/") # event_forwarder adds the endpoint path
# Mirror (dual-send) — best-effort second destination.
mirror_api_url = self.var_mirror_api_url.get().strip()
if mirror_api_url:
mirror_api_url = mirror_api_url.rstrip("/") + "/api/series4/heartbeat"
mirror_sfm_url = self.var_mirror_sfm_url.get().strip().rstrip("/")
values = {
"thordata_path": self.var_thordata_path.get().strip(),
"scan_interval": int_values["Scan Interval"],
@@ -623,6 +686,10 @@ class SettingsDialog:
"sfm_max_forwards_per_pass": int_values["Max Forwards Per Pass"],
"sfm_max_event_age_days": int_values["Max Event Age (days)"],
"sfm_state_file": self.var_sfm_state_file.get().strip(),
"mirror_api_url": mirror_api_url,
"mirror_sfm_url": mirror_sfm_url,
"mirror_sfm_state_file": self.var_mirror_sfm_state_file.get().strip(),
}
try: