-
-
Notifications
You must be signed in to change notification settings - Fork 311
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[IMP] fastapi: add event loop lifecycle management
This commit adds event loop lifecycle management to the FastAPI dispatcher. Before this commit, an event loop and the thread to run it were created each time a FastAPI app was created. The drawback of this approach is that when the app was destroyed (for example, when the cache of app was cleared), the event loop and the thread were not properly stopped, which could lead to memory leaks and zombie threads. This commit fixes this issue by creating a pool of event loops and threads that are shared among all FastAPI apps. On each call to a FastAPI app, a event loop is requested from the pool and is returned to the pool when the app is destroyed. At request time of an event loop, the pool try to reuse an existing event loop and if no event loop is available, a new event loop is created. The cache of the FastAPI app is also refactored to use it's own mechanism. It's now based on a dictionary of queues by root path by database, where each queue is a pool of FastAPI app. This allows a better management of the invalidation of the cache. It's now possible to invalidate the cache of FastAPI app by root path without affecting the cache of others root paths.
- Loading branch information
Showing
6 changed files
with
159 additions
and
43 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
# Copyright 2025 ACSONE SA/NV | ||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). | ||
""" | ||
ASGI middleware for FastAPI. | ||
This module provides an ASGI middleware for FastAPI applications. The middleware | ||
is designed to ensure managed the lifecycle of the threads used to as event loop | ||
for the ASGI application. | ||
""" | ||
|
||
from typing import Iterable | ||
|
||
import a2wsgi | ||
from a2wsgi.asgi import ASGIResponder | ||
from a2wsgi.wsgi_typing import Environ, StartResponse | ||
|
||
from .pools import event_loop_pool | ||
|
||
|
||
class ASGIMiddleware(a2wsgi.ASGIMiddleware): | ||
def __call__( | ||
self, environ: Environ, start_response: StartResponse | ||
) -> Iterable[bytes]: | ||
with event_loop_pool.get_event_loop() as loop: | ||
return ASGIResponder(self.app, loop)(environ, start_response) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from .event_loop import EventLoopPool | ||
from .fastapi_app import FastApiAppPool | ||
|
||
event_loop_pool = EventLoopPool() | ||
fastapi_app_pool = FastApiAppPool() | ||
|
||
__all__ = ["event_loop_pool", "fastapi_app_pool"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
# Copyright 2025 ACSONE SA/NV | ||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). | ||
|
||
import asyncio | ||
import queue | ||
import threading | ||
from contextlib import contextmanager | ||
from typing import Generator | ||
|
||
|
||
class EventLoopPool: | ||
def __init__(self): | ||
self.pool = queue.Queue[tuple[asyncio.AbstractEventLoop, threading.Thread]]() | ||
|
||
def __get_event_loop_and_thread( | ||
self, | ||
) -> tuple[asyncio.AbstractEventLoop, threading.Thread]: | ||
""" | ||
Get an event loop from the pool. If no event loop is available, create a new one. | ||
""" | ||
try: | ||
return self.pool.get_nowait() | ||
except queue.Empty: | ||
loop = asyncio.new_event_loop() | ||
thread = threading.Thread(target=loop.run_forever, daemon=True) | ||
thread.start() | ||
return loop, thread | ||
|
||
def __return_event_loop( | ||
self, loop: asyncio.AbstractEventLoop, thread: threading.Thread | ||
) -> None: | ||
""" | ||
Return an event loop to the pool for reuse. | ||
""" | ||
self.pool.put((loop, thread)) | ||
|
||
def shutdown(self): | ||
""" | ||
Shutdown all event loop threads in the pool. | ||
""" | ||
while not self.pool.empty(): | ||
loop, thread = self.pool.get_nowait() | ||
loop.call_soon_threadsafe(loop.stop) | ||
thread.join() | ||
loop.close() | ||
|
||
@contextmanager | ||
def get_event_loop(self) -> Generator[asyncio.AbstractEventLoop, None, None]: | ||
""" | ||
Get an event loop from the pool. If no event loop is available, create a new one. | ||
After the context manager exits, the event loop is returned to the pool for reuse. | ||
""" | ||
loop, thread = self.__get_event_loop_and_thread() | ||
try: | ||
yield loop | ||
finally: | ||
self.__return_event_loop(loop, thread) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
# Copyright 2025 ACSONE SA/NV | ||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). | ||
|
||
import queue | ||
from collections import defaultdict | ||
from contextlib import contextmanager | ||
from typing import Generator | ||
|
||
from odoo.api import Environment | ||
|
||
from fastapi import FastAPI | ||
|
||
|
||
class FastApiAppPool: | ||
def __init__(self): | ||
self._queue_by_db_by_root_path: dict[ | ||
str, dict[str, queue.Queue[FastAPI]] | ||
] = defaultdict(lambda: defaultdict(queue.Queue)) | ||
|
||
def __get_app(self, env: Environment, root_path: str) -> FastAPI: | ||
db_name = env.cr.dbname | ||
try: | ||
return self._queue_by_db_by_root_path[db_name][root_path].get_nowait() | ||
except queue.Empty: | ||
env["fastapi.endpoint"].sudo() | ||
return env["fastapi.endpoint"].sudo().get_app(root_path) | ||
|
||
def __return_app(self, env: Environment, app: FastAPI, root_path: str) -> None: | ||
db_name = env.cr.dbname | ||
self._queue_by_db_by_root_path[db_name][root_path].put(app) | ||
|
||
@contextmanager | ||
def get_app( | ||
self, root_path: str, env: Environment | ||
) -> Generator[FastAPI, None, None]: | ||
"""Return a FastAPI app to be used in a context manager. | ||
The app is retrieved from the pool if available, otherwise a new one is created. | ||
The app is returned to the pool after the context manager exits. | ||
When used into the FastApiDispatcher class this ensures that the app is reused | ||
across multiple requests but only one request at a time uses an app. | ||
""" | ||
app = self.__get_app(env, root_path) | ||
try: | ||
yield app | ||
finally: | ||
self.__return_app(env, app, root_path) | ||
|
||
def invalidate(self, root_path: str, env: Environment) -> None: | ||
db_name = env.cr.dbname | ||
self._queue_by_db_by_root_path[db_name][root_path] = queue.Queue() | ||