diff --git a/docs/advanced_onboarding/performance_and_execution.md b/docs/advanced_onboarding/performance_and_execution.md index b86d0227561..3a26e3c259e 100644 --- a/docs/advanced_onboarding/performance_and_execution.md +++ b/docs/advanced_onboarding/performance_and_execution.md @@ -5,6 +5,9 @@ meta_description: Compare Reflex performance with Streamlit and Dash using publi # Performance and execution +For deployment-level capacity and recovery, see [Scaling Reflex Apps](/docs/hosting/scaling/), +including shared state, independent job workers, and Redis diagnostics. + Reflex combines a customizable React interface with explicit Python state and event handlers. Published comparisons show concrete performance gains over Streamlit and Dash: faster initial data visibility in an NBA dashboard and faster bulk updates in a component-heavy form. The measurements below explain where those gains appeared and how to apply the same rendering and state-management techniques in your app. ## Reflex vs Streamlit: faster time to visible data diff --git a/docs/ai_builder/integrations/mcp_overview.md b/docs/ai_builder/integrations/mcp_overview.md index 23208baa4d5..1a6b02170ca 100644 --- a/docs/ai_builder/integrations/mcp_overview.md +++ b/docs/ai_builder/integrations/mcp_overview.md @@ -12,11 +12,16 @@ The hosted server is available at: https://build.reflex.dev/mcp ``` -## Available tool +## Documentation tools -The server currently exposes one read-only tool: +Check your server's `tools/list` response for the tools available in its deployed version. Releases with the knowledge tools provide two read-only documentation operations: -- `search_docs(query)` searches the Reflex documentation index and returns the relevant documentation sections and their source paths. +- `search_reflex_knowledge(queries)` accepts up to five short keyword queries and returns candidate keys, summaries, and source labels from the bundled documentation and curated knowledge. +- `get_reflex_knowledge(keys)` fetches the full text for the exact keys returned by search. Fetch the relevant entries before relying on a summary; the server reports unknown keys separately. + +For example, search with `{"queries": ["horizontal scaling", "durable jobs", "Redis Sentinel"]}`, then pass the relevant returned key to `get_reflex_knowledge`. The [scaling guide](/docs/hosting/scaling/) explains version compatibility, regional session state, and recovery assumptions. Split more than five topics into separate search calls. + +Older deployments may expose `search_docs` instead. Follow the tool schema advertised by the connected server. The knowledge tools use a packaged documentation snapshot, so a website update alone does not refresh the server's corpus; the server release must also update its locked documentation bundle. Use it for questions about Reflex APIs, components, configuration, and development workflows. Review the returned source before applying a change that depends on version-specific behavior. diff --git a/docs/app/assets/hosting/scaling-architecture.svg b/docs/app/assets/hosting/scaling-architecture.svg new file mode 100644 index 00000000000..ddd936108df --- /dev/null +++ b/docs/app/assets/hosting/scaling-architecture.svg @@ -0,0 +1,121 @@ + + Reference architecture for a scaled Reflex application + A browser loads a static frontend and exchanges events and updates through a WebSocket-capable load balancer with replaceable backend replicas. Backends use a primary database for writes and fresh reads, lagging read replicas only for eligible reads, regional Redis for session state, and an optional separate application cache. An ordinary Redis URL connects backends through a provider-managed failover endpoint to the regional Redis current primary. Redis replication to a replica is asynchronous. Endpoint switching, connection recovery and persistence depend on the selected provider and configuration; the endpoint alone does not prevent data loss. A reliable job handoff publishes to a task broker. Independent workers persist outcomes in shared durable storage before acknowledging success. Queue persistence, recovery, result storage, and idempotency must be configured and tested. + + + + + + + + A production app, split by responsibility + Illustrative topology. Add services only when the application needs them. + + Request / response + + Async job + + Replication + + + Browser + React UI + + Static frontend / CDN + HTML, JavaScript, assets + + HTTPS: load frontend + + + Load balancer + WebSocket-capable + + HTTP / WebSocket + events ↔ updates + + Reflex backend replicas + + + + 1 + 2 + N + Same code • compatible state schema + + Route connections + + + + + + + Writes / fresh reads + Lag-tolerant reads + Ordinary Redis URL + + Provider-managed + failover endpoint + Provider-specific behavior + + State / + locks + + + Primary database + Business records + Jobs + result references + + Read replica DBs + Read-only routing + May lag behind primary + + Asynchronous DB replication + + + Regional state Redis + Current primary + Shared by this region + + State Redis replica + + Async + replication + + + + Optional cache + Separate Redis service + Cache get / set + TTLs + + + Publish after reliable handoff (see job lifecycle) + + Task queue / broker + Configure persistence + and delivery / recovery + + Independent workers + Idempotent task effects + Shared inputs + result storage + + Deliver / redeliver + + Ack after persistence + + Persist outcomes in shared durable storage + Storage persistence, replication lag and job recovery depend on configuration. + A provider endpoint alone does not guarantee recovery or prevent data loss. + diff --git a/docs/app/assets/hosting/scaling-job-lifecycle.svg b/docs/app/assets/hosting/scaling-job-lifecycle.svg new file mode 100644 index 00000000000..b427681c2da --- /dev/null +++ b/docs/app/assets/hosting/scaling-job-lifecycle.svg @@ -0,0 +1,75 @@ + + A durable job handoff and recovery lifecycle + An authorized request creates a durable job and outbox record in one transaction using a stable job identifier and a tenant-scoped idempotency key enforced by database uniqueness. The backend can return the job identifier while a dispatcher retries publication until broker confirmation. The broker delivers work to an independent worker. The worker applies effects that are idempotent or transactionally deduplicated by job ID and persists the outcome and result reference before acknowledging success. A worker crash before acknowledgement can lead to redelivery under the selected broker policy, so duplicate delivery must be safe. Any backend checks authorization and reads progress or results from durable records, using a primary or verified caught-up replica for freshness-sensitive reads. This is a design pattern requiring configured and tested persistence, recovery and retention, not an exactly-once guarantee. + + + + + + + + A job can outlive its accepting backend + Example: transactional outbox + configured broker + shared durable results. + + + 1. Authorize and record the request + Commit job + outbox entry in one database transaction. + Stable job ID; enforce a tenant-scoped idempotency key in the database. + + + After commit + + Return the job ID to the UI + Return from the event; show status. + The original backend can retire. + + + Independently + + 2. Dispatch the outbox entry + Publish the same job ID on retry. + Mark sent after broker confirms. + + + Publish / confirm + + 3. Broker delivers the job + Persistence + redelivery configured + + Deliver + + + 4. Worker runs independently + Idempotent / deduplicated effects + Bound retries; surface failed jobs. + + Recovery is explicit + Crash before acknowledgement? + Recover / redeliver under the + selected broker and worker policy. + Duplicate delivery is possible + Retry must not duplicate effects. + External effects may need their + own idempotency or reconciliation. + + + Persist first + + 5. Store outcome + result reference, then acknowledge success + Any backend can authorize and read the job; use the primary for fresh reads. + Store large outputs in shared object storage; set retention and recovery policy. + + A queue library alone does not supply durability or an exactly-once guarantee. + Test interrupted publication, worker loss, duplicate delivery and result recovery. + diff --git a/docs/app/news/+scaling-guide.docs.md b/docs/app/news/+scaling-guide.docs.md new file mode 100644 index 00000000000..93176c2de7d --- /dev/null +++ b/docs/app/news/+scaling-guide.docs.md @@ -0,0 +1 @@ +Add a scaling guide with production architecture and job lifecycle diagrams and a Reflex report-job example, covering horizontal backends, state-store version compatibility, durable jobs, regional data routing, telemetry, and Redis recovery checks. diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py index f03b8315a29..14f407d03a4 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py @@ -260,6 +260,7 @@ def get_sidebar_items_hosting(): "Self Hosting", children=[ hosting.self_hosting, + hosting.scaling, hosting.databricks, hosting.cloud_providers, hosting.bring_your_own_cloud, diff --git a/docs/app/tests/test_routes.py b/docs/app/tests/test_routes.py index 195e4d1b1d8..f0e46936b4d 100644 --- a/docs/app/tests/test_routes.py +++ b/docs/app/tests/test_routes.py @@ -5,9 +5,12 @@ import sys from collections import Counter from pathlib import Path +from urllib.parse import urlsplit import pytest import reflex as rx +from reflex_base.components.component import BaseComponent +from reflex_site_shared.docs.markdown import render_docgen_document from reflex_docs.pages.docs.metadata import docs_metadata @@ -318,6 +321,46 @@ def test_python_block_links_do_not_repeat_docs_basename(): ) +@pytest.mark.parametrize("frontend_path", ["", "/docs"]) +@pytest.mark.parametrize( + "asset_name", ["scaling-architecture.svg", "scaling-job-lifecycle.svg"] +) +def test_scaling_full_size_links_match_the_displayed_assets( + monkeypatch, frontend_path, asset_name +): + """Full-size diagram links must not add the router basename a second time.""" + docs_app = Path(__file__).resolve().parent.parent + monkeypatch.chdir(docs_app) + config = rx.Config(app_name="asset_links", frontend_path=frontend_path) + monkeypatch.setattr("reflex.assets.get_config", lambda: config) + body, _ = render_docgen_document( + "docs/hosting/scaling.md", docs_app.parent / "hosting/scaling.md" + ) + pending: list[BaseComponent] = [body] + images = [] + links = [] + while pending: + component = pending.pop() + pending.extend(component.children) + if component.tag == "img" and asset_name in str(getattr(component, "src", "")): + images.append(component) + elif component.tag in {"a", "Link"} and asset_name in str(component): + links.append(component) + + assert len(images) == len(links) == 1 + image_url = str(getattr(images[0], "src", "")).strip('"') + link = links[0] + if link.tag == "Link": + destination = str(link.to).strip('"') + assert urlsplit(destination).path == f"/hosting/{asset_name}", ( + "React Router adds frontend_path to the destination; " + f"an already-prefixed asset URL would repeat it: {destination}" + ) + else: + assert urlsplit(str(link.href).strip('"')).path == urlsplit(image_url).path + assert urlsplit(image_url).path == f"{frontend_path}/hosting/{asset_name}" + + # Router links that survive the basename check still have to name a real page. # A path reached through `href=`, `to=` or `rx.redirect(...)` is navigation and is # always checked. Bare path literals are checked only when their first segment is a @@ -328,19 +371,25 @@ def test_python_block_links_do_not_repeat_docs_basename(): def test_python_block_links_resolve_to_real_routes(routes_fixture): - """Router links inside ```python blocks must name a registered docs route. + """Python block links must name a registered docs route or an existing asset. `test_doc_links.py` validates prose links off the markdown AST, which ignores fenced code blocks by design — so links written in ```python blocks are checked here instead. """ known = {route.path.rstrip("/") for route in routes_fixture if route.path} + asset_root = Path(__file__).resolve().parent.parent / "assets" + asset_paths = { + "/" + asset.relative_to(asset_root).as_posix() + for asset in asset_root.rglob("*") + if asset.is_file() + } sections = {path.split("/")[1] for path in known if path.count("/") > 1} def is_broken(link: str, navigation: bool) -> bool: path = link.split("#")[0].rstrip("/") segments = path.split("/") - if len(segments) < 3 or path in known: + if len(segments) < 3 or path in known or path in asset_paths: return False # A misspelled section (/enterprize/...) still has to fail, so navigation # is validated whatever its first segment says. diff --git a/docs/app/tests/test_scaling_job_example.py b/docs/app/tests/test_scaling_job_example.py new file mode 100644 index 00000000000..f2d59ddf4b6 --- /dev/null +++ b/docs/app/tests/test_scaling_job_example.py @@ -0,0 +1,450 @@ +"""Exercise the published handlers; fake services do not establish durability.""" + +import asyncio +import re +import sys +from contextlib import asynccontextmanager +from functools import wraps +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import reflex as rx +from reflex.compiler.compiler import compile_page +from reflex.istate.manager.memory import StateManagerMemory +from reflex.istate.manager.token import BaseStateToken +from reflex.istate.proxy import StateProxy +from reflex.istate.shared import SharedState, SharedStateBaseInternal +from reflex_base.event.context import EventContext +from reflex_base.registry import RegistrationContext + +GUIDE = Path(__file__).resolve().parents[2] / "hosting/scaling.md" + + +def run_async(test): + """Run an async case with core pytest, preserving fixture/parameter discovery.""" + + @wraps(test) + def run(*args, **kwargs): + """Execute one case in its own event loop.""" + return asyncio.run(test(*args, **kwargs)) + + return run + + +class ReportServiceError(Exception): + """The adapter could not confirm the requested operation.""" + + +class ReportService: + """A deterministic adapter double, never a production job store.""" + + def __init__(self): + self.jobs = {} + self.outbox = [] + self.calls = [] + self.fail_accept = False + self.lose_response = False + self.pause = "" + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def boundary(self, operation): + """Assert service I/O happens after releasing the real state lock.""" + manager = EventContext.get().state_manager + assert isinstance(manager, StateManagerMemory) + assert not any(lock.locked() for lock in manager._states_locks.values()) + if self.pause == operation: + self.started.set() + await self.release.wait() + + async def accept_report(self, owner, request_key, report): + """Emulate the documented owner-scoped acceptance contract.""" + await self.boundary("accept") + self.calls.append(("accept", owner, request_key, report)) + if self.fail_accept: + raise ReportServiceError + key = (owner, request_key) + if key in self.jobs: + if self.jobs[key].report != report: + raise ReportServiceError + else: + self.jobs[key] = SimpleNamespace( + id=uuid4().hex, request_key=request_key, report=report, status="queued" + ) + self.outbox.append(self.jobs[key].id) + if self.lose_response: + raise ReportServiceError + return self.jobs[key] + + async def read_report(self, owner, job_id, request_key=""): + """Return only the owner's selected or latest job.""" + await self.boundary("read") + self.calls.append(("read", owner, job_id, request_key)) + owned = [job for (who, _), job in self.jobs.items() if who == owner] + if job_id: + return next((job for job in owned if job.id == job_id), None) + if request_key: + return self.jobs.get((owner, request_key)) + return owned[-1] if owned else None + + +@pytest.fixture +def example(monkeypatch): + """Import exactly the code a reader copies, with app-owned adapters.""" + source = GUIDE.read_text() + blocks = re.findall(r"^```python\n(.*?)^```", source, re.MULTILINE | re.DOTALL) + matches = [block for block in blocks if "class ReportState(rx.State):" in block] + assert len(matches) == 1 + with RegistrationContext.get().fork() as context: + context._set_config(rx.Config(app_name="scaling_example_test")) + if SharedStateBaseInternal.get_full_name() not in context.base_states: + RegistrationContext.register_base_state(SharedStateBaseInternal) + auth = ModuleType("report_app.auth") + monkeypatch.setitem(sys.modules, auth.__name__, auth) + exec( + "import reflex as rx\n" + "class AuthState(rx.State):\n" + " _owner_id: str = ''\n" + " _session_revision: int = 0\n", + auth.__dict__, + ) + service = ReportService() + adapter = ModuleType("report_app.reports") + adapter.__dict__.update( + accept_report=service.accept_report, + read_report=service.read_report, + ReportServiceError=ReportServiceError, + ) + monkeypatch.setitem(sys.modules, adapter.__name__, adapter) + module = ModuleType(f"scaling_report_example_{uuid4().hex}") + monkeypatch.setitem(sys.modules, module.__name__, module) + exec(matches[0], module.__dict__) + assert issubclass(module.ReportState, rx.State) + assert module.ReportState.event_handlers["sync_report"].is_background + yield SimpleNamespace(module=module, service=service, auth=auth.AuthState) + + +def test_report_panel_compiles_frontend_event_and_state_bindings(example): + """Compile the copied UI itself, including real event arguments and Vars.""" + _, code = compile_page("report-example", example.module.report_panel()) + assert "export default" in code + assert "sync_report" in code and "new_report" in code + assert "job_id" in code and "status" in code + + +@asynccontextmanager +async def session(example, owner="alice"): + """Use real Reflex state, StateProxy and state-manager lock semantics.""" + manager = StateManagerMemory() + token = uuid4().hex + try: + with EventContext(token=token, state_manager=manager, enqueue_impl=AsyncMock()): + cls = example.module.ReportState + root = await manager.get_state(BaseStateToken(ident=token, cls=cls)) + state = root.get_substate(cls.get_full_name().split(".")) + proxy = StateProxy(state) + async with proxy: + auth = await proxy.get_state(example.auth) + auth._owner_id = owner + yield proxy + finally: + await manager.close() + + +async def start_report(example, state): + """Follow the explicit New report, then Submit UI flow.""" + async with state: + await example.module.ReportState.new_report.fn(state) + await example.module.ReportState.sync_report.fn(state, True) + + +@run_async +async def test_session_survives_shared_state_in_another_context(example): + """A prior shared-state demo must not break this isolated app's state locks.""" + with RegistrationContext.get().fork(): + + class PriorSharedState(SharedState): + """Model a shared-state demo evaluated in a separate app context.""" + + value: int = 0 + + async with session(example) as state: + await start_report(example, state) + assert state.job_id and state.status == "queued" + + +@run_async +@pytest.mark.parametrize("next_owner", ["alice", "bob"]) +@pytest.mark.parametrize("lost_response", [False, True]) +async def test_direct_submit_after_login_recovers_without_accepting_a_new_job( + example, next_owner, lost_response +): + """Submit after an authentication change recovers even a lost acceptance.""" + async with session(example) as state: + example.service.lose_response = lost_response + await start_report(example, state) + expected = example.service.jobs[("alice", state._request_key)] + if next_owner == "bob": + async with session(example, "bob") as other: + await start_report(example, other) + expected = example.service.jobs[("bob", other._request_key)] + example.service.lose_response = False + before = len(example.service.outbox) + async with state: + auth = await state.get_state(example.auth) + auth._owner_id = next_owner + auth._session_revision += 2 + state.job_id = state.status = state.error = "" + await example.module.ReportState.sync_report.fn(state, True) + assert example.service.calls[-1] == ("read", next_owner, "", "") + assert ( + state.job_id == expected.id and state._request_key == expected.request_key + ) + assert len(example.service.jobs) == len(example.service.outbox) == before + await example.module.ReportState.sync_report.fn(state, True) + assert len(example.service.jobs) == len(example.service.outbox) == before + + +@run_async +@pytest.mark.parametrize("existing_report", [False, True]) +async def test_fresh_state_submit_requires_explicit_new_or_recovers_existing( + example, existing_report +): + """Without a selected key, Submit never starts an unrequested new report.""" + expected = None + if existing_report: + async with session(example) as previous: + await start_report(example, previous) + expected = example.service.jobs[("alice", previous._request_key)] + before = len(example.service.outbox) + async with session(example) as state: + await example.module.ReportState.sync_report.fn(state, True) + assert example.service.calls[-1] == ("read", "alice", "", "") + assert len(example.service.jobs) == len(example.service.outbox) == before + if expected: + assert state.job_id == expected.id + assert state._request_key == expected.request_key and not state.error + else: + assert not state.job_id and not state._request_key + assert state.error == "Choose New report to start a report." + await example.module.ReportState.sync_report.fn(state, True) + assert len(example.service.jobs) == len(example.service.outbox) == before + await start_report(example, state) + assert len(example.service.jobs) == len(example.service.outbox) == before + 1 + + +@run_async +@pytest.mark.parametrize("lost_response", [False, True]) +async def test_retries_reuse_the_request_key_and_committed_job(example, lost_response): + """A repeated submission, including lost acknowledgement, keeps one job.""" + async with session(example) as state: + example.service.lose_response = lost_response + await start_report(example, state) + request_key = state._request_key + example.service.lose_response = False + await example.module.ReportState.sync_report.fn(state, True) + assert state._request_key == request_key + assert len(example.service.jobs) == len(example.service.outbox) == 1 + assert state.job_id == example.service.outbox[0] + assert state.status == "queued" and not state.error + async with state: + await example.module.ReportState.new_report.fn(state) + assert state._request_key != request_key + assert not state.job_id and not state.status + + +@run_async +async def test_failed_accept_does_not_claim_an_accepted_job(example): + """A failed acceptance leaves a retryable key without a job ID/status.""" + example.service.fail_accept = True + async with session(example) as state: + await start_report(example, state) + assert state._request_key and state.error + assert not state.job_id and not state.status + assert not example.service.jobs and not example.service.outbox + + +@run_async +async def test_status_read_uses_owner_and_selected_job_id(example): + """Possessing another owner's job ID does not return its status.""" + async with session(example) as alice: + await start_report(example, alice) + job_id = alice.job_id + await example.module.ReportState.sync_report.fn(alice) + assert example.service.calls[-1] == ( + "read", + "alice", + job_id, + alice._request_key, + ) + async with session(example, "bob") as bob: + await example.module.ReportState.sync_report.fn(bob) + async with bob: + bob.job_id = job_id + await example.module.ReportState.sync_report.fn(bob) + assert example.service.calls[-1] == ("read", "bob", job_id, "") + assert not bob.job_id and not bob.status + + +@run_async +@pytest.mark.parametrize("operation", ["submit", "read"]) +@pytest.mark.parametrize("change", ["logout", "same_owner_relogin", "selection"]) +async def test_stale_service_response_does_not_update_the_ui( + example, operation, change +): + """Responses cannot overwrite a changed authentication or selection.""" + async with session(example) as state: + async with state: + await example.module.ReportState.new_report.fn(state) + if operation == "read": + await example.module.ReportState.sync_report.fn(state, True) + example.service.pause = "accept" if operation == "submit" else "read" + task = asyncio.create_task( + example.module.ReportState.sync_report.fn(state, operation == "submit") + ) + await asyncio.wait_for(example.service.started.wait(), 2) + async with state: + if change == "selection": + await example.module.ReportState.new_report.fn(state) + else: + auth = await state.get_state(example.auth) + auth._session_revision += 1 + auth._owner_id = "" if change == "logout" else "alice" + state.job_id = state.status = state.error = "" + example.service.release.set() + await asyncio.wait_for(task, 2) + assert not state.job_id and not state.status and not state.error + + +@run_async +async def test_fresh_state_recovers_job_and_retry_key(example): + """A new session uses an authorized durable lookup, not old UI state.""" + async with session(example) as original: + await start_report(example, original) + job_id, request_key = original.job_id, original._request_key + async with session(example) as fresh: + assert not fresh.job_id and not fresh._request_key + await example.module.ReportState.sync_report.fn(fresh) + assert example.service.calls[-1] == ("read", "alice", "", "") + assert fresh.job_id == job_id and fresh._request_key == request_key + await example.module.ReportState.sync_report.fn(fresh, True) + assert len(example.service.jobs) == len(example.service.outbox) == 1 + + +@run_async +@pytest.mark.parametrize("next_owner", ["alice", "bob"]) +@pytest.mark.parametrize("clear_ui", [False, True]) +async def test_authentication_change_recovers_without_previous_session_selectors( + example, next_owner, clear_ui +): + """An account switch or relogin cannot reuse the old report selection.""" + async with session(example) as state: + await start_report(example, state) + async with session(example, next_owner) as other: + await start_report(example, other) + latest_id, latest_key = other.job_id, other._request_key + async with state: + auth = await state.get_state(example.auth) + auth._owner_id = next_owner + auth._session_revision += 2 # Logout and login between report events. + state.error = "Previous session error" + if clear_ui: + state.job_id = state.status = state.error = "" + example.service.pause = "read" + task = asyncio.create_task(example.module.ReportState.sync_report.fn(state)) + await asyncio.wait_for(example.service.started.wait(), 2) + assert not state.job_id and not state.status and not state.error + assert not state._request_key + example.service.release.set() + await asyncio.wait_for(task, 2) + assert example.service.calls[-1] == ("read", next_owner, "", "") + assert state.job_id == latest_id and state._request_key == latest_key + assert state.status == "queued" and not state.error + await example.module.ReportState.sync_report.fn(state, True) + assert len(example.service.jobs) == len(example.service.outbox) == 2 + + +@run_async +@pytest.mark.parametrize("account_change", [False, True]) +async def test_new_report_binds_identity_before_first_refresh(example, account_change): + """An explicit new selection survives initial or post-login recovery.""" + async with session(example, "bob") as previous: + await start_report(example, previous) + older_id = previous.job_id + async with session(example, "alice" if account_change else "bob") as state: + if account_change: + await start_report(example, state) + async with state: + if account_change: + auth = await state.get_state(example.auth) + auth._owner_id = "bob" + auth._session_revision += 2 + await example.module.ReportState.new_report.fn(state) + new_key = state._request_key + await example.module.ReportState.sync_report.fn(state) + assert example.service.calls[-1] == ("read", "bob", "", new_key) + assert state._request_key == new_key and not state.job_id + await example.module.ReportState.sync_report.fn(state, True) + assert state._request_key == new_key and state.job_id != older_id + + +@run_async +async def test_logout_clears_selection_without_calling_job_services(example): + """Signed-out refresh removes private selectors as well as visible fields.""" + async with session(example) as state: + await start_report(example, state) + calls = len(example.service.calls) + async with state: + auth = await state.get_state(example.auth) + auth._owner_id = "" + auth._session_revision += 1 + state.error = "Previous session error" + await example.module.ReportState.sync_report.fn(state) + assert not state.job_id and not state.status and not state.error + assert not state._request_key + assert len(example.service.calls) == calls + + +@run_async +@pytest.mark.parametrize("pending_submit", [False, True]) +async def test_refresh_does_not_replace_a_new_request_with_an_older_job( + example, pending_submit +): + """Refresh keeps a new request's key even before its acceptance returns.""" + async with session(example) as state: + await start_report(example, state) + old_job = state.job_id + async with state: + await example.module.ReportState.new_report.fn(state) + new_key = state._request_key + task = None + if pending_submit: + example.service.pause = "accept" + task = asyncio.create_task( + example.module.ReportState.sync_report.fn(state, True) + ) + await asyncio.wait_for(example.service.started.wait(), 2) + await example.module.ReportState.sync_report.fn(state) + assert state._request_key == new_key + assert not state.job_id and not state.status + if task is not None: + example.service.release.set() + await asyncio.wait_for(task, 2) + assert not state.job_id + example.service.pause = "" + await example.module.ReportState.sync_report.fn(state, True) + assert state._request_key == new_key and state.job_id != old_job + assert len(example.service.jobs) == len(example.service.outbox) == 2 + + +@run_async +async def test_signed_out_state_does_not_call_job_services(example): + """An absent trusted identity prevents both reads and submissions.""" + async with session(example, "") as state: + await example.module.ReportState.sync_report.fn(state, True) + await example.module.ReportState.sync_report.fn(state) + assert not example.service.calls + assert not state.job_id and not state.status diff --git a/docs/hosting/scaling.md b/docs/hosting/scaling.md new file mode 100644 index 00000000000..1b8b8c50bab --- /dev/null +++ b/docs/hosting/scaling.md @@ -0,0 +1,293 @@ +--- +title: Scaling Reflex Apps +meta_description: Horizontal scaling for Reflex apps, durable jobs, regional state, Redis failover, database read routing, OpenTelemetry compatibility, Redis spikes, and scale-down recovery. +--- + +# Scaling Reflex Apps + +Scale the interactive backend by adding small, replaceable replicas behind a load balancer. Keep UI events quick and give long-running jobs their own workers and durable storage. + +This guide uses a report-export application to explain the boundaries: a user requests a report, leaves the page, and later retrieves the result through any healthy backend. Choose capacity and recovery settings from production-mode tests of your exact versions. Managed hosting may expose different controls. + +## Reference production architecture + +This is an illustrative deployment, not a list of required components. Start with the request path and stores your app needs. Add read replicas for lag-tolerant read load, a separate cache for expensive repeatable reads, and a queue when jobs must outlive a backend. + +```python exec +import reflex as rx +``` + +```python eval +rx.el.figure( + rx.el.div( + rx.image( + src=rx.asset("hosting/scaling-architecture.svg"), + alt="Production architecture: browser and frontend, load balancer, backend replicas, databases, separate Redis roles, provider-managed failover endpoint, queue and workers. Text description follows.", + width="100%", + min_width="760px", + height="auto", + ), + overflow_x="auto", + width="100%", + tab_index=0, + role="region", + custom_attrs={ + "aria-label": "Production architecture; scroll horizontally on narrow screens" + }, + ), + rx.el.figcaption( + rx.el.a( + "Open architecture diagram at full size", + href="/hosting/scaling-architecture.svg", + target="_blank", + rel="noopener noreferrer", + text_decoration="underline", + ), + ), + width="100%", + margin_y="1.5em", +) +``` + +### Architecture text alternative + +The labeled arrows distinguish traffic types. On narrow screens, scroll the diagram or open it at full size. + +- **Request/response:** browser ↔ static frontend/CDN for assets; browser frontend ↔ load balancer ↔ one healthy backend replica for events and state updates over WebSockets. Backends share the same application build. +- **Application data:** backend replicas ↔ regional Redis session state; optional application cache uses a separate Redis service. Backends and workers write durable records to the primary database. Only lag-tolerant reads go to read replicas; read-after-write and other freshness-critical reads use the primary. +- **Asynchronous jobs:** backend → durable job record/outbox → task queue → independent workers. Workers save status and results in shared storage; any authorized backend can read them. The job lifecycle below explains confirmation, retry and acknowledgement boundaries. +- **Replication:** primary database → read replica databases; Redis primary → Redis replica. These arrows copy data, not application requests. Asynchronous replication can lag and can lose recent writes on failover. +- **Redis failover:** backends use an ordinary Redis URL through a provider-managed failover endpoint, which routes application traffic to the current primary. Test connection recovery and the provider's persistence behavior during failover. + +## Serve requests with replaceable backends + +Host the exported frontend independently, for example on a CDN. Configure its API URL for the WebSocket-capable load balancer in front of the Python backends. Each region's replicas share that region's session Redis; durable records, files and jobs live outside individual processes and local disks. + +Start with deliberate process counts so adding replicas does not multiply oversized database or Redis connection pools. One Granian process per small instance is a measurement starting point: + +```bash +GRANIAN_WORKERS=1 reflex run --env prod \ + --backend-only --backend-port 8000 +``` + +This command assumes Granian. If the environment also includes Uvicorn and Gunicorn, set `REFLEX_USE_GRANIAN=true` explicitly. The deployment platform controls replica count. See [self-hosting](/docs/hosting/self-hosting/) for production commands and networking. + +More replicas do not shorten one oversized event or remove contention within one session. Paginate results, keep session state small, and measure handler duration separately from state acquisition. Use async database/API clients in [background events](/docs/events/background-events/), holding the state lock only briefly to update UI state. For unavoidable blocking I/O, use `await rx.run_in_thread(...)` from a background event; use process workers or a process executor for CPU-heavy Python work. See [performance and execution](/docs/advanced-onboarding/performance-and-execution/). + +## Let jobs outlive a backend + +A report export may take minutes. The UI needs a job ID and progress, while the worker needs to finish even if the accepting backend is scaled down. Use an independent queue/worker system such as [Taskiq](https://taskiq-python.github.io/guide/architecture-overview.html) or [Celery](https://docs.celeryq.dev/en/stable/userguide/tasks.html) for that requirement. Reflex background events run inside a backend and end with that process. + +```python eval +rx.el.figure( + rx.el.div( + rx.image( + src=rx.asset("hosting/scaling-job-lifecycle.svg"), + alt="Job lifecycle: commit a job and outbox, confirm queue publication, process idempotently, save results before acknowledgement, and recover through bounded retries. Text description follows.", + width="100%", + min_width="760px", + height="auto", + ), + overflow_x="auto", + width="100%", + tab_index=0, + role="region", + custom_attrs={ + "aria-label": "Job lifecycle; scroll horizontally on narrow screens" + }, + ), + rx.el.figcaption( + rx.el.a( + "Open job lifecycle diagram at full size", + href="/hosting/scaling-job-lifecycle.svg", + target="_blank", + rel="noopener noreferrer", + text_decoration="underline", + ), + ), + width="100%", + margin_y="1.5em", +) +``` + +### Job lifecycle text alternative + +1. **Accept and record.** Authorize the request, validate inputs, and commit a job record and enqueue intent together, such as an outbox entry in the same database transaction. Store a stable job ID, owner/tenant, input reference and status. A tenant-scoped idempotency key with a uniqueness constraint handles repeated submissions. +2. **Return and dispatch.** Return the job ID after the transaction commits. A dispatcher publishes pending outbox entries, marks them delivered only after broker confirmation, and retries with the same job ID. Monitor pending entries: an accepted record does not itself prove the broker received it. +3. **Process independently.** Workers load authorized inputs from shared storage. They must not need the original backend's memory, local files or WebSocket. A crash after publication can cause duplicate delivery, so worker effects must be idempotent or transactionally deduplicated by job ID. +4. **Save, then acknowledge.** Persist progress, completion/failure and result references in shared durable storage before acknowledging success to the broker. Keep large outputs in object storage. For external effects that cannot commit atomically with the outcome, use the service's idempotency support or reconciliation; there is no general exactly-once guarantee. +5. **Recover and read.** Configure broker acknowledgements and redelivery, timeouts and bounded retries. Expose permanent failures. Any healthy backend can authorize access and read the job by ID; reopening the app can recover outstanding jobs from durable records. + +A queue library alone does not guarantee durability. Select broker persistence and worker-loss behavior, then test them. Taskiq needs an actual result backend or application job store; Celery acknowledgement and worker-loss settings affect redelivery. Define result retention, backups and expiry independently of queue delivery. Avoid process-local queues and in-memory-only results for jobs that must survive scale-down. Scale workers by backlog and oldest-job age, independently of backend replicas. + +### Connect a report job to Reflex state + +The handler below awaits an application service outside the state lock, then applies its result only if the signed-in session and selected request are still current. `report_app.auth.AuthState` is your own authentication state, not a Reflex API: its backend-only `_owner_id` comes from verified authentication, and `_session_revision` changes on every login/logout, including signing back in as the same owner. Report selection is bound to that identity and cleared when it changes. Your logout handler also clears the visible report UI immediately. + +Supply asynchronous `report_app.reports` adapters backed by your database and broker: + +- `accept_report(owner, request_key, report)` validates access and inputs, atomically commits the job and outbox, and returns only after commit. Enforce `UNIQUE(owner, request_key)`: the same key and inputs return the existing job; the same key with different inputs is rejected. +- `read_report(owner, job_id, request_key)` reads from the **primary**, filtering by owner and the selected job ID, or by owner and request key when no ID exists. Only when both are empty does it recover that owner's latest job for this report type. Return `None` for no match, without falling back to an older job for a selected key. Both adapters return a record with `id`, `status` and `request_key`; service failures raise your `ReportServiceError`. + +```python +from uuid import uuid4 + +import reflex as rx +from report_app.auth import AuthState +from report_app.reports import ReportServiceError, accept_report, read_report + + +class ReportState(rx.State): + job_id: str = "" + status: str = "" + error: str = "" + _request_key: str = "" + _identity: tuple[str, int] = ("", 0) + _generation: int = 0 + + @rx.event + async def new_report(self): + auth = await self.get_state(AuthState) + self._identity = (auth._owner_id, auth._session_revision) + self._request_key = uuid4().hex + self._generation += 1 + self.job_id = self.status = self.error = "" + + @rx.event(background=True) + async def sync_report(self, submit: bool = False): + async with self: + auth = await self.get_state(AuthState) + identity = (auth._owner_id, auth._session_revision) + if identity != self._identity or not identity[0]: + self._identity = identity + self.job_id = self.status = self.error = self._request_key = "" + self._generation += 1 + generation = self._generation + if not identity[0]: + return + request_key, job_id = self._request_key, self.job_id + + job, error = None, "" + try: + if submit and request_key: + job = await accept_report(identity[0], request_key, "monthly-sales") + else: + job = await read_report(identity[0], job_id, request_key) + except ReportServiceError: + error = "Request not confirmed. Retry or refresh before starting anew." + + async with self: + auth = await self.get_state(AuthState) + if identity != (auth._owner_id, auth._session_revision): + return + if generation != self._generation: + return + self.error = error + if not error: + self.job_id = job.id if job else "" + self.status = job.status if job else "" + if job: + self._request_key = job.request_key + elif submit and not request_key: + self.error = "Choose New report to start a report." + + +def report_panel(): + return rx.vstack( + rx.button("New report", on_click=ReportState.new_report), + rx.button("Submit / retry", on_click=ReportState.sync_report(True)), + rx.button("Refresh / recover", on_click=ReportState.sync_report(False)), + rx.text(ReportState.job_id, ": ", ReportState.status), + rx.text(ReportState.error), + ) +``` + +Choose **New report**, then **Submit / retry** to start a report. Only **New report** creates a new request key. Without a selected key, **Submit / retry** only recovers an existing job; if none exists, it prompts you to choose **New report**. Recovered jobs keep their original key for retries. Run `ReportState.sync_report` on page load and after authentication changes to recover from durable records. A key held only in lost session state cannot deduplicate a new key. For multiple outstanding jobs, provide an authorized list/selection view instead of a latest-job lookup. + +These adapters and the worker must implement the lifecycle above; a background event alone does not make the job durable. + +## Keep regional state and durable data separate + +Keep frequent session-state access close to the backends: each region uses its own Redis session store, and active sessions are routed consistently to that region. Business records, preferences and jobs belong in stores with explicit write ownership, consistency, replication and recovery policies. + +Moving a user to another region may create a fresh UI session. Rebuild useful state from durable records rather than promising transfer of regional Redis state. For example, after an export starts in Region A, a backend in Region B can authorize the user and read its job/result without the original session. A globally reachable Redis endpoint alone does not make data durable; review its [persistence and recovery tradeoffs](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/). + +### Use the primary for writes and fresh reads + +Read replicas are useful when nearby, read-only copies reduce latency or primary load. Make routing explicit in the data-access layer: + +| Operation | Destination | +| --- | --- | +| Writes, migrations, locking reads and read-write transactions | Current primary; keep each transaction on its owning connection. | +| Read-after-write or other freshness-critical reads | Primary unless a replica is verified to have caught up. Confirm a newly submitted job here. | +| Read-only queries that tolerate replication lag | Healthy read replica; a locking `SELECT` is not eligible. | +| Replica unavailable or too far behind | Another healthy replica or bounded primary fallback, with capacity protection. | + +Test lag and outages before relying on regional routing. Never send writes to a replica outside a controlled promotion process. See [PostgreSQL replication tradeoffs](https://www.postgresql.org/docs/current/high-availability.html). + +### Add a separate cache only for a measured need + +For example, cache an expensive, repeatable lookup when bounded staleness is acceptable. Use a separate Redis service so cache churn, eviction and refill bursts do not compete with Reflex session state. Key prefixes or logical database numbers on one server do not isolate resources or failures. + +`REFLEX_REDIS_URL` configures Reflex state. Application caching uses your own client/configuration, with bounded TTLs, reused clients and limits on cache-miss database work. Keep cache clients outside serialized session state. No cache is required by this architecture. + +### Use a failover endpoint + +Configure `REFLEX_REDIS_URL` with an ordinary Redis URL for a provider-managed endpoint that handles failover. + +## Keep builds and state versions compatible + +Use `uv` or another locking package manager to freeze dependencies across builds. Replicas sharing a state store must run compatible application code and state schemas. + +- Compatible framework upgrades can preserve saved state; plan for session reset on downgrade/rollback and read the release notes for migration instructions. +- Changing stored base-variable names/types makes saved state incompatible. Changing defaults or method bodies does not change the saved-state schema hash. +- Rebuild and deploy the frontend alongside changes to fields, frontend computed-variable names/types or handler names. The frontend compatibility hash includes frontend computed variables and handler names; the saved-state schema hash does not. + +See [self-hosting compatibility guidance](/docs/hosting/self-hosting/) for release-specific migration constraints. + +## Use telemetry to choose the next change + +Enable the optional `reflex-otel` instrumentor with a compatible SDK/exporter and verify data reaches the collector. Trace points and metrics remain inactive until configured. Start with the question you need to answer: + +| Question | Measure and act | +| --- | --- | +| Is one handler slow, or is it waiting for state? | Compare event spans/`reflex.event.duration` with `reflex.state.acquire.duration`; shorten the handler or its lock-held work. | +| Are connections or payloads driving load? | Correlate `reflex.websocket.connections` and `reflex.websocket.message.size` with latency and replica count; reduce unnecessary events/payloads. | +| Is the report waiting or computing? | Measure queue wait and worker execution separately; increase worker capacity only when that is the bottleneck. | + +Use service, region, deployment version and database role to locate the slow boundary. Database queries, queue publication and external workers need their own instrumentation; propagate trace context through the broker. Reflex does not automatically create those external spans. Verify one event-to-worker-to-database trace, set sampling/redaction rules, and keep sensitive payloads and high-cardinality job IDs out of metric labels. See [Reflex OpenTelemetry](/docs/api-reference/observability/) and [context propagation](https://opentelemetry.io/docs/languages/python/propagation/). + +### Pin the telemetry combination + +| Instrumentor release | Declared dependency floors | +| --- | --- | +| `reflex-otel==0.2.0` | Python >=3.11; `reflex-base >=0.10.0`. | + +This release declares `opentelemetry-api >=1.30.0,<2.0` and instrumentation/ASGI instrumentation >=0.49b0,<1.0. These are dependency constraints, not certification of arbitrary SDK/exporter combinations. Lock the exact framework, instrumentor, SDK and exporter; verify backend and optional browser export together. + +## Validate recovery under load + +When scaling down, stop routing new connections to retiring backends, drain bounded work, and test reconnects to another healthy replica. Allow time for worker drain and recovery separately. There is no universal sessions-per-instance figure. + +Here are example validation tasks for the components you choose to deploy. Run realistic sessions in production mode and record p50/p95 latency, errors, resource use, event rate, payload sizes, instance/process counts, queue delay and job completion: + +- Add backends under load; check whether the database or Redis becomes the bottleneck. +- Remove the backend that accepted a report; reconnect elsewhere and retrieve the job/result. +- Restart a worker mid-job; verify redelivery/retry behavior and absence of duplicate side effects. +- Repeat a submission and interrupt queue publication; verify idempotency and recovery of pending outbox entries. +- Fail over the provider-managed Redis endpoint; exercise locks, active sessions and reconnects, and check which writes survive under its persistence policy. +- Move a user between regions; verify session reset, authorization and recovery of durable jobs. +- Delay or disable a read replica; verify primary routing for fresh reads and bounded fallback. + +### Turn Redis spikes into an action + +Compare session Redis and cache separately over the affected time window. Use provider metrics or bounded diagnostics such as `INFO memory`, `INFO clients`, `INFO commandstats` and `SLOWLOG GET 20` through authorized operator access. + +| Signal | Next action | +| --- | --- | +| Memory, key count or evictions growing | Reduce per-session datasets/history; bound cache TTLs and monitor state eviction. | +| CPU/commands rise with event or polling rate | Reduce duplicate events/polling and limit cache refill concurrency before adding more clients. | +| Connections rise after scaling | Check process counts, pool limits, client reuse and reconnect backoff. | +| Latency tracks long-held state locks | Move slow work to the background/worker path; increasing timeouts does not add throughput. | + +`SLOWLOG` excludes network latency and may expose private arguments; redact before sharing. A `noeviction` policy rejects new writes at the memory limit. See [Redis diagnostics](https://redis.io/docs/latest/commands/info/) and [eviction policies](https://redis.io/docs/latest/develop/reference/eviction/). diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 089a8d1ffda..bbe71041931 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -18,6 +18,9 @@ Use `reflex deploy` for the managed workflow. Follow this page when you need to - Moving from managed hosting also requires transferring persistent data, secrets, domains, and any external service configuration. - You operate the servers, TLS, monitoring, backups, and updates in a self-hosted environment. +For multiple backend replicas, regional state, durable jobs, and recovery tests, +see [Scaling Reflex Apps](/docs/hosting/scaling/). + ## Managed vs. self-hosted | Deployment choice | Managed (`reflex deploy`) | Self-hosted |