-
Notifications
You must be signed in to change notification settings - Fork 1.8k
perf(state): skip unchanged scalar writes and resolve stale computed vars lazily #7036
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
0472a69
ef8666b
fc47df9
4ed537a
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 @@ | ||
| Stop re-sending a cached computed var whose recomputed value is unchanged, and stop re-walking the computed var dependency graph on every write within one event handler. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Cached computed vars are now resolved lazily: a stale value is recomputed only when one of its dependencies actually changed, and an unchanged result keeps the cache and does not cascade to dependents. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2261,6 +2261,27 @@ def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]: | |
| return isinstance(obj, FakeComputedVarBaseClass) | ||
|
|
||
|
|
||
| _MISSING: Any = object() | ||
|
|
||
| # Computed var results compared by equality before dependents are cascaded. | ||
| _CUTOFF_TYPES = frozenset({int, float, str, bool, type(None)}) | ||
|
|
||
|
|
||
| def _is_unchanged(old: Any, new: Any) -> bool: | ||
| """Whether a recomputed value is observably the same as the cached one. | ||
|
|
||
| Only scalars are compared; objects are always treated as changed. | ||
|
|
||
| Args: | ||
| old: The cached value. | ||
| new: The recomputed value. | ||
|
|
||
| Returns: | ||
| True when both are scalars of the same type and compare equal. | ||
| """ | ||
| return type(new) in _CUTOFF_TYPES and type(old) is type(new) and old == new | ||
|
|
||
|
|
||
| @dataclasses.dataclass( | ||
| eq=False, | ||
| frozen=True, | ||
|
|
@@ -2594,20 +2615,62 @@ def __get__(self, instance: BaseState | None, owner: type): | |
| if not self._cache: | ||
| value = self.fget(instance) | ||
| else: | ||
| # handle caching | ||
| if not hasattr(instance, self._cache_attr) or self.needs_update(instance): | ||
| # Set cache attr on state instance. | ||
| setattr(instance, self._cache_attr, self.fget(instance)) | ||
| # Ensure the computed var gets serialized to redis. | ||
| instance._was_touched = True | ||
| # Set the last updated timestamp on the state instance. | ||
| setattr(instance, self._last_updated_attr, datetime.datetime.now()) | ||
| if self._name in instance._stale_computed_vars: | ||
| self._resolve_stale(instance) | ||
| elif not hasattr(instance, self._cache_attr) or self.needs_update(instance): | ||
| self._recompute(instance) | ||
| value = getattr(instance, self._cache_attr) | ||
|
|
||
| self._check_deprecated_return_type(instance, value) | ||
|
|
||
| return value | ||
|
|
||
| def _recompute(self, instance: BaseState) -> None: | ||
| """Run the getter and store its value in the instance cache. | ||
|
|
||
| Args: | ||
| instance: The state instance to compute the value for. | ||
| """ | ||
| setattr(instance, self._cache_attr, self.fget(instance)) | ||
| # Ensure the computed var gets serialized to redis. | ||
| instance._was_touched = True | ||
| # Set the last updated timestamp on the state instance. | ||
| setattr(instance, self._last_updated_attr, datetime.datetime.now()) | ||
|
|
||
| def _resolve_stale(self, instance: BaseState) -> None: | ||
| """Settle a stale cached value: recompute it only if a dependency changed. | ||
|
|
||
| A dependency counts as changed when it is in its state's ``dirty_vars``. | ||
| Stale computed var dependencies are resolved first, so a chain settles | ||
| from the changed base var outward. When the getter returns a value equal | ||
| to the cached one, the cache is kept and the var stays out of | ||
| ``dirty_vars``, which stops the cascade at this point. | ||
|
|
||
| Args: | ||
| instance: The state instance holding the stale cache. | ||
| """ | ||
| name = self._name | ||
| instance._stale_computed_vars.discard(name) | ||
|
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.
When a stale computed getter raises, Knowledge Base Used:
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: If a stale computed getter raises, this method has already removed its stale marker, so the cached old value is returned on the next read without retrying. Preserve the stale marker until dependency resolution and recomputation succeed. Prompt for AI agents |
||
| cache_attr = self._cache_attr | ||
| changed = not hasattr(instance, cache_attr) or self.needs_update(instance) | ||
| if not changed: | ||
| state_cls = type(instance) | ||
| for state_name, dep in state_cls._computed_var_deps.get(name, ()): | ||
| dep_state = instance._get_state_by_full_name(state_name) | ||
| if dep_state is None: | ||
| continue | ||
| if dep in dep_state._stale_computed_vars: | ||
| dep_state.computed_vars[dep]._resolve_stale(dep_state) | ||
| if dep in dep_state.dirty_vars: | ||
| changed = True | ||
| break | ||
| if not changed: | ||
| return | ||
| old = getattr(instance, cache_attr, _MISSING) | ||
| self._recompute(instance) | ||
| if not _is_unchanged(old, getattr(instance, cache_attr)): | ||
| instance.dirty_vars.add(name) | ||
|
|
||
| def _check_deprecated_return_type(self, instance: BaseState, value: Any) -> None: | ||
| if not _isinstance(value, self._var_type, nested=1, treat_var_as_type=False): | ||
| logger.error( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,7 @@ | |
| from reflex_base.utils.types import _isinstance | ||
| from reflex_base.vars import Field, VarData, field | ||
| from reflex_base.vars.base import ( | ||
| AsyncComputedVar, | ||
| ComputedVar, | ||
| DynamicRouteVar, | ||
| EvenMoreBasicBaseState, | ||
|
|
@@ -369,6 +370,9 @@ def _is_user_descriptor(value: Any) -> bool: | |
| "inherited_backend_vars", | ||
| "event_handlers", | ||
| "_var_dependencies", | ||
| "_computed_var_deps", | ||
| "_frontend_computed_vars", | ||
| "_interval_computed_vars", | ||
| "_always_dirty_computed_vars", | ||
| "_always_dirty_substates", | ||
| "_potentially_dirty_states", | ||
|
|
@@ -402,6 +406,15 @@ class BaseState(EvenMoreBasicBaseState): | |
| # Mapping of var name to set of (state_full_name, var_name) that depend on it. | ||
| _var_dependencies: ClassVar[builtins.dict[str, set[tuple[str, str]]]] = {} | ||
|
|
||
| # Mapping of computed var name to the (state_full_name, var_name) pairs it reads. | ||
| _computed_var_deps: ClassVar[builtins.dict[str, tuple[tuple[str, str], ...]]] = {} | ||
|
|
||
| # Cached computed vars that are sent to the frontend. | ||
| _frontend_computed_vars: ClassVar[set[str]] = set() | ||
|
|
||
| # Computed vars with an update_interval. | ||
| _interval_computed_vars: ClassVar[set[str]] = set() | ||
|
|
||
| # Set of vars which always need to be recomputed | ||
| _always_dirty_computed_vars: ClassVar[set[str]] = set() | ||
|
|
||
|
|
@@ -425,6 +438,9 @@ class BaseState(EvenMoreBasicBaseState): | |
| # The set of dirty substates. | ||
| dirty_substates: set[str] = field(default_factory=set, is_var=False) | ||
|
|
||
| # Cached computed vars whose dependencies changed and that have not been re-read yet. | ||
| _stale_computed_vars: set[str] = field(default_factory=set, is_var=False) | ||
|
|
||
| # The routing path that triggered the state | ||
| router_data: builtins.dict[str, Any] = field( | ||
| default_factory=builtins.dict, is_var=False | ||
|
|
@@ -830,6 +846,7 @@ def computed_var_func(state: Self): | |
| cls.vars[unique_var_name] = computed_var_func_arg | ||
| cls._update_substate_inherited_vars({unique_var_name: computed_var_func_arg}) | ||
| cls._always_dirty_computed_vars.add(unique_var_name) | ||
| cls._frontend_computed_vars.add(unique_var_name) | ||
|
|
||
| return getattr(cls, unique_var_name) | ||
|
|
||
|
|
@@ -894,10 +911,22 @@ def _init_var_dependency_dicts(cls): | |
| Additional updates tracking dicts for vars and substates that always | ||
| need to be recomputed. | ||
| """ | ||
| cls._computed_var_deps = {} | ||
| cls._frontend_computed_vars = { | ||
| cvar_name | ||
| for cvar_name, cvar in cls.computed_vars.items() | ||
| if not cvar._backend | ||
| } | ||
| cls._interval_computed_vars = { | ||
| cvar_name | ||
| for cvar_name, cvar in cls.computed_vars.items() | ||
| if cvar._update_interval is not None | ||
| } | ||
| for cvar_name, cvar in cls.computed_vars.items(): | ||
| if not cvar._cache: | ||
| # Do not perform dep calculation when cache=False (these are always dirty). | ||
| continue | ||
| forward_deps: list[tuple[str, str]] = [] | ||
| for state_name, dvar_set in cvar._deps(objclass=cls).items(): | ||
| state_cls = cls.get_root_state().get_class_substate(state_name) | ||
| for dvar in dvar_set: | ||
|
|
@@ -916,6 +945,8 @@ def _init_var_dependency_dicts(cls): | |
| defining_state_cls._potentially_dirty_states.add( | ||
| cls.get_full_name() | ||
| ) | ||
| forward_deps.append((defining_state_cls.get_full_name(), dvar)) | ||
| cls._computed_var_deps[cvar_name] = tuple(forward_deps) | ||
|
Comment on lines
+948
to
+949
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.
When a cached synchronous computed var calls Useful? React with 👍 / 👎.
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: When a cached synchronous Prompt for AI agents |
||
|
|
||
| # ComputedVar with cache=False always need to be recomputed | ||
| cls._always_dirty_computed_vars = { | ||
|
|
@@ -1802,30 +1833,70 @@ async def get_var_value(self, var: Var[VAR_TYPE]) -> VAR_TYPE: | |
| return await value | ||
| return value | ||
|
|
||
| def _mark_dirty_computed_vars(self) -> None: | ||
| """Mark ComputedVars that need to be recalculated based on dirty_vars.""" | ||
| # Append expired computed vars to dirty_vars to trigger recalculation | ||
| self.dirty_vars.update(self._expired_computed_vars()) | ||
| # Append always dirty computed vars to dirty_vars to trigger recalculation | ||
| self.dirty_vars.update(self._always_dirty_computed_vars) | ||
| def _get_state_by_full_name(self, full_name: str) -> BaseState | None: | ||
| """Get a loaded state instance from this tree by its full name. | ||
|
|
||
| Args: | ||
| full_name: The full dotted name of the state. | ||
|
|
||
| Returns: | ||
| The state instance, or None when it is not loaded in this tree. | ||
| """ | ||
| if full_name == self.get_full_name(): | ||
| return self | ||
| try: | ||
| return self._get_root_state().get_substate(tuple(full_name.split("."))) | ||
| except ValueError: | ||
| return None | ||
|
|
||
| def _mark_dirty_computed_vars(self, from_vars: set[str] | None = None) -> None: | ||
| """Mark ComputedVars that depend on changed vars as stale. | ||
|
|
||
| A stale computed var is re-read lazily: on access, or in ``get_delta``. | ||
| Marking stops at a var that is already stale, so repeated writes in one | ||
| event do not re-walk the dependency graph. | ||
|
|
||
| Args: | ||
| from_vars: The changed vars to cascade from. Defaults to ``dirty_vars`` | ||
| plus expired and always-dirty computed vars. | ||
| """ | ||
| dirty_vars = self.dirty_vars | ||
| while dirty_vars: | ||
| calc_vars, dirty_vars = dirty_vars, set() | ||
| if from_vars is None: | ||
| if self._interval_computed_vars: | ||
| dirty_vars.update(self._expired_computed_vars()) | ||
| dirty_vars.update(self._always_dirty_computed_vars) | ||
| from_vars = dirty_vars | ||
|
|
||
| stale = self._stale_computed_vars | ||
| computed_vars = self.computed_vars | ||
| full_name = self.get_full_name() | ||
| while from_vars: | ||
| calc_vars, from_vars = from_vars, set() | ||
| for state_name, cvar in self._dirty_computed_vars(from_vars=calc_vars): | ||
| if state_name == self.get_full_name(): | ||
| defining_state = self | ||
| if state_name == full_name: | ||
| if cvar in stale: | ||
| continue | ||
| actual_var = computed_vars[cvar] | ||
| if isinstance(actual_var, AsyncComputedVar): | ||
| # Async getters cannot be resolved on a sync read. | ||
| actual_var.mark_dirty(instance=self) | ||
| dirty_vars.add(cvar) | ||
| else: | ||
| stale.add(cvar) | ||
| from_vars.add(cvar) | ||
| else: | ||
| defining_state = self._get_root_state().get_substate( | ||
| tuple(state_name.split(".")) | ||
| ) | ||
| defining_state.dirty_vars.add(cvar) | ||
| actual_var = defining_state.computed_vars.get(cvar) | ||
| if actual_var is not None: | ||
| actual_var.mark_dirty(instance=defining_state) | ||
| if defining_state is self: | ||
| dirty_vars.add(cvar) | ||
| else: | ||
| if cvar in defining_state._stale_computed_vars: | ||
| continue | ||
| defining_state._mark_dirty_computed_vars(from_vars={cvar}) | ||
| actual_var = defining_state.computed_vars[cvar] | ||
| if isinstance(actual_var, AsyncComputedVar): | ||
| actual_var.mark_dirty(instance=defining_state) | ||
| defining_state.dirty_vars.add(cvar) | ||
| else: | ||
| defining_state._stale_computed_vars.add(cvar) | ||
| # mark dirty where this var is defined | ||
| defining_state._mark_dirty() | ||
|
|
||
|
|
@@ -1835,10 +1906,11 @@ def _expired_computed_vars(self) -> set[str]: | |
| Returns: | ||
| Set of computed vars to include in the delta. | ||
| """ | ||
| computed_vars = self.computed_vars | ||
| return { | ||
| cvar | ||
| for cvar, cvar_obj in self.computed_vars.items() | ||
| if cvar_obj.needs_update(instance=self) | ||
| for cvar in self._interval_computed_vars | ||
| if computed_vars[cvar].needs_update(instance=self) | ||
| } | ||
|
|
||
| def _dirty_computed_vars( | ||
|
|
@@ -1869,9 +1941,12 @@ def get_delta(self) -> Delta: | |
| delta = {} | ||
|
|
||
| self._mark_dirty_computed_vars() | ||
| frontend_computed_vars: set[str] = { | ||
| name for name, cv in self.computed_vars.items() if not cv._backend | ||
| } | ||
| frontend_computed_vars = self._frontend_computed_vars | ||
| if stale := self._stale_computed_vars: | ||
| # Re-read stale frontend computed vars; the ones that changed join dirty_vars. | ||
| computed_vars = self.computed_vars | ||
| for name in [cvar for cvar in stale if cvar in frontend_computed_vars]: | ||
| computed_vars[name]._resolve_stale(self) | ||
|
Comment on lines
+1948
to
+1949
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.
When a dependent computed var appears before its dependency in this set snapshot, resolving the dependent recursively resolves and removes the dependency, but the stale snapshot still invokes that dependency again later. Because its base dependency remains dirty, the getter is recomputed twice; this defeats the optimization and can leave non-idempotent or side-effecting computed chains inconsistent, with the dependent based on the first result and the dependency cache holding the second. Check that each snapshot entry is still stale before resolving it. AGENTS.md reference: AGENTS.md:L43-L47 Useful? React with 👍 / 👎. |
||
|
|
||
| # Return the dirty vars for this instance, any cached/dependent computed vars, | ||
| # and always dirty computed vars (cache=False) | ||
|
|
@@ -1958,6 +2033,11 @@ def _clean(self): | |
| # Clean this state. | ||
| self.dirty_vars = set() | ||
| self.dirty_substates = set() | ||
| if stale := self._stale_computed_vars: | ||
| computed_vars = self.computed_vars | ||
| for name in stale: | ||
| computed_vars[name].mark_dirty(instance=self) | ||
| stale.clear() | ||
|
|
||
| def get_value(self, key: str) -> Any: | ||
| """Get the value of a field (without proxying). | ||
|
|
@@ -2075,6 +2155,16 @@ def __getstate__(self): | |
| state.pop("parent_state", None) | ||
| state.pop("substates", None) | ||
| state.pop("_was_touched", None) | ||
| if stale := self._stale_computed_vars: | ||
| # A stale mark is only meaningful together with the marks it | ||
| # cascaded to, and those may sit in substates that are persisted | ||
| # separately or not at all. Persist the invalidation instead: drop | ||
| # the stale caches so a reload recomputes them, and the persisted | ||
| # dirty_vars re-mark the dependents. | ||
| computed_vars = self.computed_vars | ||
| for name in stale: | ||
| state.pop(computed_vars[name]._cache_attr, None) | ||
| state["_stale_computed_vars"] = set() | ||
| # Remove all inherited vars. | ||
| for inherited_var_name in self.inherited_vars: | ||
| state.pop(inherited_var_name, None) | ||
|
|
||
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.
For float state or computed values,
0.0 == -0.0even though the sign is observable through serialization,math.copysign, and JavaScript numeric operations. Assigning one over the other now returns early, and a computed var changing only between these values keeps its old cache and stops invalidation, so backend and frontend state can remain incorrect. Exclude opposite-signed zeroes from the unchanged cutoff.AGENTS.md reference: AGENTS.md:L43-L43
Useful? React with 👍 / 👎.