From 99f4a922e57e879f599afd0ccea3f8a6a4871033 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Sep 2026 20:08:28 +0200 Subject: [PATCH] Purge unreachable deleted devices on device registry load (#181207) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/device_registry.py | 21 ++++++-- tests/helpers/test_device_registry.py | 64 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 3a4a76a72a07..1f7f0f0699d1 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -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() diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index a30e524a3d24..2b9023166c93 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -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,