Skip to content

Commit d3f8ccb

Browse files
fix: rehydrate state trees that expire during a background task (#7585)
* fix: rehydrate state trees that expire during a background task A background task releases the state lock while it runs. If the session expires in that time, `async with self` gets a recreated tree, and the delta carries only the handler's changes. The browser keeps the values of the expired session. A recreated `rx.State` root has empty `router_data`. A foreground event already uses this signal to rehydrate. `StateProxy.__aenter__` now uses the same signal: it restores the router from the event context and runs `BaseStateEventProcessor._rehydrate`. The hydrate snapshot carries `is_hydrated=false`, so the browser keeps its client storage. Closes #7478 * fix: release the state lock when rehydrating an expired tree fails Mark the proxy mutable before the rehydrate, so a failure or a cancellation goes through __aexit__ and closes the state manager context. * docs: scope the rehydrate news to the loaded state tree * test: disable oplock when simulating Redis expiry Oplock keeps the tree cached in the process, so deleting the Redis keys does not expire it. * fix: keep a background rehydrate from loading the page the client left The background task's route is the one it started on. After the tree expires, the client may be on another page, so the rehydrate stays routeless and the client's next event loads its current page.
1 parent f96cef3 commit d3f8ccb

6 files changed

Lines changed: 210 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A background task that re-enters an expired session now rehydrates the loaded state tree, so the browser no longer shows stale values for those states.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A background task that re-enters an expired session now rehydrates the loaded states. The browser drops their stale values and keeps its route and browser storage; its next event runs the current page's on_load.

‎packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,8 @@ class BaseStateEventProcessor(EventProcessor):
393393
frontend.
394394
"""
395395

396-
async def _rehydrate(self, root_state: BaseState):
396+
@staticmethod
397+
async def _rehydrate(root_state: BaseState):
397398
"""Rehydrate the state by calling the hydrate event handler.
398399
399400
Args:

‎reflex/istate/proxy.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,16 @@ async def __aenter__(self) -> Self:
272272
super().__setattr__(
273273
"__wrapped__", mutable_state.get_substate(self._self_substate_path)
274274
)
275+
from reflex.state import State
276+
277+
if type(mutable_state) is State and not mutable_state.router_data:
278+
# The tree expired while the lock was released: rehydrate it, so
279+
# the client drops stale values. The task's route may be a page
280+
# the client left, so the tree stays routeless and the client's
281+
# next event loads its current page.
282+
from reflex_base.event.processor import BaseStateEventProcessor
283+
284+
await BaseStateEventProcessor._rehydrate(mutable_state)
275285
except (Exception, asyncio.CancelledError):
276286
# Restore the proxy to a consistent state since __aexit__ will not be called when __aenter__ raises.
277287
await self.__aexit__(*sys.exc_info())

‎tests/units/istate/test_proxy.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import pytest
1717
from reflex_base.constants.state import FIELD_MARKER
1818
from reflex_base.event.context import EventContext
19+
from reflex_base.event.processor import BaseStateEventProcessor
1920
from reflex_base.utils.exceptions import ImmutableStateError
2021

2122
import reflex as rx
@@ -186,6 +187,30 @@ async def mock_modify_state_context(*args, **kwargs):
186187
pass
187188

188189

190+
@pytest.mark.asyncio
191+
async def test_state_proxy_releases_lock_when_rehydrate_fails(
192+
attached_mock_event_context: EventContext, monkeypatch: pytest.MonkeyPatch
193+
):
194+
"""A failed rehydrate of an expired tree must release the state lock."""
195+
state_proxy = StateProxy(ProxyTestState())
196+
197+
async def failing_rehydrate(root_state: BaseState): # noqa: RUF029
198+
msg = "Simulated rehydrate failure"
199+
raise RuntimeError(msg)
200+
201+
monkeypatch.setattr(BaseStateEventProcessor, "_rehydrate", failing_rehydrate)
202+
with pytest.raises(RuntimeError, match="Simulated rehydrate failure"):
203+
async with state_proxy:
204+
pass
205+
206+
state_token = BaseStateToken(
207+
ident=attached_mock_event_context.token, cls=ProxyTestState
208+
)
209+
async with asyncio.timeout(1):
210+
async with attached_mock_event_context.state_manager.modify_state(state_token):
211+
pass
212+
213+
189214
@dataclasses.dataclass
190215
class TaggedModel:
191216
"""A dataclass with a mutable attribute for attr-path refresh tests."""

‎tests/units/reflex_base/event/processor/test_base_state_processor.py‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,177 @@ async def fg(self):
492492
)
493493

494494

495+
def _expire_token(state_manager: StateManager, token: str) -> None:
496+
"""Drop every state stored for a token, as its expiration would.
497+
498+
Args:
499+
state_manager: The memory or redis state manager holding the token.
500+
token: The client token to expire.
501+
"""
502+
if isinstance(state_manager, StateManagerMemory):
503+
state_manager._purge_token(BaseStateToken(ident=token, cls=State))
504+
return
505+
assert isinstance(state_manager, StateManagerRedis)
506+
keys = state_manager.redis._internals["keys"] # pyright: ignore[reportAttributeAccessIssue]
507+
for key in [key for key in keys if key.startswith(token.encode())]:
508+
del keys[key]
509+
510+
511+
@pytest.mark.parametrize(
512+
"processor_state_manager", ["in_process", "redis"], indirect=True
513+
)
514+
async def test_background_task_rehydrates_state_tree_expired_while_unlocked(
515+
wired_app: App,
516+
real_base_state_processor: BaseStateEventProcessor,
517+
emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
518+
token: str,
519+
):
520+
"""A background task re-entering an expired tree sends the full recreated state.
521+
522+
Args:
523+
wired_app: The App wired to the processor's state manager.
524+
real_base_state_processor: The unmocked BaseStateEventProcessor.
525+
emitted_deltas: List to capture emitted deltas.
526+
token: The client token.
527+
"""
528+
waiting = asyncio.Event()
529+
release = asyncio.Event()
530+
531+
class ExpiryState(State):
532+
value: int = 0
533+
534+
@event(background=True)
535+
async def bg(self):
536+
waiting.set()
537+
await release.wait()
538+
async with self:
539+
self.value = 100
540+
541+
class ExpiryChildState(ExpiryState):
542+
child: int = 0
543+
544+
@event
545+
def set_child(self):
546+
self.child = 13
547+
548+
router_data = {
549+
RouteVar.PATH: "/post/[pid]",
550+
RouteVar.ORIGIN: "/post/abc",
551+
RouteVar.QUERY: {"pid": "abc"},
552+
RouteVar.CLIENT_TOKEN: token,
553+
}
554+
555+
set_child, bg = (
556+
Event.from_event_type(spec, router_data=router_data)[0]
557+
for spec in (ExpiryChildState.set_child(), ExpiryState.bg())
558+
)
559+
assert real_base_state_processor._root_context is not None
560+
state_manager = real_base_state_processor._root_context.state_manager
561+
if isinstance(state_manager, StateManagerRedis):
562+
# Oplock keeps the tree cached in the process, so deleting keys does not expire it.
563+
state_manager._oplock_enabled = False
564+
async with real_base_state_processor as processor:
565+
await (await processor.enqueue(token, set_child))
566+
await processor.enqueue(token, bg)
567+
await asyncio.wait_for(waiting.wait(), timeout=2)
568+
_expire_token(state_manager, token)
569+
emitted_deltas.clear()
570+
release.set()
571+
await processor.join(5)
572+
573+
root_name = State.get_full_name()
574+
child_name = ExpiryChildState.get_full_name()
575+
is_hydrated_key = CompileVars.IS_HYDRATED + FIELD_MARKER
576+
snapshots = [d for _, d in emitted_deltas if child_name in d]
577+
assert snapshots, f"The expired child state was never resent: {emitted_deltas}"
578+
snapshot = snapshots[0]
579+
# Not hydrated, so the browser does not overwrite its client storage.
580+
assert snapshot[root_name][is_hydrated_key] is False
581+
assert snapshot[child_name]["child" + FIELD_MARKER] == 0
582+
# Routeless: the client's next event sets its current page.
583+
page = snapshot[root_name][constants.ROUTER_PAGE + FIELD_MARKER]
584+
assert page.raw_path == ""
585+
assert emitted_deltas[-1][1][ExpiryState.get_full_name()] == {
586+
"value" + FIELD_MARKER: 100
587+
}
588+
589+
590+
@pytest.mark.parametrize(
591+
"processor_state_manager", ["in_process", "redis"], indirect=True
592+
)
593+
async def test_background_rehydrate_does_not_load_the_route_the_client_left(
594+
wired_app: App,
595+
real_base_state_processor: BaseStateEventProcessor,
596+
token: str,
597+
):
598+
"""A tree that expires during a background task does not reload the task's old page.
599+
600+
Args:
601+
wired_app: The App wired to the processor's state manager.
602+
real_base_state_processor: The unmocked BaseStateEventProcessor.
603+
token: The client token.
604+
"""
605+
waiting = asyncio.Event()
606+
release = asyncio.Event()
607+
608+
class LeftPageState(State):
609+
loaded: list[str] = []
610+
611+
@event
612+
def load_a(self):
613+
self.loaded = [*self.loaded, "a"]
614+
615+
@event
616+
def load_b(self):
617+
self.loaded = [*self.loaded, "b"]
618+
619+
@event
620+
def ping(self):
621+
pass
622+
623+
@event(background=True)
624+
async def bg(self):
625+
waiting.set()
626+
await release.wait()
627+
async with self:
628+
pass
629+
630+
wired_app.add_page(
631+
lambda: rx.text("a"), route="/page-a", on_load=LeftPageState.load_a
632+
)
633+
wired_app.add_page(
634+
lambda: rx.text("b"), route="/page-b", on_load=LeftPageState.load_b
635+
)
636+
assert real_base_state_processor._root_context is not None
637+
state_manager = real_base_state_processor._root_context.state_manager
638+
if isinstance(state_manager, StateManagerRedis):
639+
# Oplock keeps the tree cached in the process, so deleting keys does not expire it.
640+
state_manager._oplock_enabled = False
641+
642+
async with real_base_state_processor as processor:
643+
await processor.enqueue(
644+
token, _client_event(LeftPageState.bg(), _view("/page-a"))
645+
)
646+
await asyncio.wait_for(waiting.wait(), timeout=2)
647+
# The client moves to /page-b, then the tree expires.
648+
await _send(
649+
processor, token, _client_event(LeftPageState.ping(), _view("/page-b"))
650+
)
651+
_expire_token(state_manager, token)
652+
release.set()
653+
await processor.join(5)
654+
# The client's next event on /page-b.
655+
await _send(
656+
processor, token, _client_event(LeftPageState.ping(), _view("/page-b"))
657+
)
658+
659+
async with _read_back(real_base_state_processor, token) as root:
660+
assert (await root.get_state(LeftPageState)).loaded == ["b"], (
661+
"the background rehydrate loaded the route the client had left"
662+
)
663+
assert root.router.url.path == "/page-b"
664+
665+
495666
async def test_background_yield_inside_context_flushes_delta_before_event(
496667
wired_app: App,
497668
real_base_state_processor: BaseStateEventProcessor,

0 commit comments

Comments
 (0)