Skip to content

Commit 0298b14

Browse files
fix(output): serialise status recording across threads
Component threads (GE Cloud, Solis, ...) call record_status() alongside the main loop. The error_count attribute is a read-modify-write of the sensor's own state, so two concurrent calls could both read N and both write N+1. record_status() and record_status_under_warning() now share a status lock, which also keeps each call's current_status/status_warning update whole. It does not cover record_final_run_status()'s choice between them, which reads had_errors outside the lock: a component warning landing between that read and the write is still overwritten until the next run, as before this change. The status-change notification is sent after the lock is released: over the websocket it waits on another thread for up to two minutes, which would otherwise stall every other status write. The lock is kept out of the debug dump, which serialises PredBat's attributes and can't pickle a lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aac1786 commit 0298b14

5 files changed

Lines changed: 198 additions & 83 deletions

File tree

‎apps/predbat/output.py‎

Lines changed: 93 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2760,67 +2760,77 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra
27602760
"""
27612761
Records status to HA sensor
27622762
"""
2763-
if not extra:
2764-
extra = ""
2765-
2766-
# A warning that repeats the one already shown behind the previous run's state keeps that
2767-
# state in front of it, rather than flipping the sensor back to the bare warning for the few
2768-
# seconds until this run's own state is put back - two state changes every cycle for as long
2769-
# as the warning recurs. Any different warning or error, including the ones a run records
2770-
# when it bails out without executing, is shown bare.
2771-
state_message = message
2772-
if had_errors and self.current_status and self.current_status != message and self.current_status.endswith(", " + message):
2773-
state_message = self.current_status
2774-
2775-
self.current_status = state_message + extra
2776-
if notify and self.previous_status != message and self.set_status_notify:
2777-
if self.had_errors and had_errors:
2778-
# Already in error state, do not notify second error in a single run (spam)
2779-
pass
2780-
else:
2781-
self.call_notify(f"{self.prefix.capitalize()} status change to: {message}{extra}")
2782-
self.previous_status = message
2783-
2784-
error_count = self.get_state_wrapper(self.prefix + ".status", attribute="error_count", default=0)
2785-
try:
2786-
error_count = int(error_count)
2787-
except (ValueError, TypeError):
2788-
error_count = 0
2789-
2790-
if had_errors:
2791-
error_count += 1
2763+
# Component threads (GE Cloud, Solis, ...) record status too, so the read-modify-write of
2764+
# error_count and the current_status/status_warning pair must not interleave with the main loop.
2765+
# The sensor write under the lock is a REST call with a timeout, so a slow Home Assistant does
2766+
# hold up other status writes. The notification is sent after the lock is released: over the
2767+
# websocket it waits on another thread for up to two minutes, which would stall them far longer.
2768+
notify_message = None
2769+
with self.status_lock:
2770+
if not extra:
2771+
extra = ""
2772+
2773+
# A warning that repeats the one already shown behind the previous run's state keeps that
2774+
# state in front of it, rather than flipping the sensor back to the bare warning for the few
2775+
# seconds until this run's own state is put back - two state changes every cycle for as long
2776+
# as the warning recurs. Any different warning or error, including the ones a run records
2777+
# when it bails out without executing, is shown bare.
2778+
state_message = message
2779+
if had_errors and self.current_status and self.current_status != message and self.current_status.endswith(", " + message):
2780+
state_message = self.current_status
2781+
2782+
self.current_status = state_message + extra
2783+
if notify and self.previous_status != message and self.set_status_notify:
2784+
if self.had_errors and had_errors:
2785+
# Already in error state, do not notify second error in a single run (spam)
2786+
pass
2787+
else:
2788+
notify_message = f"{self.prefix.capitalize()} status change to: {message}{extra}"
2789+
self.previous_status = message
2790+
2791+
error_count = self.get_state_wrapper(self.prefix + ".status", attribute="error_count", default=0)
2792+
try:
2793+
error_count = int(error_count)
2794+
except (ValueError, TypeError):
2795+
error_count = 0
2796+
2797+
if had_errors:
2798+
error_count += 1
2799+
2800+
# Home Assistant rejects entity states over 255 characters, and this message is the state
2801+
# of the status sensor. Clamp what is written as the state - the full text survives in
2802+
# current_status, the log line and the notification, and attributes have no such cap.
2803+
# Motivated by the window warnings listing every configured inverter component (#4990):
2804+
# three or more of those push past 255, so the dashboard would keep a stale status on
2805+
# exactly the cycles the warning matters.
2806+
self.dashboard_item(
2807+
self.prefix + ".status",
2808+
state=state_message[:255],
2809+
attributes={
2810+
"friendly_name": "Status",
2811+
"detail": extra,
2812+
"icon": "mdi:information",
2813+
"last_updated": self.now_utc_real.strftime(TIME_FORMAT),
2814+
"debug": debug,
2815+
"version": THIS_VERSION_DISPLAY,
2816+
"error": (had_errors or self.had_errors),
2817+
"error_count": error_count,
2818+
},
2819+
)
27922820

2793-
# Home Assistant rejects entity states over 255 characters, and this message is the state
2794-
# of the status sensor. Clamp what is written as the state - the full text survives in
2795-
# current_status, the log line and the notification, and attributes have no such cap.
2796-
# Motivated by the window warnings listing every configured inverter component (#4990):
2797-
# three or more of those push past 255, so the dashboard would keep a stale status on
2798-
# exactly the cycles the warning matters.
2799-
self.dashboard_item(
2800-
self.prefix + ".status",
2801-
state=state_message[:255],
2802-
attributes={
2803-
"friendly_name": "Status",
2804-
"detail": extra,
2805-
"icon": "mdi:information",
2806-
"last_updated": self.now_utc_real.strftime(TIME_FORMAT),
2807-
"debug": debug,
2808-
"version": THIS_VERSION_DISPLAY,
2809-
"error": (had_errors or self.had_errors),
2810-
"error_count": error_count,
2811-
},
2812-
)
2821+
if had_errors:
2822+
self.log("Warn: record_status {}".format(message + extra))
2823+
else:
2824+
self.log("Info: record_status {}".format(message + extra))
28132825

2814-
if had_errors:
2815-
self.log("Warn: record_status {}".format(message + extra))
2816-
else:
2817-
self.log("Info: record_status {}".format(message + extra))
2826+
if had_errors:
2827+
self.had_errors = True
2828+
# Kept so the end of the run can put the state it executed back in front of it
2829+
self.status_warning = message
2830+
self.status_warning_debug = debug
28182831

2819-
if had_errors:
2820-
self.had_errors = True
2821-
# Kept so the end of the run can put the state it executed back in front of it
2822-
self.status_warning = message
2823-
self.status_warning_debug = debug
2832+
if notify_message:
2833+
self.call_notify(notify_message)
28242834

28252835
def record_status_under_warning(self, run_status):
28262836
"""
@@ -2837,29 +2847,31 @@ def record_status_under_warning(self, run_status):
28372847
Does nothing when no warning was recorded this run (e.g. a component thread set had_errors
28382848
on its own), leaving the sensor as it was.
28392849
"""
2840-
if not self.status_warning:
2841-
return
2842-
error_count = self.get_state_wrapper(self.prefix + ".status", attribute="error_count", default=0)
2843-
try:
2844-
error_count = int(error_count)
2845-
except (ValueError, TypeError):
2846-
error_count = 0
2847-
message = "{}, {}".format(run_status, self.status_warning)
2848-
self.current_status = message
2849-
self.dashboard_item(
2850-
self.prefix + ".status",
2851-
state=message[:255],
2852-
attributes={
2853-
"friendly_name": "Status",
2854-
"detail": "",
2855-
"icon": "mdi:information",
2856-
"last_updated": self.now_utc_real.strftime(TIME_FORMAT),
2857-
"debug": self.status_warning_debug,
2858-
"version": THIS_VERSION_DISPLAY,
2859-
"error": True,
2860-
"error_count": error_count,
2861-
},
2862-
)
2850+
# Same lock as record_status() - see there.
2851+
with self.status_lock:
2852+
if not self.status_warning:
2853+
return
2854+
error_count = self.get_state_wrapper(self.prefix + ".status", attribute="error_count", default=0)
2855+
try:
2856+
error_count = int(error_count)
2857+
except (ValueError, TypeError):
2858+
error_count = 0
2859+
message = "{}, {}".format(run_status, self.status_warning)
2860+
self.current_status = message
2861+
self.dashboard_item(
2862+
self.prefix + ".status",
2863+
state=message[:255],
2864+
attributes={
2865+
"friendly_name": "Status",
2866+
"detail": "",
2867+
"icon": "mdi:information",
2868+
"last_updated": self.now_utc_real.strftime(TIME_FORMAT),
2869+
"debug": self.status_warning_debug,
2870+
"version": THIS_VERSION_DISPLAY,
2871+
"error": True,
2872+
"error_count": error_count,
2873+
},
2874+
)
28632875

28642876
def publish_last_started(self):
28652877
"""

‎apps/predbat/predbat.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import gc
2626
import random
2727
import time
28+
import threading
2829

2930
# from memory_profiler import profile
3031

@@ -369,6 +370,7 @@ def reset(self):
369370
self.had_errors = False
370371
self.status_warning = None
371372
self.status_warning_debug = ""
373+
self.status_lock = threading.RLock()
372374
self.plan_valid = False
373375
self.plan_preclip = None
374376
self.plan_last_updated = None

‎apps/predbat/tests/test_component_health_status.py‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
# pylint: disable=attribute-defined-outside-init
1010
# fmt on
1111

12+
import threading
13+
import time
14+
1215
from output import split_status_warning
1316

1417

@@ -182,6 +185,101 @@ def test_record_status_under_warning(my_predbat):
182185
return failed
183186

184187

188+
def test_record_status_concurrent(my_predbat):
189+
"""
190+
Verify record_status() calls from several threads at once don't lose an error_count increment,
191+
and that the status notification is sent with the lock released.
192+
193+
Component threads (GE Cloud, Solis, ...) record status alongside the main loop, and error_count is
194+
a read-modify-write of the sensor's own attribute. The read is slowed here so that, without the
195+
status lock, the threads reliably read the same count and overwrite each other's increment.
196+
"""
197+
print("*** Running test: concurrent record_status calls keep every error_count increment")
198+
failed = 0
199+
status_entity = my_predbat.prefix + ".status"
200+
saved_item = my_predbat.ha_interface.dummy_items.get(status_entity)
201+
saved_value = my_predbat.dashboard_values.get(status_entity)
202+
saved = (my_predbat.current_status, my_predbat.had_errors, my_predbat.status_warning, my_predbat.status_warning_debug)
203+
original_get_state_wrapper = my_predbat.get_state_wrapper
204+
original_call_notify = my_predbat.call_notify
205+
saved_notify_flag = my_predbat.set_status_notify
206+
saved_previous_status = my_predbat.previous_status
207+
threads_count, calls_each = 4, 10
208+
209+
def _slow_get_state_wrapper(*args, **kwargs):
210+
value = original_get_state_wrapper(*args, **kwargs)
211+
time.sleep(0.001)
212+
return value
213+
214+
def _record_warnings():
215+
for _ in range(calls_each):
216+
my_predbat.record_status("Warn: concurrent test", had_errors=True)
217+
218+
try:
219+
my_predbat.dashboard_values.pop(status_entity, None)
220+
my_predbat.ha_interface.dummy_items.pop(status_entity, None)
221+
my_predbat.get_state_wrapper = _slow_get_state_wrapper
222+
threads = [threading.Thread(target=_record_warnings) for _ in range(threads_count)]
223+
for thread in threads:
224+
thread.start()
225+
for thread in threads:
226+
thread.join()
227+
error_count = my_predbat.dashboard_values[status_entity]["attributes"]["error_count"]
228+
if error_count != threads_count * calls_each:
229+
print("ERROR: expected error_count {} after {} concurrent warnings, got {}".format(threads_count * calls_each, threads_count * calls_each, error_count))
230+
failed = 1
231+
else:
232+
print("OK: all {} concurrent warnings counted".format(error_count))
233+
my_predbat.get_state_wrapper = original_get_state_wrapper
234+
235+
# The notification is sent with the lock released - over the websocket it can wait minutes
236+
# on another thread, and every other status would stall behind it
237+
notified = []
238+
239+
def _fake_notify(message):
240+
result = {}
241+
242+
def _try_lock():
243+
result["free"] = my_predbat.status_lock.acquire(blocking=False)
244+
if result["free"]:
245+
my_predbat.status_lock.release()
246+
247+
checker = threading.Thread(target=_try_lock)
248+
checker.start()
249+
checker.join()
250+
notified.append((message, result["free"]))
251+
252+
my_predbat.call_notify = _fake_notify
253+
my_predbat.set_status_notify = True
254+
my_predbat.previous_status = None
255+
my_predbat.had_errors = False
256+
my_predbat.record_status("Exporting", notify=True)
257+
if len(notified) != 1 or "Exporting" not in notified[0][0]:
258+
print("ERROR: expected one 'Exporting' notification, got {}".format(notified))
259+
failed = 1
260+
elif not notified[0][1]:
261+
print("ERROR: the notification was sent while the status lock was held")
262+
failed = 1
263+
else:
264+
print("OK: status notification sent with the lock released")
265+
finally:
266+
my_predbat.get_state_wrapper = original_get_state_wrapper
267+
my_predbat.call_notify = original_call_notify
268+
my_predbat.set_status_notify = saved_notify_flag
269+
my_predbat.previous_status = saved_previous_status
270+
my_predbat.current_status, my_predbat.had_errors, my_predbat.status_warning, my_predbat.status_warning_debug = saved
271+
if saved_item is None:
272+
my_predbat.ha_interface.dummy_items.pop(status_entity, None)
273+
else:
274+
my_predbat.ha_interface.dummy_items[status_entity] = saved_item
275+
if saved_value is None:
276+
my_predbat.dashboard_values.pop(status_entity, None)
277+
else:
278+
my_predbat.dashboard_values[status_entity] = saved_value
279+
280+
return failed
281+
282+
185283
def test_component_health_status(my_predbat):
186284
"""
187285
Verify record_final_run_status() marks the run as an error, naming the failed component(s),

‎apps/predbat/unit_test.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
from tests.test_plugin_startup import test_plugin_startup_order
6868
from tests.test_active_flag import test_active_flag
6969
from tests.test_time_loop_guard import test_time_loop_guard
70-
from tests.test_component_health_status import test_component_health_status, test_record_status_state_clamped, test_record_status_under_warning
70+
from tests.test_component_health_status import test_component_health_status, test_record_status_state_clamped, test_record_status_under_warning, test_record_status_concurrent
7171
from tests.test_optimise_levels import run_optimise_levels_tests
7272
from tests.test_trim_export import run_trim_export_tests
7373
from tests.test_plan_tiebreak import run_plan_tiebreak_tests
@@ -516,6 +516,7 @@ def main():
516516
("component_health_status", test_component_health_status, "Component errors fail the recorded run status tests", False),
517517
("record_status_state_clamped", test_record_status_state_clamped, "Status sensor state is clamped at the 255 characters Home Assistant accepts", False),
518518
("record_status_under_warning", test_record_status_under_warning, "A run's state is recorded in front of the warning it raised", False),
519+
("record_status_concurrent", test_record_status_concurrent, "Concurrent record_status calls keep every error_count increment", False),
519520
("dynamic_load_car", test_dynamic_load_car_slot_cancellation, "Dynamic load car slot cancellation tests", False),
520521
("dynamic_load_high", test_dynamic_load_high_load_baseline, "Dynamic load high-load baseline tests", False),
521522
("dynamic_load_car_not_charging", test_dynamic_load_car_not_charging, "Dynamic load car not charging tests", False),

‎apps/predbat/utils.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ def copy(self):
231231

232232

233233
# Predbat member variables never included in a debug dump or served over MCP - live object
234-
# graphs, the HA interface, loaded secrets and the URL caches. Shared with is_debug_excluded_key().
234+
# graphs, the HA interface, loaded secrets, the URL caches and locks (which can't be serialised).
235+
# Shared with is_debug_excluded_key().
235236
DEBUG_EXCLUDE_LIST = [
236237
"ha_interface",
237238
"components",
@@ -252,6 +253,7 @@ def copy(self):
252253
"github_url_cache",
253254
"octopus_url_cache",
254255
"secrets",
256+
"status_lock",
255257
]
256258

257259

0 commit comments

Comments
 (0)