Skip to content

Commit c49ea83

Browse files
masenfclaude
andauthored
Declare state stores shared by 0.9 and 0.10 instances unsupported (#7494)
* 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 * Serialize non-class field types in the previous-release schema helper The helper that recomputes the hash 0.9 workers check mirrored only the class branch of the old _serialize_type, so a state with a generic or union annotation would raise instead of being checked. Mirror the other branch too, as the 0.9.12 implementation did. Refs #7470 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Declare state stores shared by 0.9 and 0.10 instances unsupported Drop the change that wrote the 0.9 schema hash next to each pickled state so 0.9 and 0.10.0a1 workers could load state saved by this release, and restore reflex/state.py and its tests to main. Instead, a `breaking` news fragment and the Self Hosting docs state that instances of 0.9 and 0.10 cannot share a Redis or disk state store: 0.10 still loads state saved by 0.9, but a 0.9 instance discards state saved by 0.10, so a rolling deploy that runs both versions, or a rollback against the same store, resets the sessions that reach the older instance. The field-descriptors fragment no longer claims that states saved by this release load in workers of the previous one, which has not held since the names-and-types schema hash. Refs #7470 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Stop saving the entries that let 0.9 workers load a pickle `__getstate__` added empty `dirty_vars`, `dirty_substates` and `_backend_vars` entries so workers of the previous release could restore a state of this one. Those workers never get past the schema check on a state saved by this release, so the entries only made every pickle larger. `__setstate__` keeps dropping them, and the `_backend_vars` dict, from pickles written by 0.9 and 0.10.0a2 workers. Refs #7470 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aed9b15 commit c49ea83

5 files changed

Lines changed: 30 additions & 11 deletions

File tree

‎docs/hosting/self-hosting.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ Alternatively, put a reverse proxy in front of both processes and route the
6161
backend routes (`/_event`, `/ping`, `/_upload`, `/_health`) to the backend
6262
port, as the `Caddyfile`s in the container examples below do.
6363

64+
Instances of Reflex 0.9 and 0.10 cannot share a state store, whether Redis or
65+
the disk state manager's directory. A 0.10 instance loads state saved by 0.9,
66+
but a 0.9 instance discards state saved by 0.10, so a rolling deploy that runs
67+
both versions, or a rollback to 0.9 against the same store, resets the sessions
68+
that reach the older instance. Upgrade every instance of an app together, and
69+
clear the store (or point the app at a fresh one) when rolling back.
70+
6471
```md alert warning
6572
# Reverse Proxy and Websockets
6673
Because the backend uses websockets, some reverse proxy servers, like [nginx](https://nginx.org/en/docs/http/websocket.html) or [apache](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#protoupgrade), must be configured to pass the `Upgrade` header to allow backend connectivity.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
A substate may now declare a var or computed var with the same name as an inherited var: it gets an independent one of its own, instead of raising `BaseVarShadowsInheritedVarError` or `ComputedVarShadowsBaseVarsError`. A dynamic route arg only conflicts with a var of the state it is installed on. Assigning an undeclared state attribute still raises `SetUndefinedStateVarError` outside of prod mode, but no longer in prod. The internal class maps `backend_vars`, `inherited_vars` and `inherited_backend_vars`, `get_skip_vars()` and the instance `_backend_vars` are removed: use `get_fields()`, whose fields know the state they belong to. States saved by this release still load in workers of the previous one, so rolling deploys sharing Redis keep working, with one exception: while old workers remain, an in-place change to a mutable backend var (like `self._items.append(x)`) made on an old worker to a state saved by this release is not persisted. Assigning backend vars, and any change to frontend vars, is unaffected.
1+
A substate may now declare a var or computed var with the same name as an inherited var: it gets an independent one of its own, instead of raising `BaseVarShadowsInheritedVarError` or `ComputedVarShadowsBaseVarsError`. A dynamic route arg only conflicts with a var of the state it is installed on. Assigning an undeclared state attribute still raises `SetUndefinedStateVarError` outside of prod mode, but no longer in prod. The internal class maps `backend_vars`, `inherited_vars` and `inherited_backend_vars`, `get_skip_vars()` and the instance `_backend_vars` are removed: use `get_fields()`, whose fields know the state they belong to.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Instances of Reflex 0.9 and 0.10 can no longer share a Redis or disk state store. A 0.10 instance loads state saved by 0.9, but a 0.9 instance discards state saved by 0.10, so a rolling deploy that runs both versions, or a rollback to 0.9 against the same store, resets the sessions that reach the older instance. Upgrade every instance of an app together, and clear the store (or start a fresh one) when rolling back.

‎reflex/state.py‎

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

100-
# Entries in each pickle for workers of the previous release, which kept the
101-
# dirty tracking and backend vars in the instance dict. Remove in 1.0.
102-
_PREVIOUS_RELEASE_PICKLE_KEYS: dict[str, Any] = {
103-
"dirty_vars": set(),
104-
"dirty_substates": set(),
105-
"_backend_vars": {},
106-
}
107-
108100

109101
@functools.cache
110102
def _stale_pickle_keys(cls: type[BaseState]) -> frozenset[str]:
@@ -2049,8 +2041,7 @@ def __getstate__(self):
20492041
for name, f in cls.__fields__.items():
20502042
if f._owner is cls and name not in fields:
20512043
fields[name] = f.default_value()
2052-
# Empty entries that let workers of the previous release load it.
2053-
return {**fields, **_PREVIOUS_RELEASE_PICKLE_KEYS}
2044+
return fields
20542045

20552046
def __setstate__(self, state: builtins.dict[str, Any]):
20562047
"""Set the state from redis deserialization.

‎tests/units/test_state.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6671,6 +6671,26 @@ def combined(self) -> str:
66716671
state_cls._potentially_dirty_states.discard(consumer_name)
66726672

66736673

6674+
def test_getstate_holds_only_the_fields_of_the_state():
6675+
"""A pickle holds the state's own fields and nothing for older workers.
6676+
6677+
Workers of the previous release cannot load a state of this one, so the
6678+
empty entries once added for them are gone.
6679+
"""
6680+
6681+
class OwnFieldsState(BaseState):
6682+
count: int = 0
6683+
_secret: str = ""
6684+
6685+
state = OwnFieldsState(_reflex_internal_init=True) # pyright: ignore [reportCallIssue]
6686+
state.count = 2
6687+
6688+
pickled = state.__getstate__()
6689+
assert pickled["count"] == 2
6690+
assert pickled["_secret"] == ""
6691+
assert not {"dirty_vars", "dirty_substates", "_backend_vars"} & pickled.keys()
6692+
6693+
66746694
def test_setstate_migrates_older_pickles():
66756695
"""Older pickles kept backend vars in a dict of their own and the dirty sets."""
66766696

0 commit comments

Comments
 (0)