Skip to content

Commit ad268a9

Browse files
committed
[17.0][FIX] fastapi: Forwardport 16.0 pullrequest 486 - Avoid zombie threads
1 parent ce1db45 commit ad268a9

6 files changed

Lines changed: 238 additions & 20 deletions

File tree

fastapi/fastapi_dispatcher.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from .context import odoo_env_ctx
1010
from .error_handlers import convert_exception_to_status_body
11+
from .pools import fastapi_app_pool
1112

1213

1314
class FastApiDispatcher(Dispatcher):
@@ -29,18 +30,17 @@ def dispatch(self, endpoint, args):
2930
root_path = "/" + environ["PATH_INFO"].split("/")[1]
3031
# TODO store the env into contextvar to be used by the odoo_env
3132
# depends method
32-
fastapi_endpoint = self.request.env["fastapi.endpoint"].sudo()
33-
app = fastapi_endpoint.get_app(root_path)
34-
uid = fastapi_endpoint.get_uid(root_path)
35-
data = BytesIO()
36-
with self._manage_odoo_env(uid):
37-
for r in app(environ, self._make_response):
38-
data.write(r)
39-
if self.inner_exception:
40-
raise self.inner_exception
41-
return self.request.make_response(
42-
data.getvalue(), headers=self.headers, status=self.status
43-
)
33+
with fastapi_app_pool.get_app(env=request.env, root_path=root_path) as app:
34+
uid = request.env["fastapi.endpoint"].sudo().get_uid(root_path)
35+
data = BytesIO()
36+
with self._manage_odoo_env(uid):
37+
for r in app(environ, self._make_response):
38+
data.write(r)
39+
if self.inner_exception:
40+
raise self.inner_exception
41+
return self.request.make_response(
42+
data.getvalue(), headers=self.headers, status=self.status
43+
)
4444

4545
def handle_error(self, exc):
4646
headers = getattr(exc, "headers", None)

fastapi/middleware.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2025 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
"""
4+
ASGI middleware for FastAPI.
5+
This module provides an ASGI middleware for FastAPI applications. The middleware
6+
is designed to ensure managed the lifecycle of the threads used to as event loop
7+
for the ASGI application.
8+
"""
9+
10+
from collections.abc import Iterable
11+
12+
import a2wsgi
13+
from a2wsgi.asgi import ASGIResponder
14+
from a2wsgi.wsgi_typing import Environ, StartResponse
15+
16+
from .pools import event_loop_pool
17+
18+
19+
class ASGIMiddleware(a2wsgi.ASGIMiddleware):
20+
def __call__(
21+
self, environ: Environ, start_response: StartResponse
22+
) -> Iterable[bytes]:
23+
with event_loop_pool.get_event_loop() as loop:
24+
return ASGIResponder(self.app, loop)(environ, start_response)

fastapi/models/fastapi_endpoint.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from itertools import chain
88
from typing import Any
99

10-
from a2wsgi import ASGIMiddleware
1110
from starlette.middleware import Middleware
1211
from starlette.routing import Mount
1312

@@ -16,6 +15,7 @@
1615
from fastapi import APIRouter, Depends, FastAPI
1716

1817
from .. import dependencies
18+
from ..middleware import ASGIMiddleware
1919

2020
_logger = logging.getLogger(__name__)
2121

@@ -121,10 +121,10 @@ def _registered_endpoint_rule_keys(self):
121121
return tuple(res)
122122

123123
@api.model
124-
def _routing_impacting_fields(self) -> tuple[str]:
124+
def _routing_impacting_fields(self) -> tuple[str, ...]:
125125
"""The list of fields requiring to refresh the mount point of the pp
126126
into odoo if modified"""
127-
return ("root_path",)
127+
return ("root_path", "save_http_session")
128128

129129
#
130130
# end of endpoint.route.sync.mixin methods implementation
@@ -198,14 +198,14 @@ def _endpoint_registry_route_unique_key(self, routing: dict[str, Any]):
198198
return f"{self._name}:{self.id}:{path}"
199199

200200
def _reset_app(self):
201+
"""When the app is reset we clear the cache, the system will signal to
202+
others instances that the cache is not up to date and that they should
203+
invalidate their cache as well. This is required to ensure that any change
204+
requiring a reset of the app is propagated to all the running instances.
205+
"""
201206
self.env.registry.clear_cache()
202207

203208
@api.model
204-
@tools.ormcache("root_path")
205-
# TODO cache on thread local by db to enable to get 1 middelware by
206-
# thread when odoo runs in multi threads mode and to allows invalidate
207-
# specific entries in place og the overall cache as we have to do into
208-
# the _rest_app method
209209
def get_app(self, root_path):
210210
record = self.search([("root_path", "=", root_path)])
211211
if not record:

fastapi/pools/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .event_loop import EventLoopPool
2+
from .fastapi_app import FastApiAppPool
3+
from odoo.service.server import CommonServer
4+
5+
event_loop_pool = EventLoopPool()
6+
fastapi_app_pool = FastApiAppPool()
7+
8+
9+
CommonServer.on_stop(event_loop_pool.shutdown)
10+
11+
__all__ = ["event_loop_pool", "fastapi_app_pool"]

fastapi/pools/event_loop.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2025 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
4+
import asyncio
5+
import queue
6+
import threading
7+
from collections.abc import Generator
8+
from contextlib import contextmanager
9+
10+
11+
class EventLoopPool:
12+
def __init__(self):
13+
self.pool = queue.Queue[tuple[asyncio.AbstractEventLoop, threading.Thread]]()
14+
15+
def __get_event_loop_and_thread(
16+
self,
17+
) -> tuple[asyncio.AbstractEventLoop, threading.Thread]:
18+
"""
19+
Get an event loop from the pool. If no event loop is available,
20+
create a new one.
21+
"""
22+
try:
23+
return self.pool.get_nowait()
24+
except queue.Empty:
25+
loop = asyncio.new_event_loop()
26+
thread = threading.Thread(target=loop.run_forever, daemon=True)
27+
thread.start()
28+
return loop, thread
29+
30+
def __return_event_loop(
31+
self, loop: asyncio.AbstractEventLoop, thread: threading.Thread
32+
) -> None:
33+
"""
34+
Return an event loop to the pool for reuse.
35+
"""
36+
self.pool.put((loop, thread))
37+
38+
def shutdown(self):
39+
"""
40+
Shutdown all event loop threads in the pool.
41+
"""
42+
while not self.pool.empty():
43+
loop, thread = self.pool.get_nowait()
44+
loop.call_soon_threadsafe(loop.stop)
45+
thread.join()
46+
loop.close()
47+
48+
@contextmanager
49+
def get_event_loop(self) -> Generator[asyncio.AbstractEventLoop, None, None]:
50+
"""
51+
Get an event loop from the pool. If no event loop is available,
52+
create a new one.
53+
54+
After the context manager exits, the event loop is returned to
55+
the pool for reuse.
56+
"""
57+
loop, thread = self.__get_event_loop_and_thread()
58+
try:
59+
yield loop
60+
finally:
61+
self.__return_event_loop(loop, thread)

fastapi/pools/fastapi_app.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright 2025 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
import logging
4+
import queue
5+
import threading
6+
from collections import defaultdict
7+
from collections.abc import Generator
8+
from contextlib import contextmanager
9+
10+
from odoo.api import Environment
11+
12+
from fastapi import FastAPI
13+
14+
_logger = logging.getLogger(__name__)
15+
16+
17+
class FastApiAppPool:
18+
"""Pool of FastAPI apps.
19+
This class manages a pool of FastAPI apps. The pool is organized by database name
20+
and root path. Each pool is a queue of FastAPI apps.
21+
The pool is used to reuse FastAPI apps across multiple requests. This is useful
22+
to avoid the overhead of creating a new FastAPI app for each request. The pool
23+
ensures that only one request at a time uses an app.
24+
The proper way to use the pool is to use the get_app method as a context manager.
25+
This ensures that the app is returned to the pool after the context manager exits.
26+
The get_app method is designed to ensure that the app made available to the
27+
caller is unique and not used by another caller at the same time.
28+
.. code-block:: python
29+
with fastapi_app_pool.get_app(env=request.env, root_path=root_path) as app:
30+
# use the app
31+
The pool is invalidated when the cache registry is updated. This ensures that
32+
the pool is always up-to-date with the latest app configuration. It also
33+
ensures that the invalidation is done even in the case of a modification occurring
34+
in a different worker process or thread or server instance. This mechanism
35+
works because every time an attribute of the fastapi.endpoint model is modified
36+
and this attribute is part of the list returned by the `_fastapi_app_fields`,
37+
or `_routing_impacting_fields` methods, we reset the cache of a marker method
38+
`_reset_app_cache_marker`. As side effect, the cache registry is marked to be
39+
updated by the increment of the `cache_sequence` SQL sequence. This cache sequence
40+
on the registry is reloaded from the DB on each request made to a specific database.
41+
When an app is retrieved from the pool, we always compare the cache sequence of
42+
the pool with the cache sequence of the registry. If the two sequences are
43+
different, we invalidate the pool and save the new cache sequence on the pool.
44+
The cache is based on a defaultdict of defaultdict of queue.Queue. We are cautious
45+
that the use of defaultdict is not thread-safe for operations that modify the
46+
dictionary. However the only operation that modifies the dictionary is the
47+
first access to a new key. If two threads access the same key at the same time,
48+
the two threads will create two different queues. This is not a problem since
49+
at the time of returning an app to the pool, we are sure that a queue exists
50+
for the key into the cache and all the created apps are returned to the same
51+
valid queue. And the end, the lack of thread-safety for the defaultdict could
52+
only lead to a negligible overhead of creating a new queue that will never be
53+
used. This is why we consider that the use of defaultdict is safe in this context.
54+
"""
55+
56+
def __init__(self):
57+
self._queue_by_db_by_root_path: dict[
58+
str, dict[str, queue.Queue[FastAPI]]
59+
] = defaultdict(lambda: defaultdict(queue.Queue))
60+
self.__cache_sequence = 0
61+
self._lock = threading.Lock()
62+
63+
def __get_pool(self, env: Environment, root_path: str) -> queue.Queue[FastAPI]:
64+
db_name = env.cr.dbname
65+
return self._queue_by_db_by_root_path[db_name][root_path]
66+
67+
def __get_app(self, env: Environment, root_path: str) -> FastAPI:
68+
pool = self.__get_pool(env, root_path)
69+
try:
70+
return pool.get_nowait()
71+
except queue.Empty:
72+
return env["fastapi.endpoint"].sudo().get_app(root_path)
73+
74+
def __return_app(self, env: Environment, app: FastAPI, root_path: str) -> None:
75+
pool = self.__get_pool(env, root_path)
76+
pool.put(app)
77+
78+
@contextmanager
79+
def get_app(
80+
self, env: Environment, root_path: str
81+
) -> Generator[FastAPI, None, None]:
82+
"""Return a FastAPI app to be used in a context manager.
83+
The app is retrieved from the pool if available, otherwise a new one is created.
84+
The app is returned to the pool after the context manager exits.
85+
When used into the FastApiDispatcher class this ensures that the app is reused
86+
across multiple requests but only one request at a time uses an app.
87+
"""
88+
self._check_cache(env)
89+
app = self.__get_app(env, root_path)
90+
try:
91+
yield app
92+
finally:
93+
self.__return_app(env, app, root_path)
94+
95+
@property
96+
def cache_sequence(self) -> int:
97+
return self.__cache_sequence
98+
99+
@cache_sequence.setter
100+
def cache_sequence(self, value: int) -> None:
101+
if value != self.__cache_sequence:
102+
with self._lock:
103+
self.__cache_sequence = value
104+
105+
def _check_cache(self, env: Environment) -> None:
106+
cache_sequence = env.registry.cache_sequence
107+
if cache_sequence != self.cache_sequence and self.cache_sequence != 0:
108+
_logger.info(
109+
"Cache registry updated, reset fastapi_app pool for the current "
110+
"database"
111+
)
112+
self.invalidate(env)
113+
114+
self.cache_sequence = cache_sequence
115+
116+
def invalidate(self, env: Environment, root_path: str | None = None) -> None:
117+
db_name = env.cr.dbname
118+
119+
if root_path:
120+
self._queue_by_db_by_root_path[db_name][root_path] = queue.Queue()
121+
elif db_name in self._queue_by_db_by_root_path:
122+
del self._queue_by_db_by_root_path[db_name]

0 commit comments

Comments
 (0)