Skip to content

Commit 521a678

Browse files
authored
Add opt-in to Store for serializing in an executor (home-assistant#157263)
1 parent d2ba7e8 commit 521a678

File tree

2 files changed

+128
-16
lines changed

2 files changed

+128
-16
lines changed

homeassistant/helpers/storage.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
Event,
2828
HomeAssistant,
2929
callback,
30-
is_callback,
3130
)
3231
from homeassistant.exceptions import HomeAssistantError
3332
from homeassistant.loader import bind_hass
@@ -242,8 +241,25 @@ def __init__(
242241
encoder: type[JSONEncoder] | None = None,
243242
minor_version: int = 1,
244243
read_only: bool = False,
244+
serialize_in_event_loop: bool = True,
245245
) -> None:
246-
"""Initialize storage class."""
246+
"""Initialize storage class.
247+
248+
Args:
249+
serialize_in_event_loop: Whether to serialize data in the event loop.
250+
Set to True (default) if data passed to async_save and data produced by
251+
data_func passed to async_delay_save needs to be serialized in the event
252+
loop because it is not thread safe.
253+
254+
Set to False if the data passed to async_save and data produced by
255+
data_func passed to async_delay_save can safely be accessed from a
256+
separate thread, i.e. the data is thread safe and not mutated by other
257+
code while serialization is in progress.
258+
259+
Users should support serializing in a separate thread for stores which
260+
are expected to store large amounts of data to avoid blocking the event
261+
loop during serialization.
262+
"""
247263
self.version = version
248264
self.minor_version = minor_version
249265
self.key = key
@@ -259,6 +275,7 @@ def __init__(
259275
self._read_only = read_only
260276
self._next_write_time = 0.0
261277
self._manager = get_internal_store_manager(hass)
278+
self._serialize_in_event_loop = serialize_in_event_loop
262279

263280
@cached_property
264281
def path(self):
@@ -444,9 +461,10 @@ def async_delay_save(
444461
) -> None:
445462
"""Save data with an optional delay.
446463
447-
data_func: A function that returns the data to save. If the function
448-
is decorated with @callback, it will be called in the event loop. If
449-
it is a regular function, it will be called from an executor.
464+
data_func: A function that returns the data to save. If serialize_in_event_loop
465+
is True, it will be called from and the returned data will be serialized in the
466+
in the event loop. If serialize_in_event_loop is False, it will be called from
467+
and the returned data will be serialized by a separate thread.
450468
"""
451469
self._data = {
452470
"version": self.version,
@@ -548,8 +566,9 @@ async def _async_handle_write_data(self, *_args):
548566
_LOGGER.error("Error writing config for %s: %s", self.key, err)
549567

550568
async def _async_write_data(self, data: dict) -> None:
551-
if "data_func" in data and is_callback(data["data_func"]):
552-
data["data"] = data.pop("data_func")()
569+
if self._serialize_in_event_loop:
570+
if "data_func" in data:
571+
data["data"] = data.pop("data_func")()
553572
mode, json_data = json_helper.prepare_save_json(data, encoder=self._encoder)
554573
await self.hass.async_add_executor_job(
555574
self._write_prepared_data, mode, json_data

tests/helpers/test_storage.py

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
)
2828
from homeassistant.exceptions import HomeAssistantError
2929
from homeassistant.helpers import issue_registry as ir, storage
30-
from homeassistant.helpers.json import json_bytes
30+
from homeassistant.helpers.json import json_bytes, prepare_save_json
3131
from homeassistant.util import dt as dt_util
3232
from homeassistant.util.color import RGBColor
3333

@@ -177,17 +177,41 @@ def data_producer_callback() -> Any:
177177
calls.append("callback")
178178
return MOCK_DATA2
179179

180-
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
181-
store.async_delay_save(data_producer_thread_safe, 1)
180+
def mock_prepare_thread_safe(*args, **kwargs):
181+
"""Mock prepare thread safe."""
182+
assert threading.get_ident() != hass.loop_thread_id
183+
return prepare_save_json(*args, **kwargs)
182184

183-
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
184-
await hass.async_block_till_done()
185+
def mock_prepare_not_thread_safe(*args, **kwargs):
186+
"""Mock prepare not thread safe."""
187+
assert threading.get_ident() == hass.loop_thread_id
188+
return prepare_save_json(*args, **kwargs)
185189

186-
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY2)
187-
store.async_delay_save(data_producer_callback, 1)
190+
with patch(
191+
"homeassistant.helpers.storage.json_helper.prepare_save_json",
192+
wraps=mock_prepare_thread_safe,
193+
) as mock_prepare:
194+
store = storage.Store(
195+
hass, MOCK_VERSION, MOCK_KEY, serialize_in_event_loop=False
196+
)
197+
store.async_delay_save(data_producer_thread_safe, 1)
188198

189-
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
190-
await hass.async_block_till_done()
199+
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
200+
await hass.async_block_till_done()
201+
202+
mock_prepare.assert_called_once()
203+
204+
with patch(
205+
"homeassistant.helpers.storage.json_helper.prepare_save_json",
206+
wraps=mock_prepare_not_thread_safe,
207+
) as mock_prepare:
208+
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY2)
209+
store.async_delay_save(data_producer_callback, 1)
210+
211+
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
212+
await hass.async_block_till_done()
213+
214+
mock_prepare.assert_called_once()
191215

192216
assert calls == ["thread_safe", "callback"]
193217
expected_data = (
@@ -216,6 +240,75 @@ def data_producer_callback() -> Any:
216240
await hass.async_stop(force=True)
217241

218242

243+
async def test_saving_with_threading(tmp_path: Path) -> None:
244+
"""Test thread handling when saving."""
245+
246+
async def assert_storage_data(store_key: str, expected_data: str) -> None:
247+
"""Assert storage data."""
248+
249+
def read_storage_data(store_key: str) -> str:
250+
"""Read storage data."""
251+
return Path(tmp_path / f".storage/{store_key}").read_text(encoding="utf-8")
252+
253+
store_data = await asyncio.to_thread(read_storage_data, store_key)
254+
assert store_data == expected_data
255+
256+
async with async_test_home_assistant(config_dir=tmp_path) as hass:
257+
258+
def mock_prepare_thread_safe(*args, **kwargs):
259+
"""Mock prepare thread safe."""
260+
assert threading.get_ident() != hass.loop_thread_id
261+
return prepare_save_json(*args, **kwargs)
262+
263+
def mock_prepare_not_thread_safe(*args, **kwargs):
264+
"""Mock prepare not thread safe."""
265+
assert threading.get_ident() == hass.loop_thread_id
266+
return prepare_save_json(*args, **kwargs)
267+
268+
with patch(
269+
"homeassistant.helpers.storage.json_helper.prepare_save_json",
270+
wraps=mock_prepare_thread_safe,
271+
) as mock_prepare:
272+
store = storage.Store(
273+
hass, MOCK_VERSION, MOCK_KEY, serialize_in_event_loop=False
274+
)
275+
await store.async_save(MOCK_DATA)
276+
mock_prepare.assert_called_once()
277+
278+
with patch(
279+
"homeassistant.helpers.storage.json_helper.prepare_save_json",
280+
wraps=mock_prepare_not_thread_safe,
281+
) as mock_prepare:
282+
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY2)
283+
await store.async_save(MOCK_DATA2)
284+
mock_prepare.assert_called_once()
285+
286+
expected_data = (
287+
"{\n"
288+
' "version": 1,\n'
289+
' "minor_version": 1,\n'
290+
' "key": "storage-test",\n'
291+
' "data": {\n'
292+
' "hello": "world"\n'
293+
" }\n"
294+
"}"
295+
)
296+
await assert_storage_data(MOCK_KEY, expected_data)
297+
expected_data = (
298+
"{\n"
299+
' "version": 1,\n'
300+
' "minor_version": 1,\n'
301+
' "key": "storage-test-2",\n'
302+
' "data": {\n'
303+
' "goodbye": "cruel world"\n'
304+
" }\n"
305+
"}"
306+
)
307+
await assert_storage_data(MOCK_KEY2, expected_data)
308+
309+
await hass.async_stop(force=True)
310+
311+
219312
async def test_saving_with_delay_churn_reduction(
220313
hass: HomeAssistant,
221314
store: storage.Store,

0 commit comments

Comments
 (0)