Skip to content

Commit fea0cef

Browse files
committed
[FIX] fastapi: Forwardport 16.0 pullrequest 486 - Avoid zombie threads
[FIX] Linting
1 parent c54a1e7 commit fea0cef

6 files changed

Lines changed: 247 additions & 21 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):
@@ -24,18 +25,17 @@ def dispatch(self, endpoint, args):
2425
root_path = "/" + environ["PATH_INFO"].split("/")[1]
2526
# TODO store the env into contextvar to be used by the odoo_env
2627
# depends method
27-
fastapi_endpoint = self.request.env["fastapi.endpoint"].sudo()
28-
app = fastapi_endpoint.get_app(root_path)
29-
uid = fastapi_endpoint.get_uid(root_path)
30-
data = BytesIO()
31-
with self._manage_odoo_env(uid):
32-
for r in app(environ, self._make_response):
33-
data.write(r)
34-
if self.inner_exception:
35-
raise self.inner_exception
36-
return self.request.make_response(
37-
data.getvalue(), headers=self.headers, status=self.status
38-
)
28+
with fastapi_app_pool.get_app(env=request.env, root_path=root_path) as app:
29+
uid = request.env["fastapi.endpoint"].sudo().get_uid(root_path)
30+
data = BytesIO()
31+
with self._manage_odoo_env(uid):
32+
for r in app(environ, self._make_response):
33+
data.write(r)
34+
if self.inner_exception:
35+
raise self.inner_exception
36+
return self.request.make_response(
37+
data.getvalue(), headers=self.headers, status=self.status
38+
)
3939

4040
def handle_error(self, exc):
4141
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: 14 additions & 9 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
@@ -199,14 +199,19 @@ def _endpoint_registry_route_unique_key(self, routing: dict[str, Any]):
199199
return f"{self._name}:{self.id}:{path}"
200200

201201
def _reset_app(self):
202-
self.env.registry.clear_cache()
202+
self._reset_app_cache_marker.clear_cache(self)
203+
204+
@tools.ormcache()
205+
def _reset_app_cache_marker(self):
206+
"""This methos is used to get a way to mark the orm cache as dirty
207+
when the app is reset. By marking the cache as dirty, the system
208+
will signal to others instances that the cache is not up to date
209+
and that they should invalidate their cache as well. This is required
210+
to ensure that any change requiring a reset of the app is propagated
211+
to all the running instances.
212+
"""
203213

204214
@api.model
205-
@tools.ormcache("root_path")
206-
# TODO cache on thread local by db to enable to get 1 middelware by
207-
# thread when odoo runs in multi threads mode and to allows invalidate
208-
# specific entries in place og the overall cache as we have to do into
209-
# the _rest_app method
210215
def get_app(self, root_path):
211216
record = self.search([("root_path", "=", root_path)])
212217
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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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. After the context manager exits, the event loop
53+
is returned to the pool for reuse.
54+
"""
55+
loop, thread = self.__get_event_loop_and_thread()
56+
try:
57+
yield loop
58+
finally:
59+
self.__return_event_loop(loop, thread)

fastapi/pools/fastapi_app.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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[str, dict[str, queue.Queue[FastAPI]]] = (
58+
defaultdict(lambda: defaultdict(queue.Queue))
59+
)
60+
self.__cache_sequences = {}
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+
env["fastapi.endpoint"].sudo()
73+
return env["fastapi.endpoint"].sudo().get_app(root_path)
74+
75+
def __return_app(self, env: Environment, app: FastAPI, root_path: str) -> None:
76+
pool = self.__get_pool(env, root_path)
77+
pool.put(app)
78+
79+
@contextmanager
80+
def get_app(
81+
self, env: Environment, root_path: str
82+
) -> Generator[FastAPI, None, None]:
83+
"""Return a FastAPI app to be used in a context manager.
84+
The app is retrieved from the pool if available, otherwise a new one is created.
85+
The app is returned to the pool after the context manager exits.
86+
When used into the FastApiDispatcher class this ensures that the app is reused
87+
across multiple requests but only one request at a time uses an app.
88+
"""
89+
self._check_cache(env)
90+
app = self.__get_app(env, root_path)
91+
try:
92+
yield app
93+
finally:
94+
self.__return_app(env, app, root_path)
95+
96+
def get_cache_sequence(self, key: str) -> int:
97+
with self._lock:
98+
return self.__cache_sequences.get(key, 0)
99+
100+
def set_cache_sequence(self, key: str, value: int) -> None:
101+
with self._lock:
102+
if (
103+
key not in self.__cache_sequences
104+
or value != self.__cache_sequences[key]
105+
):
106+
self.__cache_sequences[key] = value
107+
108+
def _check_cache(self, env: Environment) -> None:
109+
cache_sequences = env.registry.cache_sequences
110+
for key, value in cache_sequences.items():
111+
if (
112+
value != self.get_cache_sequence(key)
113+
and self.get_cache_sequence(key) != 0
114+
):
115+
_logger.info(
116+
"Cache registry updated, reset fastapi_app pool for the current "
117+
"database"
118+
)
119+
self.invalidate(env)
120+
self.set_cache_sequence(key, value)
121+
122+
def invalidate(self, env: Environment, root_path: str | None = None) -> None:
123+
db_name = env.cr.dbname
124+
if root_path:
125+
self._queue_by_db_by_root_path[db_name][root_path] = queue.Queue()
126+
elif db_name in self._queue_by_db_by_root_path:
127+
del self._queue_by_db_by_root_path[db_name]

0 commit comments

Comments
 (0)