Skip to content

Commit 378d403

Browse files
committed
Save states with the schema hash 0.9 workers check
Since #7461, `_serialize` wrote the new `_to_schema` hash (field names and types only) next to each pickled state. Workers of 0.9.x and 0.10.0a1 compare that hash with their own, which includes the defaults, and replace the state with a fresh one on any mismatch. A rolling deploy or a rollback sharing Redis or the disk state manager with them therefore silently reset every session, and the old worker wrote the fresh state back, so the data was lost for good. The rest of the payload was already readable by those workers. Save the previous format's hash (`_legacy_state_schema`, now cached like `_to_schema`) next to the pickle, and record the new hash inside the pickled dict under `__reflex_schema__`. Older workers keep that entry as a plain instance attribute and write it back, which stays correct because they only accept a state whose names, types and defaults match their own. `_deserialize` checks the recorded hash when present, so defaults remain outside the schema for this release, and falls back to the saved hash for pickles from 0.10.0a2 and older releases. Fixes #7470 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo
1 parent 8239259 commit 378d403

3 files changed

Lines changed: 161 additions & 23 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Workers of 0.9 and 0.10.0a1 no longer discard state saved by this release (they did since 0.10.0a2), so a rolling deploy or a rollback that shares Redis or the disk state manager with them keeps user sessions instead of silently resetting them. As before, those workers keep a saved state only while the declared defaults of its vars are unchanged; workers of this release ignore defaults.

‎reflex/state.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@
9797
from reflex.utils import console, format, types
9898
from reflex.utils.exec import is_testing_env
9999

100+
# Entry in each pickle holding the `_to_schema` hash, which `_deserialize`
101+
# checks and takes out of the restored state. The hash saved next to the pickle
102+
# is `_legacy_state_schema`, the only one 0.9 and 0.10.0a1 workers check.
103+
_SCHEMA_PICKLE_KEY = "__reflex_schema__"
104+
100105
# Entries in each pickle for workers of the previous release, which kept the
101106
# dirty tracking and backend vars in the instance dict. Remove in 1.0.
102107
_PREVIOUS_RELEASE_PICKLE_KEYS: dict[str, Any] = {
@@ -1071,8 +1076,9 @@ def _init_var_dependency_dicts(cls):
10711076
parent_state.get_parent_state(),
10721077
)
10731078

1074-
# Reset cached schema value
1079+
# Reset cached schema values
10751080
cls._to_schema.cache_clear()
1081+
_legacy_state_schema.cache_clear()
10761082

10771083
@classmethod
10781084
def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]:
@@ -2040,7 +2046,7 @@ def __getstate__(self):
20402046
are stored on it, so inherited ones are not included either.
20412047
20422048
Returns:
2043-
The state dict for serialization.
2049+
The state dict for serialization, with the schema hash of the class.
20442050
"""
20452051
cls = type(self)
20462052
fields = vars(self)
@@ -2049,8 +2055,13 @@ def __getstate__(self):
20492055
for name, f in cls.__fields__.items():
20502056
if f._owner is cls and name not in fields:
20512057
fields[name] = f.default_value()
2052-
# Empty entries that let workers of the previous release load it.
2053-
return {**fields, **_PREVIOUS_RELEASE_PICKLE_KEYS}
2058+
# The schema this release checks, and empty entries that let workers of
2059+
# the previous release load it.
2060+
return {
2061+
**fields,
2062+
_SCHEMA_PICKLE_KEY: cls._to_schema(),
2063+
**_PREVIOUS_RELEASE_PICKLE_KEYS,
2064+
}
20542065

20552066
def __setstate__(self, state: builtins.dict[str, Any]):
20562067
"""Set the state from redis deserialization.
@@ -2135,7 +2146,8 @@ def _serialize(self) -> bytes:
21352146
"""
21362147
payload = b""
21372148
error = ""
2138-
self_schema = self._to_schema()
2149+
# Workers of 0.9 and 0.10.0a1 drop a state saved with any other hash.
2150+
self_schema = _legacy_state_schema(type(self))
21392151
pickle_function = pickle.dumps
21402152
try:
21412153
payload = pickle.dumps((self_schema, self))
@@ -2207,16 +2219,21 @@ def _deserialize(
22072219
except Exception as err:
22082220
msg = f"Stored state could not be unpickled: {err!r}"
22092221
raise StateSchemaMismatchError(msg) from err
2210-
if (
2211-
substate_schema != state._to_schema()
2212-
and substate_schema != _legacy_state_schema(type(state))
2213-
):
2222+
# A state saved by 0.10.0a2 or an older release has no recorded schema.
2223+
schema = vars(state).pop(_SCHEMA_PICKLE_KEY, substate_schema)
2224+
if schema != state._to_schema() and schema != _legacy_state_schema(type(state)):
22142225
raise StateSchemaMismatchError
22152226
return state
22162227

22172228

2229+
@functools.cache
22182230
def _legacy_state_schema(state_cls: type[BaseState]) -> str:
2219-
"""Hash the previous schema format to restore compatible persisted states.
2231+
"""Hash the schema format of 0.9 and 0.10.0a1, defaults included.
2232+
2233+
It is saved next to each pickled state, so that workers of those releases
2234+
load it, and restores the states they saved. Like `_to_schema`, it is
2235+
cached until a state class is created or gets a new var, so a default
2236+
assigned in between, which those releases ignore, does not change it.
22202237
22212238
Args:
22222239
state_cls: The class whose previous schema to compute.

‎tests/units/test_state.py‎

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
from reflex.istate.manager.token import BaseStateToken
7070
from reflex.istate.proxy import MutableProxy, StateProxy
7171
from reflex.state import (
72+
_SCHEMA_PICKLE_KEY,
7273
BaseState,
7374
Delta,
7475
ImmutableStateError,
@@ -5573,26 +5574,71 @@ class DifferentName(BaseState):
55735574
assert DifferentName._to_schema() != schema
55745575

55755576

5577+
def _previous_release_schema(state_cls: type[BaseState], **types: str) -> str:
5578+
"""Hash a schema as workers before 0.10.0a2 did, defaults included.
5579+
5580+
Args:
5581+
state_cls: The state class whose schema to hash.
5582+
**types: Serialized types to hash in place of the declared ones, by field name.
5583+
5584+
Returns:
5585+
The hash those workers require a saved state to carry.
5586+
"""
5587+
return md5(
5588+
pickle.dumps(
5589+
sorted(
5590+
(
5591+
name,
5592+
types.get(name)
5593+
or f"{declared.type_.__module__}.{declared.type_.__qualname__}",
5594+
declared.default if is_serializable(declared.default) else None,
5595+
)
5596+
for name, declared in state_cls.get_fields().items()
5597+
if name in state_cls.base_vars
5598+
)
5599+
)
5600+
).hexdigest()
5601+
5602+
5603+
class _SavedState:
5604+
"""Pickles a state the way a given release saves it."""
5605+
5606+
def __init__(self, state: BaseState, recorded_schema: str | None):
5607+
"""Wrap the state to pickle.
5608+
5609+
Args:
5610+
state: The state to pickle.
5611+
recorded_schema: The schema hash recorded inside the pickle, or
5612+
None for releases that did not record one.
5613+
"""
5614+
self.state = state
5615+
self.recorded_schema = recorded_schema
5616+
5617+
def __reduce__(self) -> tuple[Any, ...]:
5618+
"""Reduce to the wrapped state with the chosen schema entry.
5619+
5620+
Returns:
5621+
The arguments to recreate the state with, and its state dict.
5622+
"""
5623+
state_dict = self.state.__getstate__()
5624+
del state_dict[_SCHEMA_PICKLE_KEY]
5625+
if self.recorded_schema is not None:
5626+
state_dict[_SCHEMA_PICKLE_KEY] = self.recorded_schema
5627+
return object.__new__, (type(self.state),), state_dict
5628+
5629+
55765630
@pytest.mark.parametrize("type_name", ["builtins.int", "builtins.str"])
55775631
def test_deserialize_previous_schema_format(type_name: str):
55785632
"""Accept compatible hashes from the previous schema format only.
55795633
55805634
Args:
55815635
type_name: The type stored in the previous schema.
55825636
"""
5583-
previous_fields = [
5584-
(
5585-
name,
5586-
type_name
5587-
if name == "value"
5588-
else f"{declared.type_.__module__}.{declared.type_.__qualname__}",
5589-
declared.default if is_serializable(declared.default) else None,
5590-
)
5591-
for name, declared in DefaultSchemaState.get_fields().items()
5592-
if name in DefaultSchemaState.base_vars
5593-
]
5594-
legacy_schema = md5(pickle.dumps(sorted(previous_fields))).hexdigest()
5595-
data = pickle.dumps((legacy_schema, DefaultSchemaState(value=99)))
5637+
legacy_schema = _previous_release_schema(DefaultSchemaState, value=type_name)
5638+
data = pickle.dumps((
5639+
legacy_schema,
5640+
_SavedState(DefaultSchemaState(value=99), None),
5641+
))
55965642
if type_name == "builtins.int":
55975643
restored = BaseState._deserialize(data)
55985644
assert isinstance(restored, DefaultSchemaState)
@@ -5602,6 +5648,80 @@ def test_deserialize_previous_schema_format(type_name: str):
56025648
BaseState._deserialize(data)
56035649

56045650

5651+
def test_serialized_state_passes_the_previous_release_schema_check():
5652+
"""Workers before 0.10.0a2 load a state saved by this release.
5653+
5654+
They drop a saved state unless the hash saved with it equals their own,
5655+
which includes the defaults, so a rollback or a rolling deploy sharing the
5656+
state would otherwise reset every session.
5657+
"""
5658+
saved_schema, restored = pickle.loads(DefaultSchemaState(value=99)._serialize())
5659+
assert saved_schema == _previous_release_schema(DefaultSchemaState)
5660+
assert isinstance(restored, DefaultSchemaState)
5661+
assert restored.value == 99
5662+
5663+
5664+
@pytest.mark.parametrize(
5665+
("saved_schema", "recorded_schema", "loads"),
5666+
[
5667+
# Saved by this release, or re-saved by an older worker that loaded it.
5668+
("previous", "current", True),
5669+
# Saved by this release before a default changed.
5670+
("other", "current", True),
5671+
# Saved by this release before a field was added, renamed or retyped.
5672+
("other", "other", False),
5673+
# Saved by 0.10.0a2.
5674+
("current", None, True),
5675+
# Saved by an older release.
5676+
("previous", None, True),
5677+
("other", None, False),
5678+
],
5679+
)
5680+
def test_deserialize_checks_the_recorded_schema(
5681+
saved_schema: str, recorded_schema: str | None, loads: bool
5682+
):
5683+
"""The schema recorded in the pickle decides; older pickles fall back to the saved hash.
5684+
5685+
Args:
5686+
saved_schema: Which hash is saved next to the pickled state.
5687+
recorded_schema: Which hash is recorded inside it, if any.
5688+
loads: Whether the state is restored.
5689+
"""
5690+
hashes = {
5691+
"current": DefaultSchemaState._to_schema(),
5692+
"previous": _previous_release_schema(DefaultSchemaState),
5693+
"other": md5(b"other").hexdigest(),
5694+
}
5695+
data = pickle.dumps((
5696+
hashes[saved_schema],
5697+
_SavedState(
5698+
DefaultSchemaState(value=99),
5699+
None if recorded_schema is None else hashes[recorded_schema],
5700+
),
5701+
))
5702+
if not loads:
5703+
with pytest.raises(StateSchemaMismatchError):
5704+
BaseState._deserialize(data)
5705+
return
5706+
restored = BaseState._deserialize(data)
5707+
assert isinstance(restored, DefaultSchemaState)
5708+
assert restored.value == 99
5709+
assert _SCHEMA_PICKLE_KEY not in vars(restored)
5710+
5711+
5712+
def test_serialize_rehashes_the_previous_schema_after_add_var():
5713+
"""A var added at runtime is part of the hash saved for older workers."""
5714+
5715+
class AddVarSchemaState(BaseState):
5716+
value: int = 1
5717+
5718+
state = AddVarSchemaState(_reflex_internal_init=True) # pyright: ignore [reportCallIssue]
5719+
before, _ = pickle.loads(state._serialize())
5720+
AddVarSchemaState.add_var("added", int, 2)
5721+
after, _ = pickle.loads(state._serialize())
5722+
assert before != after == _previous_release_schema(AddVarSchemaState)
5723+
5724+
56055725
def test_reset_client_storage_uses_declared_factories():
56065726
"""Hydration evaluates declared factories for all browser storage types."""
56075727
calls = []

0 commit comments

Comments
 (0)