feat(forward): rate cap + seed-state mode for safe backfill (v1.5.2)

Two safety nets for first-deploy on Blastware ACH machines that
have accumulated tens or hundreds of thousands of historical events
in the watch folder.

1. SFM_MAX_FORWARDS_PER_PASS (default 500, 0=unlimited)
   ---------------------------------------------------
   Cap on the number of events forwarded per scan tick.  At the
   60-second default interval that's ~30K events/hour throughput —
   the SFM server gets a steady drip instead of one giant burst.
   Scan now sorts by mtime ascending so backfill advances
   chronologically (oldest first) and successive scans always
   make progress instead of re-considering the same N newest files.

   Wired into:
     - event_forwarder.find_pending_events / forward_pending
     - series3_watcher.run_watcher loop
     - config-template.ini
     - settings_dialog SFM Forward tab (new "Max Events Per Pass"
       spinbox, validated in _on_save)

2. event_forwarder.py --seed-state CLI
   -----------------------------------
   One-shot mode that walks the watch folder, sha256s every in-window
   event binary, and marks them all as already-forwarded WITHOUT
   POSTing anything.  Run before flipping SFM_FORWARD_ENABLED=true
   to skip the historical backfill entirely — the watcher then only
   forwards events that appear AFTER the seed.

   Usage:
       python event_forwarder.py --seed-state \
           --watch "C:\Blastware 10\Event\autocall home" \
           --state "C:\...\sfm_forwarded.json" \
           [--max-age-days 365]

7 new unit tests:
  - max_per_pass cap enforcement (=N, =0 unlimited, oldest-first
    ordering)
  - seed-state mode (in-window seeding, max-age skip,
    end-to-end skip-after-seed, idempotent re-runs)

README adds a "First-time deployment" section walking through both
options.  Bumps to v1.5.2.
This commit is contained in:
2026-05-10 00:20:10 +00:00
parent 3ee0cae31e
commit 815c643fb2
9 changed files with 390 additions and 15 deletions
+12 -7
View File
@@ -1,5 +1,5 @@
"""
Series 3 Watcher — Settings Dialog v1.5.1
Series 3 Watcher — Settings Dialog v1.5.2
Provides a Tkinter settings dialog that doubles as a first-run wizard.
@@ -52,6 +52,7 @@ DEFAULTS = {
"SFM_MISSING_REPORT_GRACE_SECONDS": "60",
"SFM_HTTP_TIMEOUT": "60",
"SFM_STATE_FILE": "",
"SFM_MAX_FORWARDS_PER_PASS": "500",
}
@@ -257,6 +258,7 @@ class SettingsDialog:
self.var_sfm_missing_report_grace = tk.StringVar(value=v["SFM_MISSING_REPORT_GRACE_SECONDS"])
self.var_sfm_http_timeout = tk.StringVar(value=v["SFM_HTTP_TIMEOUT"])
self.var_sfm_state_file = tk.StringVar(value=v["SFM_STATE_FILE"])
self.var_sfm_max_per_pass = tk.StringVar(value=v["SFM_MAX_FORWARDS_PER_PASS"])
# --- UI construction ---
@@ -522,15 +524,16 @@ class SettingsDialog:
self._sfm_test_status.grid(row=0, column=2, padx=(6, 0))
_add_label_spinbox(f, 2, "Forward Interval (sec)", self.var_sfm_forward_interval, 5, 3600)
_add_label_spinbox(f, 3, "Quiescence (sec)", self.var_sfm_quiescence, 1, 60)
_add_label_spinbox(f, 4, "Missing-Report Grace (sec)", self.var_sfm_missing_report_grace, 0, 600)
_add_label_spinbox(f, 5, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 600)
_add_label_spinbox(f, 3, "Max Events Per Pass", self.var_sfm_max_per_pass, 0, 100000)
_add_label_spinbox(f, 4, "Quiescence (sec)", self.var_sfm_quiescence, 1, 60)
_add_label_spinbox(f, 5, "Missing-Report Grace (sec)", self.var_sfm_missing_report_grace, 0, 600)
_add_label_spinbox(f, 6, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 600)
tk.Label(f, text="State File", anchor="w").grid(
row=6, column=0, sticky="w", padx=(8, 4), pady=4
row=7, column=0, sticky="w", padx=(8, 4), pady=4
)
state_frame = tk.Frame(f)
state_frame.grid(row=6, column=1, sticky="ew", padx=(0, 8), pady=4)
state_frame.grid(row=7, column=1, sticky="ew", padx=(0, 8), pady=4)
state_frame.columnconfigure(0, weight=1)
state_entry = ttk.Entry(state_frame, textvariable=self.var_sfm_state_file, width=32)
@@ -560,7 +563,7 @@ class SettingsDialog:
"to default to <log dir>/sfm_forwarded.json."
)
tk.Label(f, text=hint_text, justify="left", fg="#555555", wraplength=380).grid(
row=7, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)
row=8, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)
)
def _test_sfm_connection(self):
@@ -637,6 +640,7 @@ class SettingsDialog:
(self.var_sfm_quiescence, "SFM Quiescence", 1, 60, 5),
(self.var_sfm_missing_report_grace, "SFM Missing-Report Grace", 0, 600, 60),
(self.var_sfm_http_timeout, "SFM HTTP Timeout", 5, 600, 60),
(self.var_sfm_max_per_pass, "SFM Max Events Per Pass", 0, 100000, 500),
]
int_values = {}
for var, name, mn, mx, dflt in checks:
@@ -693,6 +697,7 @@ class SettingsDialog:
"SFM_MISSING_REPORT_GRACE_SECONDS": str(int_values["SFM Missing-Report Grace"]),
"SFM_HTTP_TIMEOUT": str(int_values["SFM HTTP Timeout"]),
"SFM_STATE_FILE": self.var_sfm_state_file.get().strip(),
"SFM_MAX_FORWARDS_PER_PASS": str(int_values["SFM Max Events Per Pass"]),
}
try: