diff --git a/CHANGES.rst b/CHANGES.rst index a5fa63f14e..e3cd0d9968 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,8 @@ Version 3.2.0 Unreleased +- Optimize request dispatching by caching the result of ``ensure_sync`` for view + functions in ``_sync_view_functions``, reducing CPU overhead. :pr:`5939` - Drop support for Python 3.9. :pr:`5730` - Remove previously deprecated code: ``__version__``. :pr:`5648` - ``RequestContext`` has merged with ``AppContext``. ``RequestContext`` is now diff --git a/docs/async-await.rst b/docs/async-await.rst index bb3338029a..ddba602356 100644 --- a/docs/async-await.rst +++ b/docs/async-await.rst @@ -107,6 +107,10 @@ the decorated function, return wrapper +To improve performance, consider caching the result of ``ensure_sync`` if your +extension calls it frequently on the same function. This is how Flask internally +optimizes request dispatching. + Check the changelog of the extension you want to use to see if they've implemented async support, or make a feature request or PR to them. diff --git a/src/flask/app.py b/src/flask/app.py index 652b9bbf71..04e6e1a3aa 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -333,6 +333,8 @@ def __init__( root_path=root_path, ) + self._sync_view_functions: dict[str, t.Callable[..., t.Any]] = {} + #: The Click command group for registering CLI commands for this #: object. The commands are available from the ``flask`` command #: once the application has been discovered and blueprints have @@ -969,6 +971,10 @@ def dispatch_request(self, ctx: AppContext) -> ft.ResponseReturnValue: be a response object. In order to convert the return value to a proper response object, call :func:`make_response`. + .. versionchanged:: 3.2 + The result of ``ensure_sync`` is cached in ``_sync_view_functions`` + to improve performance. + .. versionchanged:: 0.7 This no longer does the exception handling, this code was moved to the new :meth:`full_dispatch_request`. @@ -987,7 +993,13 @@ def dispatch_request(self, ctx: AppContext) -> ft.ResponseReturnValue: return self.make_default_options_response(ctx) # otherwise dispatch to the handler for that endpoint view_args: dict[str, t.Any] = req.view_args # type: ignore[assignment] - return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return] + + if rule.endpoint not in self._sync_view_functions: + self._sync_view_functions[rule.endpoint] = self.ensure_sync( + self.view_functions[rule.endpoint] + ) + + return self._sync_view_functions[rule.endpoint](**view_args) # type: ignore[no-any-return] def full_dispatch_request(self, ctx: AppContext) -> Response: """Dispatches the request and on top of that performs request @@ -1069,6 +1081,10 @@ def ensure_sync(self, func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: Override this method to change how the app runs async views. + .. versionchanged:: 3.2 + The result of this method is cached during request dispatching + to improve performance. + .. versionadded:: 2.0 """ if iscoroutinefunction(func): diff --git a/tests/test_basic.py b/tests/test_basic.py index 1d9d83f8cb..9003133b45 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1968,3 +1968,39 @@ def test_app_freed_on_zero_refcount(): assert weak() is None finally: gc.enable() + + +def test_sync_view_functions_cache(app, client): + """Test that the _sync_view_functions cache is populated and used.""" + + @app.route("/test") + def test_view(): + return "Hello" + + import unittest.mock + + with unittest.mock.patch.object( + app, "ensure_sync", wraps=app.ensure_sync + ) as mock_ensure_sync: + # First request should call ensure_sync + response = client.get("/test") + assert response.status_code == 200 + assert mock_ensure_sync.call_count == 1 + + # Second request should hit the cache and not call ensure_sync + response = client.get("/test") + assert response.status_code == 200 + assert mock_ensure_sync.call_count == 1 + + # Direct mutation test (to verify the cache is bound to endpoint) + def new_view(): + return "World" + + # Simulating a user directly updating the view functions after setup + # Because it's already cached, this mutation won't affect _sync_view_functions + app.view_functions["test"] = new_view + + # Ensure that it still returns the old result due to the cache + response = client.get("/test") + assert response.status_code == 200 + assert response.data == b"Hello"