diff --git a/packages/reflex-base/news/6740.bugfix.md b/packages/reflex-base/news/6740.bugfix.md new file mode 100644 index 00000000000..a176841db94 --- /dev/null +++ b/packages/reflex-base/news/6740.bugfix.md @@ -0,0 +1 @@ +Use monotonic time for computed var update intervals so system clock changes do not cause early or delayed cache refreshes. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index d9a3f939a27..05570d862b9 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -11,6 +11,7 @@ import json import re import string +import time import uuid import warnings from abc import ABCMeta @@ -91,6 +92,7 @@ warnings.filterwarnings("ignore", message="fields may not start with an underscore") _PYDANTIC_VALIDATE_VALUES = "__pydantic_validate_values__" +_MONOTONIC_CLOCK_ID = uuid.uuid4() def _pydantic_validator(*args, **kwargs): @@ -2488,9 +2490,14 @@ def needs_update(self, instance: BaseState) -> bool: if self._update_interval is None: return False last_updated = getattr(instance, self._last_updated_attr, None) - if last_updated is None: + if ( + not isinstance(last_updated, tuple) + or len(last_updated) != 2 + or last_updated[0] != _MONOTONIC_CLOCK_ID + ): return True - return datetime.datetime.now() - last_updated > self._update_interval + elapsed = time.monotonic() - last_updated[1] + return elapsed < 0 or elapsed > self._update_interval.total_seconds() @overload def __get__( @@ -2594,7 +2601,11 @@ def __get__(self, instance: BaseState | None, owner: type): # Ensure the computed var gets serialized to redis. instance._was_touched = True # Set the last updated timestamp on the state instance. - setattr(instance, self._last_updated_attr, datetime.datetime.now()) + setattr( + instance, + self._last_updated_attr, + (_MONOTONIC_CLOCK_ID, time.monotonic()), + ) value = getattr(instance, self._cache_attr) self._check_deprecated_return_type(instance, value) @@ -2856,7 +2867,11 @@ async def _awaitable_result(instance: BaseState = instance) -> RETURN_TYPE: # Ensure the computed var gets serialized to redis. instance._was_touched = True # Set the last updated timestamp on the state instance. - setattr(instance, self._last_updated_attr, datetime.datetime.now()) + setattr( + instance, + self._last_updated_attr, + (_MONOTONIC_CLOCK_ID, time.monotonic()), + ) value = getattr(instance, self._cache_attr) self._check_deprecated_return_type(instance, value) return value diff --git a/tests/units/vars/test_base.py b/tests/units/vars/test_base.py index e4d7e363e3c..eb210e8f396 100644 --- a/tests/units/vars/test_base.py +++ b/tests/units/vars/test_base.py @@ -1,6 +1,8 @@ +import datetime from collections.abc import Mapping, Sequence import pytest +from pytest_mock import MockerFixture from reflex_base.vars.base import computed_var, figure_out_type from reflex.state import State @@ -77,3 +79,100 @@ def cv(self) -> int: replaced = cv._replace(_var_type=float) assert replaced._var_type is float + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize( + ("wall_clock_delta", "monotonic_delta", "expected_value"), + [ + (datetime.timedelta(hours=-1), 11, 2), + (datetime.timedelta(hours=1), 1, 1), + ], +) +@pytest.mark.asyncio +async def test_computed_var_interval_uses_monotonic_time( + mocker: MockerFixture, + is_async: bool, + wall_clock_delta: datetime.timedelta, + monotonic_delta: int, + expected_value: int, +) -> None: + """Test interval expiry is unaffected by wall-clock changes. + + Args: + mocker: Pytest mock fixture. + is_async: Whether to access the asynchronous computed var. + wall_clock_delta: The wall-clock change after the initial access. + monotonic_delta: The elapsed monotonic time after the initial access. + expected_value: The expected computed value after the clocks advance. + """ + call_count = 0 + + class StateTest(State): + @computed_var(cache=True, interval=10) + def cv(self) -> int: + nonlocal call_count + call_count += 1 + return call_count + + @computed_var(cache=True, interval=10) + async def async_cv(self) -> int: + nonlocal call_count + call_count += 1 + return call_count + + monotonic_time = 100 + mocker.patch( + "reflex_base.vars.base.time.monotonic", side_effect=lambda: monotonic_time + ) + initial_wall_time = datetime.datetime(2026, 1, 1) + datetime_mock = mocker.patch("reflex_base.vars.base.datetime.datetime") + datetime_mock.now.return_value = initial_wall_time + + state = StateTest() + if is_async: + assert await state.async_cv == 1 + else: + assert state.cv == 1 + + monotonic_time += monotonic_delta + datetime_mock.now.return_value = initial_wall_time + wall_clock_delta + + if is_async: + assert await state.async_cv == expected_value + else: + assert state.cv == expected_value + assert call_count == expected_value + + +@pytest.mark.parametrize( + "persisted_timestamp", + [ + datetime.datetime(2026, 1, 1), + (object(), 100), + ], +) +def test_computed_var_interval_expires_incompatible_timestamp( + persisted_timestamp: object, +) -> None: + """Test an interval cache with an incompatible timestamp is expired. + + Args: + persisted_timestamp: A timestamp from a legacy version or another clock. + """ + call_count = 0 + + class StateTest(State): + @computed_var(cache=True, interval=10) + def cv(self) -> int: + nonlocal call_count + call_count += 1 + return call_count + + state = StateTest() + assert state.cv == 1 + + computed = StateTest.computed_vars["cv"] + setattr(state, computed._last_updated_attr, persisted_timestamp) + + assert state.cv == 2