From fbdd04820b58e7b22e6432692b8eacf49a92a017 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 9 Aug 2023 00:32:38 -0700 Subject: [PATCH 1/8] v3.3.2 --- CHANGELOG.md | 6 +++ docs/python/settings.py | 4 +- src/reactpy_django/checks.py | 2 +- src/reactpy_django/websocket/consumer.py | 47 ++++++++++++------------ 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 060a9cfc..64a84c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ Using the following categories, list your changes in this order: - Nothing (yet)! +## [3.3.2] - 2023-08-09 + +### Changed + +- Changed implementation of `REACTPY_BACKHAUL_THREAD` to attempt increased performance compatibility. + ## [3.3.1] - 2023-08-08 ### Added diff --git a/docs/python/settings.py b/docs/python/settings.py index d5cfc4ae..c9a26f5a 100644 --- a/docs/python/settings.py +++ b/docs/python/settings.py @@ -27,6 +27,6 @@ # 3. Your Django user model does not define a `backend` attribute REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend" -# Whether to enable rendering ReactPy via a dedicated backhaul thread -# This allows the webserver to process traffic while during ReactPy rendering +# Whether to enable rendering ReactPy via a dedicated backhaul thread. +# This allows the webserver to process traffic while during ReactPy rendering. REACTPY_BACKHAUL_THREAD = False diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index b60be689..fc5a89a6 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -97,7 +97,7 @@ def reactpy_warnings(app_configs, **kwargs): ) # Check if REACTPY_WEBSOCKET_URL doesn't end with a slash - REACTPY_WEBSOCKET_URL = getattr(settings, "REACTPY_WEBSOCKET_URL", "") + REACTPY_WEBSOCKET_URL = getattr(settings, "REACTPY_WEBSOCKET_URL", "reactpy/") if isinstance(REACTPY_WEBSOCKET_URL, str): if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL.endswith("/"): warnings.append( diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 279d0f0d..7a999d26 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -5,7 +5,6 @@ import asyncio import contextlib import logging -from concurrent.futures import Future from datetime import timedelta from threading import Thread from typing import Any, MutableMapping, Sequence @@ -41,12 +40,12 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): async def connect(self) -> None: """The browser has connected.""" - from reactpy_django.config import REACTPY_AUTH_BACKEND, REACTPY_BACKHAUL_THREAD + from reactpy_django.config import REACTPY_AUTH_BACKEND await super().connect() # Authenticate the user, if possible - user: Any = self.scope.get("user") + user = self.scope.get("user") if user and user.is_authenticated: try: await login(self.scope, user, backend=REACTPY_AUTH_BACKEND) @@ -77,33 +76,36 @@ async def connect(self) -> None: ) # Start the component dispatcher - self.dispatcher: Future | asyncio.Task - self.threaded = REACTPY_BACKHAUL_THREAD - if self.threaded: - if not backhaul_thread.is_alive(): - await asyncio.to_thread( - _logger.debug, "Starting ReactPy backhaul thread." - ) - backhaul_thread.start() - self.dispatcher = asyncio.run_coroutine_threadsafe( - self.run_dispatcher(), backhaul_loop - ) - else: - self.dispatcher = asyncio.create_task(self.run_dispatcher()) + self.recv_queue: asyncio.Queue = asyncio.Queue() + self.dispatcher = asyncio.create_task(self.run_dispatcher()) async def disconnect(self, code: int) -> None: """The browser has disconnected.""" self.dispatcher.cancel() + await self.dispatcher await super().disconnect(code) async def receive_json(self, content: Any, **_) -> None: """Receive a message from the browser. Typically, messages are event signals.""" - if self.threaded: - asyncio.run_coroutine_threadsafe( - self.recv_queue.put(content), backhaul_loop - ) - else: - await self.recv_queue.put(content) + await self.recv_queue.put(content) + + async def dispatch(self, message): + """Override the Django Channels dispatch method to allow running the ASGI + dispatcher in a thread.""" + from reactpy_django.config import REACTPY_BACKHAUL_THREAD + + if REACTPY_BACKHAUL_THREAD: + if not backhaul_thread.is_alive(): + await asyncio.to_thread( + _logger.debug, "Starting ReactPy backhaul thread." + ) + backhaul_thread.start() + + return asyncio.run_coroutine_threadsafe( + super().dispatch(message), backhaul_loop + ).result() + + return await super().dispatch(message) async def run_dispatcher(self): """Runs the main loop that performs component rendering tasks.""" @@ -117,7 +119,6 @@ async def run_dispatcher(self): dotted_path = scope["url_route"]["kwargs"]["dotted_path"] uuid = scope["url_route"]["kwargs"]["uuid"] search = scope["query_string"].decode() - self.recv_queue: asyncio.Queue = asyncio.Queue() connection = Connection( # For `use_connection` scope=scope, location=Location( From 2a13180e5a143049ef64604b3458dda7b4652805 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 9 Aug 2023 00:37:45 -0700 Subject: [PATCH 2/8] add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a84c9e..444302b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ Using the following categories, list your changes in this order: - Changed implementation of `REACTPY_BACKHAUL_THREAD` to attempt increased performance compatibility. +### Fixed + +- Fix bug where `REACTPY_WEBSOCKET_URL` always generates a warning if unset. + ## [3.3.1] - 2023-08-08 ### Added From 6deefd437397a7c996338ea5bad18c9ec7f33edd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 9 Aug 2023 17:08:52 -0700 Subject: [PATCH 3/8] use orjson --- CHANGELOG.md | 4 ++++ requirements/pkg-deps.txt | 1 + src/reactpy_django/websocket/consumer.py | 9 +++++++++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 444302b9..9322b6cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Using the following categories, list your changes in this order: ## [3.3.2] - 2023-08-09 +### Added + +- ReactPy Websocket will now decode messages via `orjson` resulting in an ~6% overall performance boost. + ### Changed - Changed implementation of `REACTPY_BACKHAUL_THREAD` to attempt increased performance compatibility. diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 75758695..407659ca 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -3,4 +3,5 @@ django >=4.1.0 reactpy >=1.0.0, <1.1.0 aiofile >=3.0 dill >=0.3.5 +orjson >=3.0.0 typing_extensions diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 7a999d26..2d09eae1 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -10,6 +10,7 @@ from typing import Any, MutableMapping, Sequence import dill as pickle +import orjson from channels.auth import login from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer @@ -107,6 +108,14 @@ async def dispatch(self, message): return await super().dispatch(message) + @classmethod + async def decode_json(cls, text_data): + return orjson.loads(text_data) + + @classmethod + async def encode_json(cls, content): + return orjson.dumps(content).decode() + async def run_dispatcher(self): """Runs the main loop that performs component rendering tasks.""" from reactpy_django import models From 7ef4dbd5d7e89161ecc2c9f3969db0ea7daa75f8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 9 Aug 2023 17:52:59 -0700 Subject: [PATCH 4/8] add nest_asyncio --- CHANGELOG.md | 3 +++ requirements/pkg-deps.txt | 3 ++- src/reactpy_django/__init__.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9322b6cd..031fdabe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Using the following categories, list your changes in this order: ### Added - ReactPy Websocket will now decode messages via `orjson` resulting in an ~6% overall performance boost. +- Built-in asyncio event loops are now patched via `nest_asyncio` to be re-enterant, resulting in an ~10% overall performance boost. This has no performance impact if you are running your webserver with `uvloop`. ### Changed @@ -49,6 +50,8 @@ Using the following categories, list your changes in this order: ### Fixed - Fix bug where `REACTPY_WEBSOCKET_URL` always generates a warning if unset. +- Fixed bug where `assert f is self._write_fut` would be raised within Uvicorn on Windows when `REACTPY_BACKHAUL_THREAD = True`. +- Fixed bug where rendering behavior would be jittery with Daphne on Windows when `REACTPY_BACKHAUL_THREAD = True`. ## [3.3.1] - 2023-08-08 diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 407659ca..c5958398 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -3,5 +3,6 @@ django >=4.1.0 reactpy >=1.0.0, <1.1.0 aiofile >=3.0 dill >=0.3.5 -orjson >=3.0.0 +orjson >=3.6.0 +nest_asyncio >=1.5.0 typing_extensions diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index de84506e..61b19f47 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -1,3 +1,7 @@ +import contextlib + +import nest_asyncio + from reactpy_django import checks, components, decorators, hooks, types, utils from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH @@ -11,3 +15,9 @@ "utils", "checks", ] +# Built-in asyncio event loops can create `assert f is self._write_fut` exceptions +# while we are using our backhaul thread, so we use this patch to fix this. +# This also resolves jittery rendering behaviors within Daphne. Can be demonstrated +# using our "Renders Per Second" test page. +with contextlib.suppress(ValueError): + nest_asyncio.apply() From 53eee4f6a9ebc04e8d2edb79f30f44f9500ff2d2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 9 Aug 2023 18:10:25 -0700 Subject: [PATCH 5/8] add estimated minimum RPS calculation --- .../test_app/templates/events_renders_per_second.html | 11 +++++++++++ tests/test_app/templates/mixed_time_to_load.html | 11 +++++++++++ tests/test_app/templates/renders_per_second.html | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/tests/test_app/templates/events_renders_per_second.html b/tests/test_app/templates/events_renders_per_second.html index e2c779cb..cb1ed5c8 100644 --- a/tests/test_app/templates/events_renders_per_second.html +++ b/tests/test_app/templates/events_renders_per_second.html @@ -17,6 +17,7 @@

ReactPy Event Driven Renders Per Second Test Page

Total Active Components:

Time To Load:

Event Renders Per Second:

+

Event Renders Per Second (Estimated Minimum):

Average Round-Trip Time:

@@ -50,6 +51,16 @@

ReactPy Event Driven Renders Per Second Test Page

} document.getElementById("total-erps").textContent = totalEPS; + // Calculate Min RPS + let minRPS = 0; + for (let i = 0; i < elements.length; i++) { + let rpsValue = parseFloat(elements[i].getAttribute("data-erps")); + if (rpsValue < minRPS || minRPS == 0) { + minRPS = rpsValue; + } + } + document.getElementById("min-rps").textContent = minRPS * elements.length; + // Calculate Average Event Round-Trip Time document.getElementById("avg-event-rt").textContent = ((1000 / totalEPS) * elements.length).toFixed(4) + " ms"; diff --git a/tests/test_app/templates/mixed_time_to_load.html b/tests/test_app/templates/mixed_time_to_load.html index f4b73c58..3b23050c 100644 --- a/tests/test_app/templates/mixed_time_to_load.html +++ b/tests/test_app/templates/mixed_time_to_load.html @@ -17,6 +17,7 @@

ReactPy IO/CPU Mixed Renders Per Second Test Page

Total Active Components:

Time To Load:

Total Renders Per Second:

+

Total Renders Per Second (Estimated Minimum):