Add minor version support to storage.Store (#59882)

This commit is contained in:
Erik Montnemery 2021-11-18 17:15:40 +01:00 committed by GitHub
parent cc3f179796
commit d18c250acf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 206 additions and 13 deletions

View File

@ -20,14 +20,14 @@ STORAGE_VERSION = 4
class OnboadingStorage(Store): class OnboadingStorage(Store):
"""Store onboarding data.""" """Store onboarding data."""
async def _async_migrate_func(self, old_version, old_data): async def _async_migrate_func(self, old_major_version, old_minor_version, old_data):
"""Migrate to the new version.""" """Migrate to the new version."""
# From version 1 -> 2, we automatically mark the integration step done # From version 1 -> 2, we automatically mark the integration step done
if old_version < 2: if old_major_version < 2:
old_data["done"].append(STEP_INTEGRATION) old_data["done"].append(STEP_INTEGRATION)
if old_version < 3: if old_major_version < 3:
old_data["done"].append(STEP_CORE_CONFIG) old_data["done"].append(STEP_CORE_CONFIG)
if old_version < 4: if old_major_version < 4:
old_data["done"].append(STEP_ANALYTICS) old_data["done"].append(STEP_ANALYTICS)
return old_data return old_data

View File

@ -148,7 +148,7 @@ UPDATE_FIELDS = {
class PersonStore(Store): class PersonStore(Store):
"""Person storage.""" """Person storage."""
async def _async_migrate_func(self, old_version, old_data): async def _async_migrate_func(self, old_major_version, old_minor_version, old_data):
"""Migrate to the new version. """Migrate to the new version.
Migrate storage to use format of collection helper. Migrate storage to use format of collection helper.

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
import inspect
from json import JSONEncoder from json import JSONEncoder
import logging import logging
import os import os
@ -75,11 +76,13 @@ class Store:
key: str, key: str,
private: bool = False, private: bool = False,
*, *,
encoder: type[JSONEncoder] | None = None,
atomic_writes: bool = False, atomic_writes: bool = False,
encoder: type[JSONEncoder] | None = None,
minor_version: int = 1,
) -> None: ) -> None:
"""Initialize storage class.""" """Initialize storage class."""
self.version = version self.version = version
self.minor_version = minor_version
self.key = key self.key = key
self.hass = hass self.hass = hass
self._private = private self._private = private
@ -99,8 +102,8 @@ class Store:
async def async_load(self) -> dict | list | None: async def async_load(self) -> dict | list | None:
"""Load data. """Load data.
If the expected version does not match the given version, the migrate If the expected version and minor version do not match the given versions, the
function will be invoked with await migrate_func(version, config). migrate function will be invoked with migrate_func(version, minor_version, config).
Will ensure that when a call comes in while another one is in progress, Will ensure that when a call comes in while another one is in progress,
the second call will wait and return the result of the first call. the second call will wait and return the result of the first call.
@ -137,7 +140,15 @@ class Store:
if data == {}: if data == {}:
return None return None
if data["version"] == self.version:
# Add minor_version if not set
if "minor_version" not in data:
data["minor_version"] = 1
if (
data["version"] == self.version
and data["minor_version"] == self.minor_version
):
stored = data["data"] stored = data["data"]
else: else:
_LOGGER.info( _LOGGER.info(
@ -146,13 +157,29 @@ class Store:
data["version"], data["version"],
self.version, self.version,
) )
if len(inspect.signature(self._async_migrate_func).parameters) == 2:
# pylint: disable-next=no-value-for-parameter
stored = await self._async_migrate_func(data["version"], data["data"]) stored = await self._async_migrate_func(data["version"], data["data"])
else:
try:
stored = await self._async_migrate_func(
data["version"], data["minor_version"], data["data"]
)
except NotImplementedError:
if data["version"] != self.version:
raise
stored = data["data"]
return stored return stored
async def async_save(self, data: dict | list) -> None: async def async_save(self, data: dict | list) -> None:
"""Save data.""" """Save data."""
self._data = {"version": self.version, "key": self.key, "data": data} self._data = {
"version": self.version,
"minor_version": self.minor_version,
"key": self.key,
"data": data,
}
if self.hass.state == CoreState.stopping: if self.hass.state == CoreState.stopping:
self._async_ensure_final_write_listener() self._async_ensure_final_write_listener()
@ -163,7 +190,12 @@ class Store:
@callback @callback
def async_delay_save(self, data_func: Callable[[], dict], delay: float = 0) -> None: def async_delay_save(self, data_func: Callable[[], dict], delay: float = 0) -> None:
"""Save data with an optional delay.""" """Save data with an optional delay."""
self._data = {"version": self.version, "key": self.key, "data_func": data_func} self._data = {
"version": self.version,
"minor_version": self.minor_version,
"key": self.key,
"data_func": data_func,
}
self._async_cleanup_delay_listener() self._async_cleanup_delay_listener()
self._async_ensure_final_write_listener() self._async_ensure_final_write_listener()
@ -248,7 +280,7 @@ class Store:
atomic_writes=self._atomic_writes, atomic_writes=self._atomic_writes,
) )
async def _async_migrate_func(self, old_version, old_data): async def _async_migrate_func(self, old_major_version, old_minor_version, old_data):
"""Migrate to the new version.""" """Migrate to the new version."""
raise NotImplementedError raise NotImplementedError

View File

@ -169,6 +169,7 @@ async def test_agent_user_id_storage(hass, hass_storage):
hass_storage["google_assistant"] = { hass_storage["google_assistant"] = {
"version": 1, "version": 1,
"minor_version": 1,
"key": "google_assistant", "key": "google_assistant",
"data": {"agent_user_ids": {"agent_1": {}}}, "data": {"agent_user_ids": {"agent_1": {}}},
} }
@ -178,6 +179,7 @@ async def test_agent_user_id_storage(hass, hass_storage):
assert hass_storage["google_assistant"] == { assert hass_storage["google_assistant"] == {
"version": 1, "version": 1,
"minor_version": 1,
"key": "google_assistant", "key": "google_assistant",
"data": {"agent_user_ids": {"agent_1": {}}}, "data": {"agent_user_ids": {"agent_1": {}}},
} }
@ -188,6 +190,7 @@ async def test_agent_user_id_storage(hass, hass_storage):
assert hass_storage["google_assistant"] == { assert hass_storage["google_assistant"] == {
"version": 1, "version": 1,
"minor_version": 1,
"key": "google_assistant", "key": "google_assistant",
"data": data, "data": data,
} }

View File

@ -17,6 +17,9 @@ from homeassistant.util import dt
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
MOCK_VERSION = 1 MOCK_VERSION = 1
MOCK_VERSION_2 = 2
MOCK_MINOR_VERSION_1 = 1
MOCK_MINOR_VERSION_2 = 2
MOCK_KEY = "storage-test" MOCK_KEY = "storage-test"
MOCK_DATA = {"hello": "world"} MOCK_DATA = {"hello": "world"}
MOCK_DATA2 = {"goodbye": "cruel world"} MOCK_DATA2 = {"goodbye": "cruel world"}
@ -28,6 +31,30 @@ def store(hass):
yield storage.Store(hass, MOCK_VERSION, MOCK_KEY) yield storage.Store(hass, MOCK_VERSION, MOCK_KEY)
@pytest.fixture
def store_v_1_1(hass):
"""Fixture of a store that prevents writing on Home Assistant stop."""
yield storage.Store(
hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
@pytest.fixture
def store_v_1_2(hass):
"""Fixture of a store that prevents writing on Home Assistant stop."""
yield storage.Store(
hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_2
)
@pytest.fixture
def store_v_2_1(hass):
"""Fixture of a store that prevents writing on Home Assistant stop."""
yield storage.Store(
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
async def test_loading(hass, store): async def test_loading(hass, store):
"""Test we can save and load data.""" """Test we can save and load data."""
await store.async_save(MOCK_DATA) await store.async_save(MOCK_DATA)
@ -78,6 +105,7 @@ async def test_saving_with_delay(hass, store, hass_storage):
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": MOCK_DATA, "data": MOCK_DATA,
} }
@ -101,6 +129,7 @@ async def test_saving_on_final_write(hass, hass_storage):
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": MOCK_DATA, "data": MOCK_DATA,
} }
@ -148,6 +177,7 @@ async def test_loading_while_delay(hass, store, hass_storage):
await store.async_save({"delay": "no"}) await store.async_save({"delay": "no"})
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"delay": "no"}, "data": {"delay": "no"},
} }
@ -155,6 +185,7 @@ async def test_loading_while_delay(hass, store, hass_storage):
store.async_delay_save(lambda: {"delay": "yes"}, 1) store.async_delay_save(lambda: {"delay": "yes"}, 1)
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"delay": "no"}, "data": {"delay": "no"},
} }
@ -170,6 +201,7 @@ async def test_writing_while_writing_delay(hass, store, hass_storage):
await store.async_save({"delay": "no"}) await store.async_save({"delay": "no"})
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"delay": "no"}, "data": {"delay": "no"},
} }
@ -178,6 +210,7 @@ async def test_writing_while_writing_delay(hass, store, hass_storage):
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"delay": "no"}, "data": {"delay": "no"},
} }
@ -196,6 +229,7 @@ async def test_multiple_delay_save_calls(hass, store, hass_storage):
await store.async_save({"delay": "no"}) await store.async_save({"delay": "no"})
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"delay": "no"}, "data": {"delay": "no"},
} }
@ -204,6 +238,7 @@ async def test_multiple_delay_save_calls(hass, store, hass_storage):
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"delay": "no"}, "data": {"delay": "no"},
} }
@ -221,6 +256,7 @@ async def test_multiple_save_calls(hass, store, hass_storage):
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY, "key": MOCK_KEY,
"data": {"savecount": 5}, "data": {"savecount": 5},
} }
@ -252,6 +288,7 @@ async def test_migrator_existing_config(hass, store, hass_storage):
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"key": MOCK_KEY, "key": MOCK_KEY,
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"data": data, "data": data,
} }
@ -277,5 +314,125 @@ async def test_migrator_transforming_config(hass, store, hass_storage):
assert hass_storage[store.key] == { assert hass_storage[store.key] == {
"key": MOCK_KEY, "key": MOCK_KEY,
"version": MOCK_VERSION, "version": MOCK_VERSION,
"minor_version": 1,
"data": data, "data": data,
} }
async def test_minor_version_default(hass, store, hass_storage):
"""Test minor version default."""
await store.async_save(MOCK_DATA)
assert hass_storage[store.key]["minor_version"] == 1
async def test_minor_version(hass, store_v_1_2, hass_storage):
"""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_migrate_major_not_implemented_raises(hass, store, store_v_2_1):
"""Test migrating between major versions fails if not implemented."""
await store_v_2_1.async_save(MOCK_DATA)
with pytest.raises(NotImplementedError):
await store.async_load()
async def test_migrate_minor_not_implemented(
hass, hass_storage, store_v_1_1, store_v_1_2
):
"""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, hass_storage, store_v_1_2):
"""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
legacy_store = CustomStore(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
await legacy_store.async_save(MOCK_DATA)
assert hass_storage[legacy_store.key] == {
"key": MOCK_KEY,
"version": 2,
"minor_version": 1,
"data": MOCK_DATA,
}
async def test_legacy_migration(hass, hass_storage, store_v_1_2):
"""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
await legacy_store.async_save(MOCK_DATA)
assert hass_storage[legacy_store.key] == {
"key": MOCK_KEY,
"version": 2,
"minor_version": 1,
"data": MOCK_DATA,
}

View File

@ -444,6 +444,7 @@ async def test_updating_configuration(hass, hass_storage):
}, },
"key": "core.config", "key": "core.config",
"version": 1, "version": 1,
"minor_version": 1,
} }
hass_storage["core.config"] = dict(core_data) hass_storage["core.config"] = dict(core_data)
await config_util.async_process_ha_core_config( await config_util.async_process_ha_core_config(