-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Deduplicate unchanged uncached computed var deltas #6946
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
1a84c2a
a9748a8
a75ab16
7a5994d
962a973
bc2f61a
0b5912a
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 @@ | ||
| `@rx.var(cache=False)` vars now remember what they last sent to the frontend. They are still recomputed on every state update, but the value is only included in the delta when it actually changed, so an uncached var whose value stays the same no longer causes needless network traffic and re-renders. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| `ComputedVar` now records a key for the value an uncached (`cache=False`) var last sent to each client, so `BaseState.get_delta` can leave the var out of the delta when a recomputation produces the same value. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |||||||||||||||||||||||||||
| import dataclasses | ||||||||||||||||||||||||||||
| import datetime | ||||||||||||||||||||||||||||
| import functools | ||||||||||||||||||||||||||||
| import hashlib | ||||||||||||||||||||||||||||
| import inspect | ||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||
|
|
@@ -25,6 +26,7 @@ | |||||||||||||||||||||||||||
| Annotated, | ||||||||||||||||||||||||||||
| Any, | ||||||||||||||||||||||||||||
| ClassVar, | ||||||||||||||||||||||||||||
| Final, | ||||||||||||||||||||||||||||
| Generic, | ||||||||||||||||||||||||||||
| Literal, | ||||||||||||||||||||||||||||
| NoReturn, | ||||||||||||||||||||||||||||
|
|
@@ -53,7 +55,7 @@ | |||||||||||||||||||||||||||
| VarDependencyError, | ||||||||||||||||||||||||||||
| VarTypeError, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| from reflex_base.utils.format import format_state_name | ||||||||||||||||||||||||||||
| from reflex_base.utils.format import format_state_name, json_dumps | ||||||||||||||||||||||||||||
| from reflex_base.utils.imports import ( | ||||||||||||||||||||||||||||
| ImmutableImportDict, | ||||||||||||||||||||||||||||
| ImmutableParsedImportDict, | ||||||||||||||||||||||||||||
|
|
@@ -2249,6 +2251,47 @@ class FakeComputedVarBaseClass(property): | |||||||||||||||||||||||||||
| __pydantic_run_validation__ = False | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Marker for a value that has no delta key. Compared by identity and never stored | ||||||||||||||||||||||||||||
| # on a state instance, so it is safe from serialization round trips. | ||||||||||||||||||||||||||||
| _UNKEYABLE_VALUE: Final = object() | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Types whose instances are immutable and cheap to compare directly. float is | ||||||||||||||||||||||||||||
| # deliberately absent: NaN is not equal to itself, so floats are keyed by their | ||||||||||||||||||||||||||||
| # serialized form instead of comparing equal to nothing forever. | ||||||||||||||||||||||||||||
| _ATOMIC_DELTA_VALUE_TYPES: Final = frozenset({str, int, bool, type(None)}) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Size of the digest used to key non-atomic delta values. | ||||||||||||||||||||||||||||
| _DELTA_VALUE_DIGEST_SIZE: Final = 16 | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def _delta_value_key(value: Any) -> Any: | ||||||||||||||||||||||||||||
| """Get a comparable, alias-free key for a value going into a delta. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Immutable scalars are keyed by themselves, paired with their type so that | ||||||||||||||||||||||||||||
| Python-equal but JSON-distinct values (``1`` and ``True``) do not collide. | ||||||||||||||||||||||||||||
| Everything else is keyed by a digest of its serialized form: that is exactly | ||||||||||||||||||||||||||||
| what the client receives, so a value without a registered serializer keys by | ||||||||||||||||||||||||||||
| the ``null`` the client would get, it cannot be invalidated by a later | ||||||||||||||||||||||||||||
| in-place mutation of the value, and it stays small no matter how big the | ||||||||||||||||||||||||||||
| value is. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||
| value: The value to key. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||
| The key, or ``_UNKEYABLE_VALUE`` if the value cannot be serialized. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| value_type = type(value) | ||||||||||||||||||||||||||||
| if value_type in _ATOMIC_DELTA_VALUE_TYPES: | ||||||||||||||||||||||||||||
| return (value_type, value) | ||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| return hashlib.blake2b( | ||||||||||||||||||||||||||||
| json_dumps(value).encode(), digest_size=_DELTA_VALUE_DIGEST_SIZE | ||||||||||||||||||||||||||||
| ).digest() | ||||||||||||||||||||||||||||
|
masenf marked this conversation as resolved.
|
||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||
| return _UNKEYABLE_VALUE | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]: | ||||||||||||||||||||||||||||
| """Check if the object is a ComputedVar. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
@@ -2483,6 +2526,51 @@ def _last_updated_attr(self) -> str: | |||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| return f"__last_updated_{self._js_expr}" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @property | ||||||||||||||||||||||||||||
| def _last_delta_key_attr(self) -> str: | ||||||||||||||||||||||||||||
| """The attribute used to store the key of the last value sent in a delta. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||
| An attribute name. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| return f"__last_delta_{self._js_expr}" | ||||||||||||||||||||||||||||
|
masenf marked this conversation as resolved.
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def _record_delta_value(self, instance: BaseState, value: Any, token: str) -> bool: | ||||||||||||||||||||||||||||
| """Record the value an uncached var contributes to the delta. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Uncached vars are recomputed for every delta, but recomputing does not | ||||||||||||||||||||||||||||
| imply the value changed. Keeping a key for the last value that was sent | ||||||||||||||||||||||||||||
| to the client allows an unchanged value to be omitted from the delta, | ||||||||||||||||||||||||||||
| avoiding a needless re-render on the frontend. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| The client token is recorded alongside the key because a single state | ||||||||||||||||||||||||||||
| instance can serve several clients (linked shared states): a value that | ||||||||||||||||||||||||||||
| was already sent to one client still has to be sent to the others. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||
| instance: The state instance that the computed var is attached to. | ||||||||||||||||||||||||||||
| value: The freshly computed value. | ||||||||||||||||||||||||||||
| token: The client token the delta is being produced for. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||
| Whether the value differs from the last recorded value and should | ||||||||||||||||||||||||||||
| therefore be included in the delta. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| attr = self._last_delta_key_attr | ||||||||||||||||||||||||||||
| key = _delta_value_key(value) | ||||||||||||||||||||||||||||
| if key is _UNKEYABLE_VALUE: | ||||||||||||||||||||||||||||
| # The value can never be compared, so it always has to be sent. | ||||||||||||||||||||||||||||
| with contextlib.suppress(AttributeError): | ||||||||||||||||||||||||||||
| delattr(instance, attr) | ||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||
| recorded = (token, key) | ||||||||||||||||||||||||||||
| if getattr(instance, attr, None) == recorded: | ||||||||||||||||||||||||||||
|
masenf marked this conversation as resolved.
|
||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||
| setattr(instance, attr, recorded) | ||||||||||||||||||||||||||||
|
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. P2: This dynamically generated tracking attribute bypasses BaseState's reserved-field checks, allowing a colliding internal name to overwrite delta bookkeeping silently. Define the bookkeeping as a reserved non-var state field or reserve the generated names explicitly. (Based on your team's feedback about reserved internal state tracking fields.) Prompt for AI agents
Collaborator
Author
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. Pushing back: this is the established convention for per-var bookkeeping on a state instance, not something new here.
For a collision, a user would have to declare a var literally named Promoting these to reserved non-var state fields is a reasonable idea, but it should cover all three attributes at once and is a change to Generated by Claude Code |
||||||||||||||||||||||||||||
| # Ensure the recorded value gets serialized to redis. | ||||||||||||||||||||||||||||
| instance._was_touched = True | ||||||||||||||||||||||||||||
|
Comment on lines
+2566
to
+2571
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. P2: When linked clients alternate deltas, this stores only the most recent client token rather than one key per client. After both clients receive the same value, alternating requests resend it indefinitely; store a token-to-key mapping. Prompt for AI agents
Suggested change
Collaborator
Author
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. Correct diagnosis, but I'm not taking the suggested fix in this PR. A plain The current behavior in that case is a missed optimization, not a correctness bug: the key thrashes between tokens, so the value is resent — which is exactly what happens today on Filed as follow-up work in the PR discussion (bounded per-client key map with LRU eviction). Generated by Claude Code |
||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def needs_update(self, instance: BaseState) -> bool: | ||||||||||||||||||||||||||||
| """Check if the computed var needs to be updated. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,7 @@ | |
| import re | ||
| import sys | ||
| import time | ||
| from collections.abc import Callable, Iterator, Mapping, Sequence | ||
| from collections.abc import Callable, Coroutine, Iterator, Mapping, Sequence | ||
| from hashlib import md5 | ||
| from types import FunctionType | ||
| from typing import ( | ||
|
|
@@ -309,6 +309,30 @@ async def _resolve_delta(delta: Delta) -> Delta: | |
| return delta | ||
|
|
||
|
|
||
| async def _drop_unchanged_delta_value( | ||
| cvar: ComputedVar, | ||
| instance: BaseState, | ||
| value: Coroutine[None, None, Any], | ||
| token: str, | ||
| ) -> Any: | ||
| """Await an async uncached computed var, dropping it if the value did not change. | ||
|
|
||
| Args: | ||
| cvar: The computed var that produced the coroutine. | ||
| instance: The state instance the computed var is attached to. | ||
| value: The coroutine returned by the computed var. | ||
| token: The client token the delta is being produced for. | ||
|
|
||
| Returns: | ||
| The resolved value, or ``_DROP_FROM_DELTA`` when it matches the last | ||
| value that was sent to the client. | ||
| """ | ||
| resolved = await value | ||
| if not cvar._record_delta_value(instance, resolved, token): | ||
| return _DROP_FROM_DELTA | ||
| return resolved | ||
|
|
||
|
|
||
| RETURN = TypeVar("RETURN") | ||
| PARAMS = ParamSpec("PARAMS") | ||
|
|
||
|
|
@@ -1954,9 +1978,15 @@ def _dirty_computed_vars( | |
| if include_backend or not self.computed_vars[cvar]._backend | ||
| } | ||
|
|
||
| def get_delta(self) -> Delta: | ||
| def get_delta(self, record_values: bool = True) -> Delta: | ||
| """Get the delta for the state. | ||
|
|
||
| Args: | ||
| record_values: Whether the values of uncached computed vars should be | ||
| recorded as sent to the client. Pass False when the delta is | ||
| computed for its side effects and then discarded, otherwise the | ||
| unsent values would be omitted from the next delta. | ||
|
|
||
| Returns: | ||
| The delta for the state. | ||
| """ | ||
|
|
@@ -1973,30 +2003,46 @@ def get_delta(self) -> Delta: | |
| self.dirty_vars.intersection(frontend_computed_vars) | ||
| ) | ||
|
|
||
| subdelta: dict[str, Any] = { | ||
| prop + FIELD_MARKER: self.get_value(prop) | ||
| for prop in delta_vars | ||
| if not types.is_backend_base_variable(prop, type(self)) | ||
| } | ||
| always_dirty_computed_vars = self._always_dirty_computed_vars | ||
| # Token of the client this delta is for, used to know which values it has. | ||
| token = self.router.session.client_token if always_dirty_computed_vars else "" | ||
|
masenf marked this conversation as resolved.
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. P1: Derive the delta token from the root state rather than the patched shared substate. During linked-state fan-out, Prompt for AI agents
Collaborator
Author
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. Pushing back: the suggested change is a no-op, because
On the fan-out concern specifically: (The Generated by Claude Code |
||
| subdelta: dict[str, Any] = {} | ||
| for prop in delta_vars: | ||
| if types.is_backend_base_variable(prop, type(self)): | ||
| continue | ||
| value = self.get_value(prop) | ||
| if record_values and prop in always_dirty_computed_vars: | ||
| # Uncached computed vars are recomputed for every delta; only | ||
| # send them when the recomputed value actually changed. | ||
| cvar = self.computed_vars[prop] | ||
| if inspect.iscoroutine(value): | ||
| value = _drop_unchanged_delta_value(cvar, self, value, token) | ||
| elif not cvar._record_delta_value(self, value, token): | ||
| continue | ||
| subdelta[prop + FIELD_MARKER] = value | ||
|
|
||
| if len(subdelta) > 0: | ||
| delta[self.get_full_name()] = subdelta | ||
|
|
||
| # Recursively find the substate deltas. | ||
| substates = self.substates | ||
| for substate in self.dirty_substates.union(self._always_dirty_substates): | ||
| delta.update(substates[substate].get_delta()) | ||
| delta.update(substates[substate].get_delta(record_values=record_values)) | ||
|
|
||
| # Return the delta. | ||
| return delta | ||
|
|
||
| async def _get_resolved_delta(self) -> Delta: | ||
| async def _get_resolved_delta(self, record_values: bool = True) -> Delta: | ||
| """Get the delta for the state after resolving all coroutines. | ||
|
|
||
| Args: | ||
| record_values: Whether the values of uncached computed vars should be | ||
| recorded as sent to the client. See `get_delta`. | ||
|
|
||
| Returns: | ||
| The resolved delta for the state. | ||
| """ | ||
| return await _resolve_delta(self.get_delta()) | ||
| return await _resolve_delta(self.get_delta(record_values=record_values)) | ||
|
|
||
| def _mark_dirty(self): | ||
| """Mark the substate and all parent states as dirty.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When a value has no registered serializer,
json_dumpsconverts it tonullinstead of raising._delta_value_keythen reuses thenulldigest and drops later values, so non-serializable values are not always sent as documented; make unsupported serialization return_UNKEYABLE_VALUE, including for nested values.Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushing back: this is the intended behavior, not a bug — though the docstring did read as if it were, and I've fixed that in a9748a8.
When a value has no registered serializer, the client receives
null. That is true of the first delta and every later one. So two different unserializable objects are genuinely indistinguishable to the frontend, and suppressing the second one is correct: re-sendingnullovernullis exactly the pointless re-render this PR exists to avoid. The comparison is deliberately "what would the client receive", not "is this the same Python object" — that's also what makes it immune to in-place mutation of a state-owned value._UNKEYABLE_VALUEis reserved for values where serialization actually raises (e.g. a circular structure), which is covered bytest_uncached_computed_var_unkeyable_value_always_sent.The docstring now says so explicitly rather than implying every unserializable value is unkeyable:
Generated by Claude Code