mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
The store manager caches by filename, so a compressed store asking for "key.zst" was told the file does not exist whenever only the uncompressed predecessor was on disk. That short-circuited the load before the fallback in _load_data_from_disk could run, so enabling compress on an existing store read as empty on any real start, where the manager is initialized. Only report "does not exist" when neither filename is on disk. Rename the file that actually failed to parse when handling corruption. The compressed path was renamed unconditionally, which raised FileNotFoundError out of the executor when the uncompressed fallback was the corrupt one, and named the wrong file in the repair issue. Preload compressed stores too. Callers only know the plain key, so look for both spellings before intersecting with the files on disk. Also invalidate both filenames when writing or removing, since both drop the uncompressed predecessor, and use a sentinel to tell "no file" apart from a file holding an empty dict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1596 lines
55 KiB
Python
1596 lines
55 KiB
Python
"""Tests for the storage helper."""
|
|
|
|
import asyncio
|
|
from collections.abc import AsyncGenerator
|
|
from compression import zstd
|
|
from datetime import timedelta
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import threading
|
|
from typing import Any, NamedTuple
|
|
from unittest.mock import Mock, patch
|
|
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
import py
|
|
import pytest
|
|
|
|
from homeassistant.const import (
|
|
EVENT_HOMEASSISTANT_FINAL_WRITE,
|
|
EVENT_HOMEASSISTANT_START,
|
|
EVENT_HOMEASSISTANT_STARTED,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
)
|
|
from homeassistant.core import (
|
|
DOMAIN as HOMEASSISTANT_DOMAIN,
|
|
CoreState,
|
|
HomeAssistant,
|
|
callback,
|
|
)
|
|
from homeassistant.exceptions import HomeAssistantError, UnsupportedStorageVersionError
|
|
from homeassistant.helpers import issue_registry as ir, storage
|
|
from homeassistant.helpers.json import json_bytes, prepare_save_json
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.color import RGBColor
|
|
|
|
from tests.common import (
|
|
async_fire_time_changed,
|
|
async_fire_time_changed_exact,
|
|
async_test_home_assistant,
|
|
)
|
|
|
|
MOCK_VERSION = 1
|
|
MOCK_VERSION_2 = 2
|
|
MOCK_MINOR_VERSION_1 = 1
|
|
MOCK_MINOR_VERSION_2 = 2
|
|
MOCK_KEY = "storage-test"
|
|
MOCK_KEY2 = "storage-test-2"
|
|
MOCK_DATA = {"hello": "world"}
|
|
MOCK_DATA2 = {"goodbye": "cruel world"}
|
|
|
|
|
|
@pytest.fixture
|
|
def store(hass: HomeAssistant) -> storage.Store:
|
|
"""Fixture of a store that prevents writing on Home Assistant stop."""
|
|
return storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
|
|
|
|
@pytest.fixture
|
|
def store_v_1_1(hass: HomeAssistant) -> storage.Store:
|
|
"""Fixture of a store that prevents writing on Home Assistant stop."""
|
|
return storage.Store(
|
|
hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def store_v_1_2(hass: HomeAssistant) -> storage.Store:
|
|
"""Fixture of a store that prevents writing on Home Assistant stop."""
|
|
return storage.Store(
|
|
hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_2
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def store_v_2_1(hass: HomeAssistant) -> storage.Store:
|
|
"""Fixture of a store that prevents writing on Home Assistant stop."""
|
|
return storage.Store(
|
|
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def read_only_store(hass: HomeAssistant) -> storage.Store:
|
|
"""Fixture of a read only store."""
|
|
return storage.Store(hass, MOCK_VERSION, MOCK_KEY, read_only=True)
|
|
|
|
|
|
async def test_loading(hass: HomeAssistant, store: storage.Store) -> None:
|
|
"""Test we can save and load data."""
|
|
await store.async_save(MOCK_DATA)
|
|
data = await store.async_load()
|
|
assert data == MOCK_DATA
|
|
|
|
|
|
async def test_custom_encoder(hass: HomeAssistant) -> None:
|
|
"""Test we can save and load data."""
|
|
|
|
class JSONEncoder(json.JSONEncoder):
|
|
"""Mock JSON encoder."""
|
|
|
|
def default(self, o):
|
|
"""Mock JSON encode method."""
|
|
return "9"
|
|
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY, encoder=JSONEncoder)
|
|
with pytest.raises(TypeError):
|
|
await store.async_save(Mock())
|
|
await store.async_save(object())
|
|
data = await store.async_load()
|
|
assert data == "9"
|
|
|
|
|
|
async def test_loading_non_existing(hass: HomeAssistant, store: storage.Store) -> None:
|
|
"""Test we can save and load data."""
|
|
with patch("homeassistant.util.json.open", side_effect=FileNotFoundError):
|
|
data = await store.async_load()
|
|
assert data is None
|
|
|
|
|
|
async def test_loading_parallel(
|
|
hass: HomeAssistant,
|
|
store: storage.Store,
|
|
hass_storage: dict[str, Any],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test we can save and load data."""
|
|
hass_storage[store.key] = {"version": MOCK_VERSION, "data": MOCK_DATA}
|
|
|
|
results = await asyncio.gather(store.async_load(), store.async_load())
|
|
|
|
assert results[0] == MOCK_DATA
|
|
assert results[1] == MOCK_DATA
|
|
assert caplog.text.count(f"Loading data for {store.key}")
|
|
|
|
|
|
async def test_saving_with_delay(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test saving data after a delay."""
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
assert store.key not in hass_storage
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
|
|
async def test_saving_with_delay_threading(tmp_path: Path) -> None:
|
|
"""Test thread handling when saving with a delay."""
|
|
calls = []
|
|
|
|
async def assert_storage_data(store_key: str, expected_data: str) -> None:
|
|
"""Assert storage data."""
|
|
|
|
def read_storage_data(store_key: str) -> str:
|
|
"""Read storage data."""
|
|
return Path(tmp_path / f".storage/{store_key}").read_text(encoding="utf-8")
|
|
|
|
store_data = await asyncio.to_thread(read_storage_data, store_key)
|
|
assert store_data == expected_data
|
|
|
|
async with async_test_home_assistant(config_dir=tmp_path) as hass:
|
|
|
|
def data_producer_thread_safe() -> Any:
|
|
"""Produce data to store."""
|
|
assert threading.get_ident() != hass.loop_thread_id
|
|
calls.append("thread_safe")
|
|
return MOCK_DATA
|
|
|
|
@callback
|
|
def data_producer_callback() -> Any:
|
|
"""Produce data to store."""
|
|
assert threading.get_ident() == hass.loop_thread_id
|
|
calls.append("callback")
|
|
return MOCK_DATA2
|
|
|
|
def mock_prepare_thread_safe(*args, **kwargs):
|
|
"""Mock prepare thread safe."""
|
|
assert threading.get_ident() != hass.loop_thread_id
|
|
return prepare_save_json(*args, **kwargs)
|
|
|
|
def mock_prepare_not_thread_safe(*args, **kwargs):
|
|
"""Mock prepare not thread safe."""
|
|
assert threading.get_ident() == hass.loop_thread_id
|
|
return prepare_save_json(*args, **kwargs)
|
|
|
|
with patch(
|
|
"homeassistant.helpers.storage.json_helper.prepare_save_json",
|
|
wraps=mock_prepare_thread_safe,
|
|
) as mock_prepare:
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION, MOCK_KEY, serialize_in_event_loop=False
|
|
)
|
|
store.async_delay_save(data_producer_thread_safe, 1)
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
|
|
mock_prepare.assert_called_once()
|
|
|
|
with patch(
|
|
"homeassistant.helpers.storage.json_helper.prepare_save_json",
|
|
wraps=mock_prepare_not_thread_safe,
|
|
) as mock_prepare:
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY2)
|
|
store.async_delay_save(data_producer_callback, 1)
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
|
|
mock_prepare.assert_called_once()
|
|
|
|
assert calls == ["thread_safe", "callback"]
|
|
expected_data = (
|
|
"{\n"
|
|
' "version": 1,\n'
|
|
' "minor_version": 1,\n'
|
|
' "key": "storage-test",\n'
|
|
' "data": {\n'
|
|
' "hello": "world"\n'
|
|
" }\n"
|
|
"}"
|
|
)
|
|
await assert_storage_data(MOCK_KEY, expected_data)
|
|
expected_data = (
|
|
"{\n"
|
|
' "version": 1,\n'
|
|
' "minor_version": 1,\n'
|
|
' "key": "storage-test-2",\n'
|
|
' "data": {\n'
|
|
' "goodbye": "cruel world"\n'
|
|
" }\n"
|
|
"}"
|
|
)
|
|
await assert_storage_data(MOCK_KEY2, expected_data)
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_saving_with_threading(tmp_path: Path) -> None:
|
|
"""Test thread handling when saving."""
|
|
|
|
async def assert_storage_data(store_key: str, expected_data: str) -> None:
|
|
"""Assert storage data."""
|
|
|
|
def read_storage_data(store_key: str) -> str:
|
|
"""Read storage data."""
|
|
return Path(tmp_path / f".storage/{store_key}").read_text(encoding="utf-8")
|
|
|
|
store_data = await asyncio.to_thread(read_storage_data, store_key)
|
|
assert store_data == expected_data
|
|
|
|
async with async_test_home_assistant(config_dir=tmp_path) as hass:
|
|
|
|
def mock_prepare_thread_safe(*args, **kwargs):
|
|
"""Mock prepare thread safe."""
|
|
assert threading.get_ident() != hass.loop_thread_id
|
|
return prepare_save_json(*args, **kwargs)
|
|
|
|
def mock_prepare_not_thread_safe(*args, **kwargs):
|
|
"""Mock prepare not thread safe."""
|
|
assert threading.get_ident() == hass.loop_thread_id
|
|
return prepare_save_json(*args, **kwargs)
|
|
|
|
with patch(
|
|
"homeassistant.helpers.storage.json_helper.prepare_save_json",
|
|
wraps=mock_prepare_thread_safe,
|
|
) as mock_prepare:
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION, MOCK_KEY, serialize_in_event_loop=False
|
|
)
|
|
await store.async_save(MOCK_DATA)
|
|
mock_prepare.assert_called_once()
|
|
|
|
with patch(
|
|
"homeassistant.helpers.storage.json_helper.prepare_save_json",
|
|
wraps=mock_prepare_not_thread_safe,
|
|
) as mock_prepare:
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY2)
|
|
await store.async_save(MOCK_DATA2)
|
|
mock_prepare.assert_called_once()
|
|
|
|
expected_data = (
|
|
"{\n"
|
|
' "version": 1,\n'
|
|
' "minor_version": 1,\n'
|
|
' "key": "storage-test",\n'
|
|
' "data": {\n'
|
|
' "hello": "world"\n'
|
|
" }\n"
|
|
"}"
|
|
)
|
|
await assert_storage_data(MOCK_KEY, expected_data)
|
|
expected_data = (
|
|
"{\n"
|
|
' "version": 1,\n'
|
|
' "minor_version": 1,\n'
|
|
' "key": "storage-test-2",\n'
|
|
' "data": {\n'
|
|
' "goodbye": "cruel world"\n'
|
|
" }\n"
|
|
"}"
|
|
)
|
|
await assert_storage_data(MOCK_KEY2, expected_data)
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_saving_with_delay_churn_reduction(
|
|
hass: HomeAssistant,
|
|
store: storage.Store,
|
|
hass_storage: dict[str, Any],
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test saving data after a delay with timer churn reduction."""
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
assert store.key not in hass_storage
|
|
|
|
freezer.tick(0.2)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
freezer.tick(1)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
del hass_storage[store.key]
|
|
# Simulate what some of the registries do when they add 100 entities
|
|
for _ in range(100):
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
|
|
freezer.tick(0.2)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
|
|
freezer.tick(1)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key in hass_storage
|
|
|
|
del hass_storage[store.key]
|
|
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
freezer.tick(0.5)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
freezer.tick(0.8)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
freezer.tick(0.8)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
freezer.tick(0.2)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key in hass_storage
|
|
|
|
# Make sure if we do another delayed save
|
|
# and one with a shorter delay, the shorter delay wins
|
|
del hass_storage[store.key]
|
|
store.async_delay_save(lambda: MOCK_DATA, 2)
|
|
freezer.tick(0.2)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
freezer.tick(1.0)
|
|
async_fire_time_changed_exact(hass)
|
|
await hass.async_block_till_done()
|
|
assert store.key in hass_storage
|
|
|
|
|
|
async def test_saving_on_final_write(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test delayed saves trigger when we quit Home Assistant."""
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
store.async_delay_save(lambda: MOCK_DATA, 5)
|
|
assert store.key not in hass_storage
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
hass.set_state(CoreState.stopping)
|
|
await hass.async_block_till_done()
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
|
|
await hass.async_block_till_done()
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
|
|
async def test_not_delayed_saving_while_stopping(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test delayed saves don't write after the stop event has fired."""
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
hass.set_state(CoreState.stopping)
|
|
|
|
store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
|
|
async def test_not_delayed_saving_after_stopping(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test delayed saves don't write after stop if issued before stopping."""
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
store.async_delay_save(lambda: MOCK_DATA, 10)
|
|
assert store.key not in hass_storage
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
hass.set_state(CoreState.stopping)
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15))
|
|
await hass.async_block_till_done()
|
|
assert store.key not in hass_storage
|
|
|
|
|
|
async def test_not_saving_while_stopping(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test saves don't write when stopping Home Assistant."""
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
hass.set_state(CoreState.stopping)
|
|
await store.async_save(MOCK_DATA)
|
|
assert store.key not in hass_storage
|
|
|
|
|
|
async def test_loading_while_delay(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test we load new data even if not written yet."""
|
|
await store.async_save({"delay": "no"})
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "no"},
|
|
}
|
|
|
|
store.async_delay_save(lambda: {"delay": "yes"}, 1)
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "no"},
|
|
}
|
|
|
|
data = await store.async_load()
|
|
assert data == {"delay": "yes"}
|
|
|
|
|
|
async def test_writing_while_writing_delay(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test a write while a write with delay is active."""
|
|
store.async_delay_save(lambda: {"delay": "yes"}, 1)
|
|
assert store.key not in hass_storage
|
|
await store.async_save({"delay": "no"})
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "no"},
|
|
}
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "no"},
|
|
}
|
|
|
|
data = await store.async_load()
|
|
assert data == {"delay": "no"}
|
|
|
|
|
|
async def test_multiple_delay_save_calls(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test a write while a write with changing delays."""
|
|
store.async_delay_save(lambda: {"delay": "yes"}, 1)
|
|
store.async_delay_save(lambda: {"delay": "yes"}, 2)
|
|
store.async_delay_save(lambda: {"delay": "yes"}, 3)
|
|
|
|
assert store.key not in hass_storage
|
|
await store.async_save({"delay": "no"})
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "no"},
|
|
}
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "no"},
|
|
}
|
|
|
|
data = await store.async_load()
|
|
assert data == {"delay": "no"}
|
|
|
|
|
|
async def test_delay_save_zero(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test async_delay_save accepts 0."""
|
|
store.async_delay_save(lambda: {"delay": "0"}, 0)
|
|
# sleep is to run one event loop to get the task scheduled
|
|
await asyncio.sleep(0)
|
|
await hass.async_block_till_done()
|
|
assert store.key in hass_storage
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"delay": "0"},
|
|
}
|
|
|
|
|
|
async def test_multiple_save_calls(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test multiple write tasks."""
|
|
|
|
assert store.key not in hass_storage
|
|
|
|
tasks = [store.async_save({"savecount": savecount}) for savecount in range(6)]
|
|
await asyncio.gather(*tasks)
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"savecount": 5},
|
|
}
|
|
|
|
data = await store.async_load()
|
|
assert data == {"savecount": 5}
|
|
|
|
|
|
async def test_migrator_no_existing_config(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test migrator with no existing config."""
|
|
with (
|
|
patch("os.path.isfile", return_value=False),
|
|
patch.object(store, "async_load", return_value={"cur": "config"}),
|
|
):
|
|
data = await storage.async_migrator(hass, "old-path", store)
|
|
|
|
assert data == {"cur": "config"}
|
|
assert store.key not in hass_storage
|
|
|
|
|
|
async def test_migrator_existing_config(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test migrating existing config."""
|
|
with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove:
|
|
data = await storage.async_migrator(
|
|
hass, "old-path", store, old_conf_load_func=lambda _: {"old": "config"}
|
|
)
|
|
|
|
assert len(mock_remove.mock_calls) == 1
|
|
assert data == {"old": "config"}
|
|
assert hass_storage[store.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"data": data,
|
|
}
|
|
|
|
|
|
async def test_migrator_transforming_config(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test migrating config to new format."""
|
|
|
|
async def old_conf_migrate_func(old_config):
|
|
"""Migrate old config to new format."""
|
|
return {"new": old_config["old"]}
|
|
|
|
with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove:
|
|
data = await storage.async_migrator(
|
|
hass,
|
|
"old-path",
|
|
store,
|
|
old_conf_migrate_func=old_conf_migrate_func,
|
|
old_conf_load_func=lambda _: {"old": "config"},
|
|
)
|
|
|
|
assert len(mock_remove.mock_calls) == 1
|
|
assert data == {"new": "config"}
|
|
assert hass_storage[store.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"data": data,
|
|
}
|
|
|
|
|
|
async def test_minor_version_default(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test minor version default."""
|
|
|
|
await store.async_save(MOCK_DATA)
|
|
assert hass_storage[store.key]["minor_version"] == 1
|
|
|
|
|
|
async def test_minor_version(
|
|
hass: HomeAssistant, store_v_1_2: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test minor version."""
|
|
|
|
await store_v_1_2.async_save(MOCK_DATA)
|
|
assert hass_storage[store_v_1_2.key]["minor_version"] == MOCK_MINOR_VERSION_2
|
|
|
|
|
|
async def test_loading_newer_major_version_raises(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
store: storage.Store,
|
|
store_v_2_1: storage.Store,
|
|
) -> None:
|
|
"""Test loading storage with a newer major version raises and preserves data."""
|
|
await store_v_2_1.async_save(MOCK_DATA)
|
|
with pytest.raises(UnsupportedStorageVersionError) as exc_info:
|
|
await store.async_load()
|
|
assert exc_info.value.storage_key == MOCK_KEY
|
|
assert exc_info.value.found_version == MOCK_VERSION_2
|
|
assert exc_info.value.max_supported_version == MOCK_VERSION
|
|
# Verify on-disk data is not modified
|
|
assert hass_storage[MOCK_KEY]["version"] == MOCK_VERSION_2
|
|
assert hass_storage[MOCK_KEY]["minor_version"] == MOCK_MINOR_VERSION_1
|
|
assert hass_storage[MOCK_KEY]["data"] == MOCK_DATA
|
|
|
|
|
|
async def test_migrate_minor_not_implemented(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
store_v_1_1: storage.Store,
|
|
store_v_1_2: storage.Store,
|
|
) -> None:
|
|
"""Test migrating between minor versions does not fail if not implemented."""
|
|
|
|
assert store_v_1_1.key == store_v_1_2.key
|
|
|
|
await store_v_1_1.async_save(MOCK_DATA)
|
|
assert hass_storage[store_v_1_1.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": MOCK_VERSION,
|
|
"minor_version": MOCK_MINOR_VERSION_1,
|
|
"data": MOCK_DATA,
|
|
}
|
|
data = await store_v_1_2.async_load()
|
|
assert hass_storage[store_v_1_1.key]["data"] == data
|
|
|
|
await store_v_1_2.async_save(MOCK_DATA)
|
|
assert hass_storage[store_v_1_2.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": MOCK_VERSION,
|
|
"minor_version": MOCK_MINOR_VERSION_2,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
|
|
async def test_migration(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2: storage.Store
|
|
) -> None:
|
|
"""Test migration."""
|
|
calls = 0
|
|
|
|
class CustomStore(storage.Store):
|
|
async def _async_migrate_func(
|
|
self, old_major_version, old_minor_version, old_data: dict
|
|
):
|
|
nonlocal calls
|
|
calls += 1
|
|
assert old_major_version == store_v_1_2.version
|
|
assert old_minor_version == store_v_1_2.minor_version
|
|
return old_data
|
|
|
|
await store_v_1_2.async_save(MOCK_DATA)
|
|
assert hass_storage[store_v_1_2.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": MOCK_VERSION,
|
|
"minor_version": MOCK_MINOR_VERSION_2,
|
|
"data": MOCK_DATA,
|
|
}
|
|
assert calls == 0
|
|
|
|
custom_store = CustomStore(hass, 2, store_v_1_2.key, minor_version=1)
|
|
data = await custom_store.async_load()
|
|
assert calls == 1
|
|
assert hass_storage[store_v_1_2.key]["data"] == data
|
|
|
|
# Assert the migrated data has been saved
|
|
assert hass_storage[custom_store.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": 2,
|
|
"minor_version": 1,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
|
|
async def test_legacy_migration(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2: storage.Store
|
|
) -> None:
|
|
"""Test legacy migration method signature."""
|
|
calls = 0
|
|
|
|
class LegacyStore(storage.Store):
|
|
async def _async_migrate_func(self, old_version, old_data: dict):
|
|
nonlocal calls
|
|
calls += 1
|
|
assert old_version == store_v_1_2.version
|
|
return old_data
|
|
|
|
await store_v_1_2.async_save(MOCK_DATA)
|
|
assert hass_storage[store_v_1_2.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": MOCK_VERSION,
|
|
"minor_version": MOCK_MINOR_VERSION_2,
|
|
"data": MOCK_DATA,
|
|
}
|
|
assert calls == 0
|
|
|
|
legacy_store = LegacyStore(hass, 2, store_v_1_2.key, minor_version=1)
|
|
data = await legacy_store.async_load()
|
|
assert calls == 1
|
|
assert hass_storage[store_v_1_2.key]["data"] == data
|
|
|
|
# Assert the migrated data has been saved
|
|
assert hass_storage[legacy_store.key] == {
|
|
"key": MOCK_KEY,
|
|
"version": 2,
|
|
"minor_version": 1,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
|
|
async def test_changing_delayed_written_data(
|
|
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test changing data that is written with delay."""
|
|
data_to_store = {"hello": "world"}
|
|
store.async_delay_save(lambda: data_to_store, 1)
|
|
assert store.key not in hass_storage
|
|
|
|
loaded_data = await store.async_load()
|
|
assert loaded_data == data_to_store
|
|
assert loaded_data is not data_to_store
|
|
|
|
loaded_data["hello"] = "earth"
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
assert hass_storage[store.key] == {
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": {"hello": "world"},
|
|
}
|
|
|
|
|
|
async def test_saving_load_round_trip(tmpdir: py.path.local) -> None:
|
|
"""Test saving and loading round trip."""
|
|
loop = asyncio.get_running_loop()
|
|
config_dir = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
|
|
class NamedTupleSubclass(NamedTuple):
|
|
"""A NamedTuple subclass."""
|
|
|
|
name: str
|
|
|
|
nts = NamedTupleSubclass("a")
|
|
|
|
data = {
|
|
"named_tuple_subclass": nts,
|
|
"rgb_color": RGBColor(255, 255, 0),
|
|
"set": {1, 2, 3},
|
|
"list": [1, 2, 3],
|
|
"tuple": (1, 2, 3),
|
|
"dict_with_int": {1: 1, 2: 2},
|
|
"dict_with_named_tuple": {1: nts, 2: nts},
|
|
}
|
|
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
await store.async_save(data)
|
|
load = await store.async_load()
|
|
assert load == {
|
|
"dict_with_int": {"1": 1, "2": 2},
|
|
"dict_with_named_tuple": {"1": ["a"], "2": ["a"]},
|
|
"list": [1, 2, 3],
|
|
"named_tuple_subclass": ["a"],
|
|
"rgb_color": [255, 255, 0],
|
|
"set": [1, 2, 3],
|
|
"tuple": [1, 2, 3],
|
|
}
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_loading_corrupt_core_file(
|
|
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test we handle unrecoverable corruption in a core file."""
|
|
loop = asyncio.get_running_loop()
|
|
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
|
|
|
|
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
|
|
storage_key = "core.anything"
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION_2, storage_key, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
await store.async_save({"hello": "world"})
|
|
storage_path = os.path.join(tmp_storage, ".storage")
|
|
store_file = os.path.join(storage_path, store.key)
|
|
|
|
data = await store.async_load()
|
|
assert data == {"hello": "world"}
|
|
|
|
def _corrupt_store():
|
|
with open(store_file, "w", encoding="utf8") as f:
|
|
f.write("corrupt")
|
|
|
|
await hass.async_add_executor_job(_corrupt_store)
|
|
|
|
data = await store.async_load()
|
|
assert data is None
|
|
assert "Unrecoverable error decoding storage" in caplog.text
|
|
|
|
issue_registry = ir.async_get(hass)
|
|
found_issue = None
|
|
issue_entry = None
|
|
for (domain, issue), entry in issue_registry.issues.items():
|
|
if domain == HOMEASSISTANT_DOMAIN and issue.startswith(
|
|
f"storage_corruption_{storage_key}_"
|
|
):
|
|
found_issue = issue
|
|
issue_entry = entry
|
|
break
|
|
|
|
assert found_issue is not None
|
|
assert issue_entry is not None
|
|
assert issue_entry.is_fixable is True
|
|
assert issue_entry.translation_placeholders["storage_key"] == storage_key
|
|
assert issue_entry.issue_domain == HOMEASSISTANT_DOMAIN
|
|
assert (
|
|
"unexpected character, expected a JSON value: line 1 column 1 (char 0)"
|
|
in issue_entry.translation_placeholders["error"]
|
|
)
|
|
|
|
files = await hass.async_add_executor_job(
|
|
os.listdir, os.path.join(tmp_storage, ".storage")
|
|
)
|
|
assert ".corrupt" in files[0]
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_loading_corrupt_file_known_domain(
|
|
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test we handle unrecoverable corruption for a known domain."""
|
|
|
|
loop = asyncio.get_running_loop()
|
|
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
|
|
|
|
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
|
|
hass.config.components.add("testdomain")
|
|
storage_key = "testdomain.testkey"
|
|
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION_2, storage_key, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
await store.async_save({"hello": "world"})
|
|
storage_path = os.path.join(tmp_storage, ".storage")
|
|
store_file = os.path.join(storage_path, store.key)
|
|
|
|
data = await store.async_load()
|
|
assert data == {"hello": "world"}
|
|
|
|
def _corrupt_store():
|
|
with open(store_file, "w", encoding="utf8") as f:
|
|
f.write('{"valid":"json"}..with..corrupt')
|
|
|
|
await hass.async_add_executor_job(_corrupt_store)
|
|
|
|
data = await store.async_load()
|
|
assert data is None
|
|
assert "Unrecoverable error decoding storage" in caplog.text
|
|
|
|
issue_registry = ir.async_get(hass)
|
|
found_issue = None
|
|
issue_entry = None
|
|
for (domain, issue), entry in issue_registry.issues.items():
|
|
if domain == HOMEASSISTANT_DOMAIN and issue.startswith(
|
|
f"storage_corruption_{storage_key}_"
|
|
):
|
|
found_issue = issue
|
|
issue_entry = entry
|
|
break
|
|
|
|
assert found_issue is not None
|
|
assert issue_entry is not None
|
|
assert issue_entry.is_fixable is True
|
|
assert issue_entry.translation_placeholders["storage_key"] == storage_key
|
|
assert issue_entry.issue_domain == "testdomain"
|
|
assert (
|
|
"unexpected content after document: line 1 column 17 (char 16)"
|
|
in issue_entry.translation_placeholders["error"]
|
|
)
|
|
|
|
files = await hass.async_add_executor_job(
|
|
os.listdir, os.path.join(tmp_storage, ".storage")
|
|
)
|
|
assert ".corrupt" in files[0]
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_os_error_is_fatal(tmpdir: py.path.local) -> None:
|
|
"""Test OSError during load is fatal."""
|
|
loop = asyncio.get_running_loop()
|
|
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
|
|
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
await store.async_save({"hello": "world"})
|
|
|
|
with (
|
|
pytest.raises(OSError),
|
|
patch(
|
|
"homeassistant.helpers.storage.json_util.load_json", side_effect=OSError
|
|
),
|
|
):
|
|
await store.async_load()
|
|
|
|
# Verify second load is also failing
|
|
with (
|
|
pytest.raises(OSError),
|
|
patch(
|
|
"homeassistant.helpers.storage.json_util.load_json", side_effect=OSError
|
|
),
|
|
):
|
|
await store.async_load()
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_json_load_failure(tmpdir: py.path.local) -> None:
|
|
"""Test json load raising HomeAssistantError."""
|
|
loop = asyncio.get_running_loop()
|
|
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
|
|
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
|
|
store = storage.Store(
|
|
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
|
|
)
|
|
await store.async_save({"hello": "world"})
|
|
base_os_error = OSError()
|
|
base_os_error.errno = 30
|
|
home_assistant_error = HomeAssistantError()
|
|
home_assistant_error.__cause__ = base_os_error
|
|
|
|
with (
|
|
pytest.raises(HomeAssistantError),
|
|
patch(
|
|
"homeassistant.helpers.storage.json_util.load_json",
|
|
side_effect=home_assistant_error,
|
|
),
|
|
):
|
|
await store.async_load()
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_read_only_store(
|
|
hass: HomeAssistant, read_only_store: storage.Store, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test store opened in read only mode does not save."""
|
|
read_only_store.async_delay_save(lambda: MOCK_DATA, 1)
|
|
assert read_only_store.key not in hass_storage
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
|
await hass.async_block_till_done()
|
|
assert read_only_store.key not in hass_storage
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
hass.set_state(CoreState.stopping)
|
|
await hass.async_block_till_done()
|
|
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
|
|
await hass.async_block_till_done()
|
|
assert read_only_store.key not in hass_storage
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
|
|
await hass.async_block_till_done()
|
|
assert read_only_store.key not in hass_storage
|
|
|
|
|
|
async def test_store_manager_caching(
|
|
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test store manager caching."""
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _setup_mock_storage():
|
|
config_dir = tmpdir.mkdir("temp_config")
|
|
tmp_storage = config_dir.mkdir(".storage")
|
|
tmp_storage.join("integration1").write_binary(
|
|
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
|
|
)
|
|
tmp_storage.join("integration2").write_binary(
|
|
json_bytes({"data": {"integration2": "integration2"}, "version": 1})
|
|
)
|
|
tmp_storage.join("broken").write_binary(b"invalid")
|
|
return config_dir
|
|
|
|
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
|
|
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
assert (
|
|
store_manager.async_fetch("integration1") is None
|
|
) # has data but not cached
|
|
assert (
|
|
store_manager.async_fetch("integration2") is None
|
|
) # has data but not cached
|
|
assert (
|
|
store_manager.async_fetch("integration3") is None
|
|
) # no file not but cached
|
|
|
|
await store_manager.async_initialize()
|
|
assert (
|
|
store_manager.async_fetch("integration1") is None
|
|
) # has data but not cached
|
|
assert (
|
|
store_manager.async_fetch("integration2") is None
|
|
) # has data but not cached
|
|
assert (
|
|
store_manager.async_fetch("integration3") is not None
|
|
) # no file and initialized
|
|
|
|
result = store_manager.async_fetch("integration3")
|
|
assert result is not None
|
|
exists, data = result
|
|
assert exists is False
|
|
assert data is None
|
|
|
|
await store_manager.async_preload(["integration3", "integration2", "broken"])
|
|
assert "Error loading broken" in caplog.text
|
|
|
|
assert (
|
|
store_manager.async_fetch("integration1") is None
|
|
) # has data but not cached
|
|
result = store_manager.async_fetch("integration2")
|
|
assert result is not None
|
|
exists, data = result
|
|
assert exists is True
|
|
assert data == {"data": {"integration2": "integration2"}, "version": 1}
|
|
|
|
assert (
|
|
store_manager.async_fetch("integration3") is not None
|
|
) # no file and initialized
|
|
result = store_manager.async_fetch("integration3")
|
|
assert result is not None
|
|
exists, data = result
|
|
assert exists is False
|
|
assert data is None
|
|
|
|
integration1 = storage.Store(hass, 1, "integration1")
|
|
await integration1.async_save({"integration1": "updated"})
|
|
# Save should invalidate the cache
|
|
assert store_manager.async_fetch("integration1") is None # invalidated
|
|
|
|
integration2 = storage.Store(hass, 1, "integration2")
|
|
integration2.async_delay_save(lambda: {"integration2": "updated"})
|
|
# Delay save should invalidate the cache after it saves
|
|
assert "integration2" not in store_manager._invalidated
|
|
|
|
# Block twice to flush out the delayed save
|
|
await hass.async_block_till_done()
|
|
await hass.async_block_till_done()
|
|
assert store_manager.async_fetch("integration2") is None # invalidated
|
|
|
|
store_manager.async_invalidate("integration3")
|
|
assert store_manager.async_fetch("integration1") is None # invalidated by save
|
|
assert (
|
|
store_manager.async_fetch("integration2") is None
|
|
) # invalidated by delay save
|
|
assert store_manager.async_fetch("integration3") is None # invalidated
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
assert store_manager.async_fetch("integration1") is None
|
|
assert store_manager.async_fetch("integration2") is None
|
|
assert store_manager.async_fetch("integration3") is None
|
|
await store_manager.async_initialize()
|
|
await store_manager.async_preload(["integration1", "integration2"])
|
|
result = store_manager.async_fetch("integration1")
|
|
assert result is not None
|
|
exists, data = result
|
|
assert exists is True
|
|
assert data["data"] == {"integration1": "updated"}
|
|
|
|
integration1 = storage.Store(hass, 1, "integration1")
|
|
assert await integration1.async_load() == {"integration1": "updated"}
|
|
|
|
# Load should pop the cache
|
|
assert store_manager.async_fetch("integration1") is None
|
|
|
|
integration2 = storage.Store(hass, 1, "integration2")
|
|
assert await integration2.async_load() == {"integration2": "updated"}
|
|
|
|
# Load should pop the cache
|
|
assert store_manager.async_fetch("integration2") is None
|
|
|
|
integration3 = storage.Store(hass, 1, "integration3")
|
|
assert await integration3.async_load() is None
|
|
|
|
await integration3.async_save({"integration3": "updated"})
|
|
assert await integration3.async_load() == {"integration3": "updated"}
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
# Now make sure everything still works when we do not
|
|
# manually load the storage manager
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
integration1 = storage.Store(hass, 1, "integration1")
|
|
assert await integration1.async_load() == {"integration1": "updated"}
|
|
await integration1.async_save({"integration1": "updated2"})
|
|
assert await integration1.async_load() == {"integration1": "updated2"}
|
|
|
|
integration2 = storage.Store(hass, 1, "integration2")
|
|
assert await integration2.async_load() == {"integration2": "updated"}
|
|
await integration2.async_save({"integration2": "updated2"})
|
|
assert await integration2.async_load() == {"integration2": "updated2"}
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
# Now remove the stores
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
await store_manager.async_initialize()
|
|
await store_manager.async_preload(["integration1", "integration2"])
|
|
|
|
integration1 = storage.Store(hass, 1, "integration1")
|
|
assert integration1._manager is store_manager
|
|
assert await integration1.async_load() == {"integration1": "updated2"}
|
|
|
|
integration2 = storage.Store(hass, 1, "integration2")
|
|
assert integration2._manager is store_manager
|
|
assert await integration2.async_load() == {"integration2": "updated2"}
|
|
|
|
await integration1.async_remove()
|
|
await integration2.async_remove()
|
|
|
|
assert store_manager.async_fetch("integration1") is None
|
|
assert store_manager.async_fetch("integration2") is None
|
|
|
|
assert await integration1.async_load() is None
|
|
assert await integration2.async_load() is None
|
|
|
|
await hass.async_stop(force=True)
|
|
|
|
# Now make sure the stores are removed and another run works
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
await store_manager.async_initialize()
|
|
await store_manager.async_preload(["integration1"])
|
|
result = store_manager.async_fetch("integration1")
|
|
assert result is not None
|
|
exists, data = result
|
|
assert exists is False
|
|
assert data is None
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_store_manager_sub_dirs(tmpdir: py.path.local) -> None:
|
|
"""Test store manager ignores subdirs."""
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _setup_mock_storage():
|
|
config_dir = tmpdir.mkdir("temp_config")
|
|
sub_dir_storage = config_dir.mkdir(".storage").mkdir("subdir")
|
|
|
|
sub_dir_storage.join("integration1").write_binary(
|
|
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
|
|
)
|
|
return config_dir
|
|
|
|
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
|
|
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
await store_manager.async_initialize()
|
|
assert store_manager.async_fetch("subdir/integration1") is None
|
|
assert store_manager.async_fetch("subdir/integrationx") is None
|
|
integration1 = storage.Store(hass, 1, "subdir/integration1")
|
|
assert await integration1.async_load() == {"integration1": "integration1"}
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_store_manager_cleanup_after_started(
|
|
tmpdir: py.path.local, freezer: FrozenDateTimeFactory
|
|
) -> None:
|
|
"""Test that the cache is cleaned up after startup."""
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _setup_mock_storage():
|
|
config_dir = tmpdir.mkdir("temp_config")
|
|
tmp_storage = config_dir.mkdir(".storage")
|
|
tmp_storage.join("integration1").write_binary(
|
|
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
|
|
)
|
|
tmp_storage.join("integration2").write_binary(
|
|
json_bytes({"data": {"integration2": "integration2"}, "version": 1})
|
|
)
|
|
return config_dir
|
|
|
|
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
|
|
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
hass.set_state(CoreState.not_running)
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
await store_manager.async_initialize()
|
|
await store_manager.async_preload(["integration1", "integration2"])
|
|
assert "integration1" in store_manager._data_preload
|
|
assert "integration2" in store_manager._data_preload
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
|
await hass.async_block_till_done()
|
|
assert "integration1" in store_manager._data_preload
|
|
assert "integration2" in store_manager._data_preload
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
|
await hass.async_block_till_done()
|
|
assert "integration1" in store_manager._data_preload
|
|
assert "integration2" in store_manager._data_preload
|
|
freezer.tick(storage.MANAGER_CLEANUP_DELAY)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
# The cache should be removed after the cleanup delay
|
|
# since it means nothing ever loaded it and we want to
|
|
# recover the memory
|
|
assert "integration1" not in store_manager._data_preload
|
|
assert "integration2" not in store_manager._data_preload
|
|
assert store_manager.async_fetch("integration1") is None
|
|
assert store_manager.async_fetch("integration2") is None
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_store_manager_cleanup_after_stop(
|
|
tmpdir: py.path.local, freezer: FrozenDateTimeFactory
|
|
) -> None:
|
|
"""Test that the cache is cleaned up after stop event.
|
|
|
|
This should only happen if we stop within the cleanup delay.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _setup_mock_storage():
|
|
config_dir = tmpdir.mkdir("temp_config")
|
|
tmp_storage = config_dir.mkdir(".storage")
|
|
tmp_storage.join("integration1").write_binary(
|
|
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
|
|
)
|
|
tmp_storage.join("integration2").write_binary(
|
|
json_bytes({"data": {"integration2": "integration2"}, "version": 1})
|
|
)
|
|
return config_dir
|
|
|
|
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
|
|
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
hass.set_state(CoreState.not_running)
|
|
store_manager = storage.get_internal_store_manager(hass)
|
|
await store_manager.async_initialize()
|
|
await store_manager.async_preload(["integration1", "integration2"])
|
|
assert "integration1" in store_manager._data_preload
|
|
assert "integration2" in store_manager._data_preload
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
|
await hass.async_block_till_done()
|
|
assert "integration1" in store_manager._data_preload
|
|
assert "integration2" in store_manager._data_preload
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
|
await hass.async_block_till_done()
|
|
assert "integration1" in store_manager._data_preload
|
|
assert "integration2" in store_manager._data_preload
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
assert "integration1" not in store_manager._data_preload
|
|
assert "integration2" not in store_manager._data_preload
|
|
assert store_manager.async_fetch("integration1") is None
|
|
assert store_manager.async_fetch("integration2") is None
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
async def test_storage_concurrent_load(hass: HomeAssistant) -> None:
|
|
"""Test that we can load the store concurrently."""
|
|
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
|
|
async def _load_store():
|
|
await asyncio.sleep(0)
|
|
return "data"
|
|
|
|
with patch.object(store, "_async_load", side_effect=_load_store):
|
|
# Test that we can load the store concurrently
|
|
loads = await asyncio.gather(
|
|
store.async_load(), store.async_load(), store.async_load()
|
|
)
|
|
for load in loads:
|
|
assert load == "data"
|
|
|
|
|
|
async def test_load_empty_returns_none_and_read_only(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test store with load_empty returns None, becomes read-only."""
|
|
# Use a future version to also verify no version error is raised
|
|
hass_storage[MOCK_KEY] = {
|
|
"version": 99,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": MOCK_DATA,
|
|
}
|
|
|
|
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
|
|
store.set_load_empty()
|
|
|
|
data = await store.async_load()
|
|
assert data is None
|
|
assert store._read_only is True
|
|
|
|
await store.async_save({"new": "data"})
|
|
assert hass_storage[MOCK_KEY]["data"] == MOCK_DATA
|
|
assert hass_storage[MOCK_KEY]["version"] == 99
|
|
|
|
|
|
def _storage_file(hass: HomeAssistant, name: str) -> Path:
|
|
"""Return the path of a file in the .storage dir."""
|
|
return Path(hass.config.config_dir) / storage.STORAGE_DIR / name
|
|
|
|
|
|
def _write_store_file(path: Path, data: dict[str, Any], compress: bool) -> None:
|
|
"""Write a store envelope to disk the way a previous run would have."""
|
|
payload = json.dumps(
|
|
{
|
|
"version": MOCK_VERSION,
|
|
"minor_version": 1,
|
|
"key": MOCK_KEY,
|
|
"data": data,
|
|
}
|
|
).encode()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(zstd.compress(payload) if compress else payload)
|
|
|
|
|
|
@pytest.fixture
|
|
async def disk_hass(tmpdir: py.path.local) -> AsyncGenerator[HomeAssistant]:
|
|
"""Yield a Home Assistant instance backed by a real config directory."""
|
|
loop = asyncio.get_running_loop()
|
|
config_dir = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
|
|
|
|
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
|
|
yield hass
|
|
await hass.async_stop(force=True)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("atomic_writes", "serialize_in_event_loop"),
|
|
[
|
|
pytest.param(False, True, id="default"),
|
|
pytest.param(True, True, id="atomic_writes"),
|
|
pytest.param(False, False, id="serialize_in_executor"),
|
|
],
|
|
)
|
|
async def test_compress_save_load_round_trip(
|
|
disk_hass: HomeAssistant, atomic_writes: bool, serialize_in_event_loop: bool
|
|
) -> None:
|
|
"""Test that a compressed store saves a .zst file and loads back correctly."""
|
|
store = storage.Store(
|
|
disk_hass,
|
|
MOCK_VERSION,
|
|
MOCK_KEY,
|
|
compress=True,
|
|
atomic_writes=atomic_writes,
|
|
serialize_in_event_loop=serialize_in_event_loop,
|
|
)
|
|
await store.async_save(MOCK_DATA)
|
|
|
|
zst_file = _storage_file(disk_hass, MOCK_KEY + ".zst")
|
|
plain_file = _storage_file(disk_hass, MOCK_KEY)
|
|
|
|
assert zst_file.is_file()
|
|
assert not plain_file.exists()
|
|
|
|
on_disk = json.loads(zstd.decompress(zst_file.read_bytes()))
|
|
assert on_disk["data"] == MOCK_DATA
|
|
|
|
assert await store.async_load() == MOCK_DATA
|
|
|
|
|
|
async def test_compress_migrates_plain_to_compressed(disk_hass: HomeAssistant) -> None:
|
|
"""Test that saving with compress=True removes an existing plain file."""
|
|
plain_store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY)
|
|
await plain_store.async_save(MOCK_DATA)
|
|
|
|
plain_file = _storage_file(disk_hass, MOCK_KEY)
|
|
assert plain_file.is_file()
|
|
|
|
compressed_store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY, compress=True)
|
|
|
|
# Before the first compressed write the plain file is still the fallback.
|
|
assert await compressed_store.async_load() == MOCK_DATA
|
|
|
|
# Saving with compress=True should write .zst and remove the plain file.
|
|
await compressed_store.async_save(MOCK_DATA2)
|
|
|
|
assert _storage_file(disk_hass, MOCK_KEY + ".zst").is_file()
|
|
assert not plain_file.exists()
|
|
assert await compressed_store.async_load() == MOCK_DATA2
|
|
|
|
|
|
async def test_compress_falls_back_with_initialized_manager(
|
|
disk_hass: HomeAssistant,
|
|
) -> None:
|
|
"""Test the plain fallback is used when the store manager knows the files.
|
|
|
|
The manager caches by filename, so it reports the .zst as non-existent.
|
|
That must not short-circuit the load, because the uncompressed file the
|
|
store falls back to is still on disk.
|
|
"""
|
|
await disk_hass.async_add_executor_job(
|
|
_write_store_file, _storage_file(disk_hass, MOCK_KEY), MOCK_DATA, False
|
|
)
|
|
await storage.get_internal_store_manager(disk_hass).async_initialize()
|
|
|
|
store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY, compress=True)
|
|
assert await store.async_load() == MOCK_DATA
|
|
|
|
|
|
async def test_compress_no_file_at_all(disk_hass: HomeAssistant) -> None:
|
|
"""Test a compressed store with neither file on disk loads as empty."""
|
|
await storage.get_internal_store_manager(disk_hass).async_initialize()
|
|
|
|
store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY, compress=True)
|
|
assert await store.async_load() is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("corrupt_name", "absent_name"),
|
|
[
|
|
pytest.param(MOCK_KEY + ".zst", MOCK_KEY, id="compressed_file"),
|
|
pytest.param(MOCK_KEY, MOCK_KEY + ".zst", id="uncompressed_fallback"),
|
|
],
|
|
)
|
|
async def test_compress_corrupt_file(
|
|
disk_hass: HomeAssistant,
|
|
caplog: pytest.LogCaptureFixture,
|
|
corrupt_name: str,
|
|
absent_name: str,
|
|
) -> None:
|
|
"""Test that the corrupt file is the one renamed, not the compressed path."""
|
|
corrupt_file = _storage_file(disk_hass, corrupt_name)
|
|
|
|
def _write_corrupt() -> None:
|
|
corrupt_file.parent.mkdir(parents=True, exist_ok=True)
|
|
corrupt_file.write_bytes(b"this is not valid zstd data")
|
|
|
|
await disk_hass.async_add_executor_job(_write_corrupt)
|
|
assert not _storage_file(disk_hass, absent_name).exists()
|
|
|
|
store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY, compress=True)
|
|
assert await store.async_load() is None
|
|
assert "Unrecoverable error decoding storage" in caplog.text
|
|
assert not corrupt_file.exists()
|
|
|
|
storage_path = corrupt_file.parent
|
|
files = await disk_hass.async_add_executor_job(os.listdir, storage_path)
|
|
assert [f for f in files if ".corrupt" in f] == [
|
|
f for f in files if f.startswith(corrupt_name + ".corrupt")
|
|
]
|
|
|
|
issue_registry = ir.async_get(disk_hass)
|
|
issues = [
|
|
entry
|
|
for entry in issue_registry.issues.values()
|
|
if entry.translation_key == "storage_corruption"
|
|
]
|
|
assert len(issues) == 1
|
|
assert issues[0].translation_placeholders["original_path"] == str(corrupt_file)
|
|
|
|
|
|
async def test_compress_store_manager_preload(disk_hass: HomeAssistant) -> None:
|
|
"""Test that a compressed store is preloaded when its plain key is asked for."""
|
|
await disk_hass.async_add_executor_job(
|
|
_write_store_file,
|
|
_storage_file(disk_hass, MOCK_KEY + ".zst"),
|
|
MOCK_DATA,
|
|
True,
|
|
)
|
|
|
|
store_manager = storage.get_internal_store_manager(disk_hass)
|
|
await store_manager.async_initialize()
|
|
# Callers such as bootstrap only know the plain key.
|
|
await store_manager.async_preload([MOCK_KEY])
|
|
|
|
result = store_manager.async_fetch(MOCK_KEY + ".zst")
|
|
assert result is not None
|
|
exists, cached_data = result
|
|
assert exists is True
|
|
assert cached_data["data"] == MOCK_DATA # type: ignore[index]
|
|
|
|
|
|
async def test_compress_store_manager_cache(disk_hass: HomeAssistant) -> None:
|
|
"""Test that compressed stores are served from the store manager cache."""
|
|
await disk_hass.async_add_executor_job(
|
|
_write_store_file,
|
|
_storage_file(disk_hass, MOCK_KEY + ".zst"),
|
|
MOCK_DATA,
|
|
True,
|
|
)
|
|
|
|
store_manager = storage.get_internal_store_manager(disk_hass)
|
|
await store_manager.async_initialize()
|
|
await store_manager.async_preload([MOCK_KEY + ".zst"])
|
|
|
|
store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY, compress=True)
|
|
assert await store.async_load() == MOCK_DATA
|
|
|
|
|
|
async def test_compress_async_remove(disk_hass: HomeAssistant) -> None:
|
|
"""Test that removing a compressed store removes both files."""
|
|
zst_file = _storage_file(disk_hass, MOCK_KEY + ".zst")
|
|
plain_file = _storage_file(disk_hass, MOCK_KEY)
|
|
await disk_hass.async_add_executor_job(_write_store_file, zst_file, MOCK_DATA, True)
|
|
await disk_hass.async_add_executor_job(
|
|
_write_store_file, plain_file, MOCK_DATA, False
|
|
)
|
|
|
|
store = storage.Store(disk_hass, MOCK_VERSION, MOCK_KEY, compress=True)
|
|
await store.async_remove()
|
|
|
|
assert not zst_file.exists()
|
|
assert not plain_file.exists()
|