Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/6740.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use monotonic time for computed var update intervals so system clock changes do not cause early or delayed cache refreshes.
23 changes: 19 additions & 4 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import json
import re
import string
import time
import uuid
import warnings
from abc import ABCMeta
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Comment on lines +2499 to +2500

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 elapsed < 0 is unreachable once the UUID guard passes. Within a single process, time.monotonic() is guaranteed to be non-decreasing, so subtracting a previously recorded value from the same process's clock will always yield a non-negative result. Consider removing the dead condition to keep the expression clean.

Suggested change
elapsed = time.monotonic() - last_updated[1]
return elapsed < 0 or elapsed > self._update_interval.total_seconds()
elapsed = time.monotonic() - last_updated[1]
return elapsed > self._update_interval.total_seconds()

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


@overload
def __get__(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions tests/units/vars/test_base.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Comment on lines +129 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead datetime mock — wall-clock immunity not actually verified

reflex_base.vars.base.datetime.datetime is patched here, but the updated production code never calls datetime.datetime.now() in the interval-check path anymore. The mock has no effect on execution, so this test does not actually verify that wall-clock changes are ignored — it only verifies that time.monotonic() controls expiry. The wall_clock_delta parameter is therefore inert: the test passes identically whether or not the datetime patch is applied, and a future regression that re-introduces a datetime.datetime.now() call alongside time.monotonic() would not be caught by this test. Either drop the datetime mock (and rename the test parameters/docstring accordingly) or add an assertion that datetime_mock.now was not called after the interval check.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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
Loading