Cache serialization of config entry storage (#127435)

This commit is contained in:
J. Nick Koston 2024-10-03 12:51:09 -05:00 committed by GitHub
parent 0bbca596a9
commit e2b1ef053f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 96 additions and 18 deletions

View file

@ -57,7 +57,7 @@ from .helpers.event import (
async_call_later,
)
from .helpers.frame import report
from .helpers.json import json_bytes, json_fragment
from .helpers.json import json_bytes, json_bytes_sorted, json_fragment
from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType
from .loader import async_suggest_report_issue
from .setup import (
@ -247,14 +247,13 @@ type UpdateListenerType = Callable[
[HomeAssistant, ConfigEntry], Coroutine[Any, Any, None]
]
FROZEN_CONFIG_ENTRY_ATTRS = {
"entry_id",
"domain",
STATE_KEYS = {
"state",
"reason",
"error_reason_translation_key",
"error_reason_translation_placeholders",
}
FROZEN_CONFIG_ENTRY_ATTRS = {"entry_id", "domain", *STATE_KEYS}
UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = {
"unique_id",
"title",
@ -447,7 +446,8 @@ class ConfigEntry(Generic[_DataT]):
raise AttributeError(f"{key} cannot be changed")
super().__setattr__(key, value)
self.clear_cache()
self.clear_state_cache()
self.clear_storage_cache()
@property
def supports_options(self) -> bool:
@ -473,13 +473,13 @@ class ConfigEntry(Generic[_DataT]):
)
return self._supports_reconfigure or False
def clear_cache(self) -> None:
"""Clear cached properties."""
def clear_state_cache(self) -> None:
"""Clear cached properties that are included in as_json_fragment."""
self.__dict__.pop("as_json_fragment", None)
@cached_property
def as_json_fragment(self) -> json_fragment:
"""Return JSON fragment of a config entry."""
"""Return JSON fragment of a config entry that is used for the API."""
json_repr = {
"created_at": self.created_at.timestamp(),
"entry_id": self.entry_id,
@ -501,6 +501,15 @@ class ConfigEntry(Generic[_DataT]):
}
return json_fragment(json_bytes(json_repr))
def clear_storage_cache(self) -> None:
"""Clear cached properties that are included in as_storage_fragment."""
self.__dict__.pop("as_storage_fragment", None)
@cached_property
def as_storage_fragment(self) -> json_fragment:
"""Return a storage fragment for this entry."""
return json_fragment(json_bytes_sorted(self.as_dict()))
async def async_setup(
self,
hass: HomeAssistant,
@ -833,7 +842,8 @@ class ConfigEntry(Generic[_DataT]):
"""Invoke remove callback on component."""
old_modified_at = self.modified_at
object.__setattr__(self, "modified_at", utcnow())
self.clear_cache()
self.clear_state_cache()
self.clear_storage_cache()
if self.source == SOURCE_IGNORE:
return
@ -890,7 +900,10 @@ class ConfigEntry(Generic[_DataT]):
"error_reason_translation_placeholders",
error_reason_translation_placeholders,
)
self.clear_cache()
self.clear_state_cache()
# Storage cache is not cleared here because the state is not stored
# in storage and we do not want to clear the cache on every state change
# since state changes are frequent.
async_dispatcher_send_internal(
hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self
)
@ -1663,7 +1676,8 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
self._unindex_entry(entry_id)
object.__setattr__(entry, "unique_id", new_unique_id)
self._index_entry(entry)
entry.clear_cache()
entry.clear_state_cache()
entry.clear_storage_cache()
def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]:
"""Get entries for a domain."""
@ -2138,7 +2152,8 @@ class ConfigEntries:
)
self._async_schedule_save()
entry.clear_cache()
entry.clear_state_cache()
entry.clear_storage_cache()
self._async_dispatch(ConfigEntryChange.UPDATED, entry)
return True
@ -2321,7 +2336,10 @@ class ConfigEntries:
@callback
def _data_to_save(self) -> dict[str, list[dict[str, Any]]]:
"""Return data to save."""
return {"entries": [entry.as_dict() for entry in self._entries.values()]}
# typing does not know that the storage fragment will serialize to a dict
return {
"entries": [entry.as_storage_fragment for entry in self._entries.values()] # type: ignore[misc]
}
async def async_wait_component(self, entry: ConfigEntry) -> bool:
"""Wait for an entry's component to load and return if the entry is loaded.

View file

@ -162,13 +162,17 @@ def json_dumps(data: Any) -> str:
return json_bytes(data).decode("utf-8")
json_bytes_sorted = partial(
orjson.dumps,
option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS,
default=json_encoder_default,
)
"""Dump json bytes with keys sorted."""
def json_dumps_sorted(data: Any) -> str:
"""Dump json string with keys sorted."""
return orjson.dumps(
data,
option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS,
default=json_encoder_default,
).decode("utf-8")
return json_bytes_sorted(data).decode("utf-8")
JSON_DUMP: Final = json_dumps

View file

@ -18,6 +18,7 @@ from homeassistant.helpers.json import (
ExtendedJSONEncoder,
JSONEncoder as DefaultHASSJSONEncoder,
find_paths_unserializable_data,
json_bytes_sorted,
json_bytes_strip_null,
json_dumps,
json_dumps_sorted,
@ -107,6 +108,14 @@ def test_json_dumps_sorted() -> None:
)
def test_json_bytes_sorted() -> None:
"""Test the json bytes sorted function."""
data = {"c": 3, "a": 1, "b": 2}
assert json_bytes_sorted(data) == json.dumps(
data, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def test_json_dumps_float_subclass() -> None:
"""Test the json dumps a float subclass."""

View file

@ -40,11 +40,13 @@ from homeassistant.exceptions import (
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component
from homeassistant.util.async_ import create_eager_task
import homeassistant.util.dt as dt_util
from homeassistant.util.json import json_loads
from .common import (
MockConfigEntry,
@ -6590,3 +6592,48 @@ async def test_reauth_helper_alignment(
# Ensure context and init data are aligned
assert helper_flow_context == reauth_flow_context
assert helper_flow_init_data == reauth_flow_init_data
def test_state_not_stored_in_storage() -> None:
"""Test that state is not stored in storage.
Verify we don't start accidentally storing state in storage.
"""
entry = MockConfigEntry(domain="test")
loaded = json_loads(json_dumps(entry.as_storage_fragment))
for key in config_entries.STATE_KEYS:
assert key not in loaded
def test_storage_cache_is_cleared_on_entry_update(hass: HomeAssistant) -> None:
"""Test that the storage cache is cleared when an entry is updated."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
_ = entry.as_storage_fragment
hass.config_entries.async_update_entry(entry, data={"new": "data"})
loaded = json_loads(json_dumps(entry.as_storage_fragment))
assert "new" in loaded["data"]
async def test_storage_cache_is_cleared_on_entry_disable(hass: HomeAssistant) -> None:
"""Test that the storage cache is cleared when an entry is disabled."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
_ = entry.as_storage_fragment
await hass.config_entries.async_set_disabled_by(
entry.entry_id, config_entries.ConfigEntryDisabler.USER
)
loaded = json_loads(json_dumps(entry.as_storage_fragment))
assert loaded["disabled_by"] == "user"
async def test_state_cache_is_cleared_on_entry_disable(hass: HomeAssistant) -> None:
"""Test that the state cache is cleared when an entry is disabled."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
_ = entry.as_storage_fragment
await hass.config_entries.async_set_disabled_by(
entry.entry_id, config_entries.ConfigEntryDisabler.USER
)
loaded = json_loads(json_dumps(entry.as_json_fragment))
assert loaded["disabled_by"] == "user"