Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8e5dfd7
Fix stale substates after background task state expiry
harsh21234i Oct 10, 2026
ebc95c1
Wait for the expiration test page to render
harsh21234i Oct 10, 2026
2e3dbc1
Format the background expiry regression test
harsh21234i Oct 10, 2026
5038a67
Avoid state recreation overhead on normal requests
harsh21234i Oct 11, 2026
b56ecaf
Fix expired state refresh tracking
harsh21234i Oct 12, 2026
eed9534
Track recreated Redis state and avoid task retention
harsh21234i Oct 12, 2026
ad2ca87
Clear recreation tracking for Redis tasks
harsh21234i Oct 12, 2026
90652d4
Avoid evaluating unavailable computed vars on state recreation
harsh21234i Oct 12, 2026
c1eb672
Invalidate computed vars from recreated backend fields
harsh21234i Oct 12, 2026
d8a8d04
fix: refresh cross-state vars after state recreation
harsh21234i Oct 14, 2026
e69885f
Merge branch 'main' into fix/7478-refresh-expired-background-state
harsh21234i Oct 14, 2026
52bfa62
ci: install SQLAlchemy asyncio extra in legacy smoke test
harsh21234i Oct 14, 2026
e39ccb4
fix: include unloaded sibling dependencies on state recreation
harsh21234i Oct 14, 2026
8f3f11b
test: cover recreated dependencies from unloaded siblings
harsh21234i Oct 14, 2026
f43427f
fix: avoid mutating state-class loop iterator
harsh21234i Oct 14, 2026
d6be382
fix: refresh recreated states without persisting them
harsh21234i Oct 14, 2026
b7da0b9
test: cover computed refresh on recreated state
harsh21234i Oct 14, 2026
f665f9a
test: preserve browser token after state expiry
harsh21234i Oct 14, 2026
d18e0fa
feat: add per-token state manifest key
harsh21234i Oct 14, 2026
8aebb75
fix: track Redis states persisted before expiration
harsh21234i Oct 14, 2026
01709ba
test: model persisted Redis state manifests
harsh21234i Oct 14, 2026
21c08af
test: cover Redis state expiry provenance
harsh21234i Oct 14, 2026
adb7795
test: avoid forcing Redis substates to persist
harsh21234i Oct 14, 2026
5819c58
test: remove unused Redis manager import
harsh21234i Oct 14, 2026
1d23cef
fix: migrate existing Redis keys into state manifest
harsh21234i Oct 14, 2026
d9d4702
test: seed Redis manifest with recorded keys
harsh21234i Oct 14, 2026
c7dd863
test: wait for token before checking state refresh
harsh21234i Oct 14, 2026
657c51f
test: exercise proxy recreation for computed refresh
harsh21234i Oct 14, 2026
990f767
style: format Redis manifest comprehension
harsh21234i Oct 14, 2026
532ee34
style: format proxy regression assertion
harsh21234i Oct 14, 2026
054d13c
Merge branch 'main' into fix/7478-refresh-expired-background-state
harsh21234i Oct 14, 2026
286afb8
fix: preserve framework state during recreation refresh
harsh21234i Oct 14, 2026
1e7d917
test: cover proxy refresh after state expiry
harsh21234i Oct 14, 2026
b3a0f7e
Merge remote-tracking branch 'upstream/main' into HEAD
harsh21234i Oct 14, 2026
927020f
test: type async Redis mock call
harsh21234i Oct 14, 2026
b30c897
test: isolate proxy recovery state from application fixtures
masenf Oct 14, 2026
3464287
Merge branch 'main' into fix/7478-refresh-expired-background-state
harsh21234i Oct 17, 2026
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 news/+7478-refresh-expired-background-state.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Mark recreated state trees dirty so background-task completion deltas refresh expired substate values in the client.
56 changes: 55 additions & 1 deletion reflex/istate/manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import logging
import sys
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Iterator
from contextvars import ContextVar
from types import TracebackType
from typing import TYPE_CHECKING, Generic, TypedDict, overload

Expand All @@ -19,6 +20,9 @@
from reflex.utils import console, prerequisites

logger = logging.getLogger(__name__)
_STATE_RECREATION_TRACKER: ContextVar[list["BaseState"] | None] = ContextVar(
"state_recreation_tracker", default=None
)

if TYPE_CHECKING:
from reflex.state import BaseState
Expand Down Expand Up @@ -333,6 +337,56 @@ async def __aexit__(
return await self._state_context.__aexit__(exc_type, exc, tb) or suppressed


@contextlib.contextmanager
def _track_state_recreation() -> Iterator[list["BaseState"]]:
"""Track state instances recreated inside the current async context.

Yields:
The state instances reported as recreated before the context exits.
"""
recreated_states: list[BaseState] = []
tracker_token = _STATE_RECREATION_TRACKER.set(recreated_states)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
try:
yield recreated_states
finally:
_STATE_RECREATION_TRACKER.reset(tracker_token)


@contextlib.contextmanager
def _no_state_recreation_tracking() -> Iterator[None]:
"""Temporarily prevent child tasks from inheriting state tracking."""
tracker_token = _STATE_RECREATION_TRACKER.set(None)
try:
yield
finally:
_STATE_RECREATION_TRACKER.reset(tracker_token)


def _report_state_recreation(state: "BaseState") -> None:
"""Report a state recreated during a tracked modification context.

Args:
state: The newly recreated state.
"""
if (recreated_states := _STATE_RECREATION_TRACKER.get()) is not None:
recreated_states.append(state)


def _report_state_tree_recreation(state: "BaseState") -> None:
"""Report every state in a newly recreated tree.

Args:
state: The root of the recreated state tree.
"""
if (recreated_states := _STATE_RECREATION_TRACKER.get()) is None:
return
pending = [state]
while pending:
current = pending.pop()
recreated_states.append(current)
pending.extend(current.substates.values())


def _release_state_tree(state: "BaseState"):
"""Clear the substate links of a purged state tree.

Expand Down
4 changes: 4 additions & 0 deletions reflex/istate/manager/disk.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
StateModificationContext,
_default_token_expiration,
_release_state_tree,
_report_state_recreation,
)
from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken
from reflex.state import BaseState
Expand Down Expand Up @@ -186,6 +187,8 @@ async def populate_substates(
instance.parent_state = state

await self.populate_substates(token, instance, root_state)
if instance is fresh_instance:
_report_state_recreation(instance)

@override
async def get_state(
Expand Down Expand Up @@ -216,6 +219,7 @@ async def get_state(
fresh_root_state = root_state_cls(_reflex_internal_init=True)
if root_state is None:
root_state = fresh_root_state
_report_state_recreation(root_state)
elif not isinstance(root_state, BaseState):
msg = "Deserialized state is not an instance of BaseState, cannot populate substates."
raise TypeError(msg)
Expand Down
17 changes: 10 additions & 7 deletions reflex/istate/manager/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
StateManager,
StateModificationContext,
_default_token_expiration,
_no_state_recreation_tracking,
_release_state_tree,
_report_state_tree_recreation,
)
from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken

Expand Down Expand Up @@ -56,9 +58,9 @@ def _get_or_create_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE:
key = token.cache_key
if key not in self.states:
if isinstance(token, BaseStateToken):
self.states[key] = token.cls.get_root_state()(
_reflex_internal_init=True
)
state = token.cls.get_root_state()(_reflex_internal_init=True)
_report_state_tree_recreation(state)
self.states[key] = state
else:
self.states[key] = token.cls()
return cast(TOKEN_TYPE, self.states[key])
Expand Down Expand Up @@ -139,10 +141,11 @@ def _ensure_expiration_task(self):
"""Ensure the expiration background task is running."""
if self._expiration_task is None or self._expiration_task.done():
asyncio.get_running_loop() # Ensure we're in an event loop.
self._expiration_task = asyncio.create_task(
self._expire_states(),
name="StateManagerMemory|Expiration",
)
with _no_state_recreation_tracking():
self._expiration_task = asyncio.create_task(
self._expire_states(),
name="StateManagerMemory|Expiration",
)

@override
async def get_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE:
Expand Down
94 changes: 66 additions & 28 deletions reflex/istate/manager/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
StateManager,
StateModificationContext,
_default_token_expiration,
_no_state_recreation_tracking,
_report_state_recreation,
)
from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken
from reflex.state import BaseState
Expand Down Expand Up @@ -127,16 +129,19 @@ class OplockFound(Exception): # noqa: N818
"""Indicates that an opportunistic lock was found."""


# KEYS: the lock key, then the state keys. ARGV: the lock id, the state
# expiration in seconds, then the serialized states in KEYS order. Writes only
# while the lock is held by ARGV[1], and returns the lock's remaining PTTL, or
# nil when the lock changed hands or expired and nothing was written.
# KEYS: the lock key, the state manifest key, then the state keys.
# ARGV: the lock id, the state expiration in seconds, then serialized states
# in KEYS order. The manifest tracks which per-state keys have been persisted.
_FENCED_SAVE_SCRIPT = """
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
return nil
end
for i = 2, #KEYS do
redis.call('SET', KEYS[i], ARGV[i + 1], 'EX', ARGV[2])
for i = 3, #KEYS do
redis.call('SET', KEYS[i], ARGV[i], 'EX', ARGV[2])
redis.call('SADD', KEYS[2], KEYS[i])
end
if #KEYS > 2 then
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[2]) * 1000)
Comment on lines +143 to +144

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.

P1 Expired child values stay visible

The Redis manifest expires at the same time as the last saved state. When a background task waits longer than token_expiration, both the saved states and their manifest disappear. The next load treats every missing state as never saved and reports no recreation. If the task updates only the parent, the browser keeps showing the child's old values.

Keep recreation history available after the state values expire, and add a test that expires both the states and the manifest.

end
return redis.call('PTTL', KEYS[1])
"""
Expand Down Expand Up @@ -357,18 +362,39 @@ async def get_state(
key=lambda x: x.get_full_name(),
)

# Read the tree atomically with one command instead of a transaction
# containing a GET for each state. An already populated tree needs no IO.
redis_states = (
await self.redis.mget([
token._state_key(state_cls) for state_cls in required_state_classes
])
if required_state_classes
else []
)
# Read state values and their persisted-key manifest in one round trip.
state_keys = [
token._state_key(state_cls) for state_cls in required_state_classes
]
if required_state_classes:
pipeline = self.redis.pipeline(transaction=False)
pipeline.mget(state_keys)
pipeline.smembers(token._state_manifest_key())
redis_states, manifest_keys = await pipeline.execute()
persisted_state_keys = {
key.decode() if isinstance(key, bytes) else key for key in manifest_keys
}
untracked_keys = [
key
for key, redis_state in zip(state_keys, redis_states, strict=True)
if redis_state is not None and key not in persisted_state_keys
]
if untracked_keys:
# Learn existing Redis keys on first access after introducing the manifest.
manifest_pipeline = self.redis.pipeline(transaction=False)
for key in untracked_keys:
manifest_pipeline.sadd(token._state_manifest_key(), key)
manifest_pipeline.pexpire(
token._state_manifest_key(), self.token_expiration * 1000
)
await manifest_pipeline.execute()
else:
redis_states = []
persisted_state_keys = set()

for state_cls, redis_state in zip(
for state_cls, state_key, redis_state in zip(
required_state_classes,
state_keys,
redis_states,
strict=True,
):
Expand All @@ -379,11 +405,13 @@ async def get_state(
with contextlib.suppress(StateSchemaMismatchError):
state = BaseState._deserialize(data=redis_state)
if state is None:
# Key didn't exist or schema mismatch so create a new instance for this token.
# A missing never-persisted state has no stale client value to reset.
state = state_cls(
init_substates=False,
_reflex_internal_init=True,
)
if redis_state is not None or state_key in persisted_state_keys:
_report_state_recreation(state)
state_full_name = state.get_full_name()
flat_state_tree[state_full_name] = state
if state.get_parent_state() is not None:
Expand Down Expand Up @@ -438,11 +466,18 @@ async def set_state(
pickle_state = token.serialize(state)
writes = [(str(token), pickle_state)] if pickle_state else []

manifest_key = (
token._state_manifest_key()
if isinstance(token, BaseStateToken)
else f"{token.lock_key}_reflex_state_manifest"
)
if lock_id is None:
if writes:
pipeline = self.redis.pipeline(transaction=False)
for key, pickle_state in writes:
pipeline.set(key, pickle_state, ex=self.token_expiration)
pipeline.sadd(manifest_key, key)
pipeline.pexpire(manifest_key, self.token_expiration * 1000)
await pipeline.execute()
return

Expand All @@ -460,8 +495,9 @@ async def set_state(
fenced_save = cast("Callable[..., Awaitable[int | None]]", self.redis.eval)
pttl = await fenced_save(
_FENCED_SAVE_SCRIPT,
1 + len(writes),
2 + len(writes),
lock_key,
manifest_key,
*(key for key, _ in writes),
lock_id,
self.token_expiration,
Expand Down Expand Up @@ -798,10 +834,11 @@ async def lease_breaker():
lock_key not in self._local_leases
and await self._n_lock_contenders(self._lock_key(token)) == 0
):
self._local_leases[lock_key] = task = asyncio.create_task(
lease_breaker(),
name=f"reflex_lease_breaker|{lock_key}|{lock_id.decode()}",
)
with _no_state_recreation_tracking():
self._local_leases[lock_key] = task = asyncio.create_task(
lease_breaker(),
name=f"reflex_lease_breaker|{lock_key}|{lock_id.decode()}",
)
# Fetch the requested state into the cache.
self._cached_states[lock_key] = await self.get_state(token)
return task
Expand Down Expand Up @@ -908,12 +945,13 @@ async def _subscribe_lock_updates(self):

def _ensure_lock_task(self) -> None:
"""Ensure the lock updates subscriber task is running."""
ensure_task(
owner=self,
task_attribute="_lock_task",
coro_function=self._subscribe_lock_updates,
suppress_exceptions=[Exception],
)
with _no_state_recreation_tracking():
ensure_task(
owner=self,
task_attribute="_lock_task",
coro_function=self._subscribe_lock_updates,
suppress_exceptions=[Exception],
)

async def _ensure_lock_task_subscribed(self, timeout: float | None = None) -> None:
"""Ensure the lock updates subscriber task is running and subscribed to avoid missing notifications.
Expand Down
8 changes: 8 additions & 0 deletions reflex/istate/manager/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ def _state_key(self, cls: type[BaseState]) -> str:
"""
return f"{self.ident}_{cls.get_full_name()}"

def _state_manifest_key(self) -> str:
"""Get the Redis key recording state keys previously persisted for this token.

Returns:
The Redis key for this token's persisted-state manifest.
"""
return f"{self.ident}_reflex_state_manifest"

def __str__(self) -> str:
"""The key used in the underlying StateManager store.

Expand Down
36 changes: 31 additions & 5 deletions reflex/istate/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ def __init__(
self._self_actx_lock = asyncio.Lock()
self._self_actx_lock_holder = None
self._self_parent_state_proxy = parent_state_proxy
self._self_recreated_states: list[BaseState] = []
# Whether `async with self` was ever entered; a background handler that
# never did emitted no delta, so the processor flushes once for it.
self._self_entered_context = False
Expand Down Expand Up @@ -266,12 +267,29 @@ async def __aenter__(self) -> Self:
self._self_actx = ctx.state_manager.modify_state_with_links(
token=self._self_substate_token, event=self._self_event
)
mutable_state = await self._self_actx.__aenter__()
from reflex.istate.manager import _track_state_recreation

with _track_state_recreation() as recreated_states:
mutable_state = await self._self_actx.__aenter__()
self._self_recreated_states = recreated_states
from reflex.state import State

for state in recreated_states:
state_cls = type(state)
if state_cls is not State:
state.dirty_vars.update(
name
for name, field in state_cls.get_fields().items()
if field._owner is state_cls
)
state.dirty_vars.update(state.computed_vars)
for computed_var in state.computed_vars.values():
computed_var.mark_dirty(instance=state)
state._mark_dirty()
refreshed_state = mutable_state.get_substate(self._self_substate_path)
self._self_mutable = True
self._self_entered_context = True
super().__setattr__(
"__wrapped__", mutable_state.get_substate(self._self_substate_path)
)
super().__setattr__("__wrapped__", refreshed_state)
except (Exception, asyncio.CancelledError):
# Restore the proxy to a consistent state since __aexit__ will not be called when __aenter__ raises.
await self.__aexit__(*sys.exc_info())
Expand All @@ -292,7 +310,15 @@ async def __aexit__(self, *exc_info: Any) -> None:
try:
if self._self_mutable and self._self_actx is not None:
root_state = self.__wrapped__._get_root_state()
delta = await root_state._get_resolved_delta()
touched_states = [
(state, state._was_touched) for state in self._self_recreated_states
]
try:
delta = await root_state._get_resolved_delta()
finally:
for state, was_touched in touched_states:
state._was_touched = was_touched
Comment thread
harsh21234i marked this conversation as resolved.
self._self_recreated_states.clear()
root_state._clean()
# When the frontend vars are modified emit the delta to the frontend.
if delta:
Expand Down
Loading
Loading