-
Notifications
You must be signed in to change notification settings - Fork 1.8k
ENG-9148: feat(compiler): incremental compile cache & warm hot-reload daemon (REFLEX_COMPILE_CACHE) #6688
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?
ENG-9148: feat(compiler): incremental compile cache & warm hot-reload daemon (REFLEX_COMPILE_CACHE) #6688
Changes from all commits
a453ccb
8360450
7078d9e
5ba6614
fe06056
9f1b1c2
43d5351
19b2df4
aac67b2
519f885
f79b9db
451be1e
724cd3d
21e7d3e
14e5a3c
bf6a8bf
5cb52c5
6dbface
516d9fe
17a31ac
e765921
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 @@ | ||
| Added an experimental disk-persisted incremental compile cache, enabled by the `REFLEX_COMPILE_CACHE` environment variable. When on, a fresh compile reuses the previous build already on disk in `.web` and recompiles only the pages whose source changed, tracked via a per-page dependency graph (Python import closure, files read during page evaluation, component modules, and referenced state). App-wide inputs (Reflex version, config/lockfiles, and the app entrypoint's config modules such as theme/app-wraps/stylesheets) gate the whole cache, falling back to a full compile when they change. `reflex run` dev additionally gains a warm fork-per-compile daemon so hot reloads skip the cold reimport and rebuild only what changed. Off by default — the compile path is unchanged when the flag is unset. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Added an optional per-page source-read recorder hook (`page_source_recorder` in the compiler plugin) used by the incremental compile cache to track the exact files each page reads during evaluation, and made auto-generated unique ref names reproducible across in-process compiles so memo content hashes stay stable. No behavior change unless the compile cache is enabled. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| import dataclasses | ||
| import inspect | ||
| from collections.abc import Callable, Sequence | ||
| from contextlib import AbstractContextManager | ||
| from contextvars import ContextVar, Token | ||
| from types import TracebackType | ||
| from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, TypeVar, cast | ||
|
|
@@ -35,6 +36,10 @@ | |
| _BaseComponentT = TypeVar("_BaseComponentT", bound=BaseComponent) | ||
|
|
||
|
|
||
| #: Optional recorder for source files read during each page evaluation. | ||
| page_source_recorder: Callable[[], AbstractContextManager[set[str]]] | None = None | ||
|
|
||
|
|
||
| class PageDefinition(Protocol): | ||
| """Protocol for page-like objects compiled by :class:`CompileContext`.""" | ||
|
|
||
|
|
@@ -690,6 +695,12 @@ class PageContext(BaseContext): | |
| output_path: str | None = None | ||
| output_code: str | None = None | ||
| source_module: str | None = None | ||
| # Source files read while evaluating this page, when a recorder is installed. | ||
| source_files: set[str] = dataclasses.field(default_factory=set) | ||
| # Auto-memo components first registered while compiling this page. | ||
| memo_contributions: dict[tuple[str, str | None], Any] = dataclasses.field( | ||
| default_factory=dict | ||
| ) | ||
| # Stack of ``id(component)`` for components whose subtree is | ||
| # memoize-suppressed. Populated by ``MemoizeStatefulPlugin`` when it | ||
| # encounters a ``MemoizationLeaf``-style snapshot boundary and popped on | ||
|
|
@@ -762,7 +773,9 @@ class CompileContext(BaseContext): | |
| app_wrap_components: dict[tuple[int, str], Component] = dataclasses.field( | ||
| default_factory=dict | ||
| ) | ||
| stateful_routes: dict[str, None] = dataclasses.field(default_factory=dict) | ||
| # Routes whose evaluation defined new state classes, mapped to the full | ||
| # names of the states each page defined. | ||
| stateful_routes: dict[str, list[str]] = dataclasses.field(default_factory=dict) | ||
| # Auto-memoize wrapper tags seen during the tree walk (populated by | ||
| # ``MemoizeStatefulPlugin``). | ||
| memoize_wrappers: dict[str, None] = dataclasses.field(default_factory=dict) | ||
|
|
@@ -794,6 +807,7 @@ def compile( | |
| """ | ||
| from reflex.compiler import compiler | ||
| from reflex.state import all_base_state_classes | ||
| from reflex_base.vars.base import reset_unique_variable_names | ||
|
|
||
| self.ensure_context_attached() | ||
| self.compiled_pages.clear() | ||
|
|
@@ -803,15 +817,30 @@ def compile( | |
| self.memoize_wrappers.clear() | ||
| self.auto_memo_components.clear() | ||
|
|
||
| # Keep generated ref names stable across in-process compiles. | ||
| reset_unique_variable_names() | ||
|
Collaborator
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 practical problem with resetting this every time is that the generated names are order-dependent. so if the first compilation hits 3 pages and they each use i think we need a little more machinery here; off the top of my head thinking, |
||
|
|
||
| recorder = page_source_recorder | ||
| for page in self.pages: | ||
| page_fn = page.component | ||
| n_states_before = len(all_base_state_classes) | ||
| page_ctx = self.hooks.eval_page( | ||
| page_fn, | ||
| page=page, | ||
| compile_context=self, | ||
| **kwargs, | ||
| ) | ||
| if recorder is not None: | ||
| with recorder() as read_set: | ||
| page_ctx = self.hooks.eval_page( | ||
| page_fn, | ||
| page=page, | ||
| compile_context=self, | ||
| **kwargs, | ||
| ) | ||
| if page_ctx is not None: | ||
| page_ctx.source_files = read_set | ||
| else: | ||
| page_ctx = self.hooks.eval_page( | ||
| page_fn, | ||
| page=page, | ||
| compile_context=self, | ||
| **kwargs, | ||
| ) | ||
| if page_ctx is None: | ||
| page_name = getattr(page_fn, "__name__", repr(page_fn)) | ||
| msg = ( | ||
|
|
@@ -824,7 +853,12 @@ def compile( | |
| raise RuntimeError(msg) | ||
|
|
||
| if len(all_base_state_classes) > n_states_before: | ||
| self.stateful_routes[page.route] = None | ||
| # Record which states this page defined (registration order is | ||
| # insertion order), so the compile cache can fingerprint the | ||
| # page's contribution to the contexts file. | ||
| self.stateful_routes[page.route] = list(all_base_state_classes)[ | ||
| n_states_before: | ||
| ] | ||
|
|
||
| self.compiled_pages[page_ctx.route] = page_ctx | ||
|
|
||
|
|
@@ -836,6 +870,7 @@ def compile( | |
| self.compiled_pages.values(), | ||
| strict=True, | ||
| ): | ||
| memo_before = set(self.auto_memo_components) | ||
| with page_ctx: | ||
| page_ctx.root_component = self.hooks.compile_component( | ||
| page_ctx.root_component, | ||
|
|
@@ -848,6 +883,12 @@ def compile( | |
| compile_context=self, | ||
| **kwargs, | ||
| ) | ||
| # Attribute newly-registered auto-memo components to this page. | ||
| page_ctx.memo_contributions = { | ||
| key: value | ||
| for key, value in self.auto_memo_components.items() | ||
| if key not in memo_before | ||
| } | ||
|
|
||
| page_ctx.frontend_imports = page_ctx.merged_imports(collapse=True) | ||
| self.all_imports = merge_imports( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.