Purge unreachable deleted devices on device registry load (#181207)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erik Montnemery
2026-09-05 10:33:11 +00:00
committed by Franck Nijhof
co-authored by Copilot Autofix powered by AI
parent 6759b6053d
commit 99f4a922e5
2 changed files with 82 additions and 3 deletions
+18 -3
View File
@@ -4156,6 +4156,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
child_devices = ChildDeviceRegistryItems()
deleted_devices = DeletedDeviceRegistryItems()
child_devices_dropped = False
empty_deleted_devices_dropped = 0
if data is not None:
for device in data["devices"]:
@@ -4259,6 +4260,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
return None
for device in data["deleted_devices"]:
# A deleted device with neither identifiers nor connections can never
# be restored (restore matches a re-registered device by identifier or
# connection) and serves no deduplication purpose, so it would linger
# forever. Current code cannot create one; drop such legacy cruft on
# load instead of carrying it in memory and rewriting it on every save.
if not device["identifiers"] and not device["connections"]:
empty_deleted_devices_dropped += 1
continue
deleted_devices[device["id"]] = DeletedDeviceEntry(
area_id=device["area_id"],
config_entry_id=device["config_entry_id"],
@@ -4290,6 +4299,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
shadowed_count,
)
if empty_deleted_devices_dropped:
_LOGGER.info(
"Dropped %d deleted devices with no identifiers or connections",
empty_deleted_devices_dropped,
)
self._devices = devices
self.devices = _DeprecatedDeviceRegistryItemsView(self._devices)
self._child_devices = child_devices
@@ -4298,9 +4313,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self._device_data = devices.data
self._child_device_data = child_devices.data
# Persist dropped corrupt/orphaned children so the store isn't left dirty until
# an unrelated write
if child_devices_dropped:
# Persist dropped corrupt/orphaned children and empty deleted devices so the
# store isn't left dirty until an unrelated write
if child_devices_dropped or empty_deleted_devices_dropped:
self.async_schedule_save()
self._loaded_event.set()
+64
View File
@@ -11583,6 +11583,70 @@ async def test_loading_child_device_with_missing_parent(
assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == []
@pytest.mark.parametrize("load_registries", [False])
async def test_loading_drops_empty_deleted_devices(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test stored deleted devices with no identifiers or connections are dropped."""
def _deleted_device(
device_id: str,
identifiers: list[list[str]],
connections: list[list[str]],
) -> dict[str, Any]:
return {
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"connections": connections,
"created_at": "2024-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": False,
"id": device_id,
"identifiers": identifiers,
"labels": [],
"modified_at": "2024-01-01T00:00:00+00:00",
"name_by_user": None,
"orphaned_timestamp": None,
"domain": None,
}
hass_storage[dr.STORAGE_KEY] = {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [],
"child_devices": [],
"deleted_devices": [
_deleted_device("with_identifiers", [["test", "1"]], []),
_deleted_device("with_connections", [], [["mac", "12:34:56:78:90:ab"]]),
_deleted_device("empty_1", [], []),
_deleted_device("empty_2", [], []),
],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
assert set(registry._deleted_devices) == {"with_identifiers", "with_connections"}
assert "Dropped 2 deleted devices with no identifiers or connections" in caplog.text
# The drop scheduled a save, so it persists instead of leaving the store dirty
# until an unrelated write
await flush_store(registry._store)
stored_ids = {
device["id"]
for device in hass_storage[dr.STORAGE_KEY]["data"]["deleted_devices"]
}
assert stored_ids == {"with_identifiers", "with_connections"}
async def test_effective_area_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,