Repository navigation
Fix stale substates after background task state expiry #7543
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8e5dfd7
ebc95c1
2e3dbc1
5038a67
b56ecaf
eed9534
ad2ca87
90652d4
c1eb672
d8a8d04
e69885f
52bfa62
e39ccb4
8f3f11b
f43427f
d6be382
b7da0b9
f665f9a
d18e0fa
8aebb75
01709ba
21c08af
adb7795
5819c58
1d23cef
d9d4702
c7dd863
657c51f
990f767
532ee34
054d13c
286afb8
1e7d917
b3a0f7e
927020f
b30c897
3464287
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Redis manifest expires at the same time as the last saved state. When a background task waits longer than 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]) | ||
| """ | ||
|
|
@@ -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, | ||
| ): | ||
|
|
@@ -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: | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.