Avoid firing update coordinator callbacks when nothing has changed (#97268)

pull/97410/head
J. Nick Koston 2023-07-28 12:19:20 -05:00 committed by GitHub
parent 3a54448836
commit 13349e76ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 135 additions and 2 deletions

View File

@ -54,7 +54,12 @@ class BaseDataUpdateCoordinatorProtocol(Protocol):
class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
"""Class to manage fetching data from single endpoint."""
"""Class to manage fetching data from single endpoint.
Setting :attr:`always_update` to ``False`` will cause coordinator to only
callback listeners when data has changed. This requires that the data
implements ``__eq__`` or uses a python object that already does.
"""
def __init__(
self,
@ -65,6 +70,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
update_interval: timedelta | None = None,
update_method: Callable[[], Awaitable[_DataT]] | None = None,
request_refresh_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
always_update: bool = True,
) -> None:
"""Initialize global data updater."""
self.hass = hass
@ -74,6 +80,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
self.update_interval = update_interval
self._shutdown_requested = False
self.config_entry = config_entries.current_entry.get()
self.always_update = always_update
# It's None before the first successful update.
# Components should call async_config_entry_first_refresh
@ -277,7 +284,10 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
if log_timing := self.logger.isEnabledFor(logging.DEBUG):
start = monotonic()
auth_failed = False
previous_update_success = self.last_update_success
previous_data = self.data
try:
self.data = await self._async_update_data()
@ -371,7 +381,15 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
if not auth_failed and self._listeners and not self.hass.is_stopping:
self._schedule_refresh()
self.async_update_listeners()
if not self.last_update_success and not previous_update_success:
return
if (
self.always_update
or self.last_update_success != previous_update_success
or previous_data != self.data
):
self.async_update_listeners()
@callback
def async_set_update_error(self, err: Exception) -> None:

View File

@ -594,3 +594,118 @@ async def test_async_set_update_error(
# Remove callbacks to avoid lingering timers
remove_callbacks()
async def test_only_callback_on_change_when_always_update_is_false(
crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture
) -> None:
"""Test we do not callback listeners unless something has actually changed when always_update is false."""
update_callback = Mock()
crd.always_update = False
remove_callbacks = crd.async_add_listener(update_callback)
mocked_data = None
mocked_exception = None
async def _update_method() -> int:
nonlocal mocked_data
nonlocal mocked_exception
if mocked_exception is not None:
raise mocked_exception
return mocked_data
crd.update_method = _update_method
mocked_data = {"a": 1}
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
mocked_data = {"a": 1}
await crd.async_refresh()
update_callback.assert_not_called()
update_callback.reset_mock()
mocked_data = None
mocked_exception = aiohttp.ClientError("Client Failure #1")
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
mocked_data = None
mocked_exception = aiohttp.ClientError("Client Failure #1")
await crd.async_refresh()
update_callback.assert_not_called()
update_callback.reset_mock()
mocked_exception = None
mocked_data = {"a": 1}
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
mocked_data = {"a": 1}
await crd.async_refresh()
update_callback.assert_not_called()
update_callback.reset_mock()
mocked_data = {"a": 2}
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
mocked_data = {"a": 2}
await crd.async_refresh()
update_callback.assert_not_called()
update_callback.reset_mock()
mocked_data = {"a": 2, "b": 3}
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
remove_callbacks()
async def test_always_callback_when_always_update_is_true(
crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture
) -> None:
"""Test we callback listeners even though the data is the same when always_update is True."""
update_callback = Mock()
remove_callbacks = crd.async_add_listener(update_callback)
mocked_data = None
mocked_exception = None
async def _update_method() -> int:
nonlocal mocked_data
nonlocal mocked_exception
if mocked_exception is not None:
raise mocked_exception
return mocked_data
crd.update_method = _update_method
mocked_data = {"a": 1}
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
mocked_data = {"a": 1}
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
# But still don't fire it if we are only getting
# failure over and over
mocked_data = None
mocked_exception = aiohttp.ClientError("Client Failure #1")
await crd.async_refresh()
update_callback.assert_called_once()
update_callback.reset_mock()
mocked_data = None
mocked_exception = aiohttp.ClientError("Client Failure #1")
await crd.async_refresh()
update_callback.assert_not_called()
update_callback.reset_mock()
remove_callbacks()