Skip to content

Commit 13166a3

Browse files
reflect oca changes
1 parent 6980fed commit 13166a3

File tree

9 files changed

+427
-165
lines changed

9 files changed

+427
-165
lines changed

fastapi/README.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
15
============
26
Odoo FastAPI
37
============
@@ -7,13 +11,13 @@ Odoo FastAPI
711
!! This file is generated by oca-gen-addon-readme !!
812
!! changes will be overwritten. !!
913
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10-
!! source digest: sha256:1fafbce70d6ff751906e05186e5f47ec4917939f5bcc130c272e3e998228ca87
14+
!! source digest: sha256:131313b5c1951c5bc88afff7454fce8fee59b641e2c9fa58a9549a31dc70c74c
1115
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1216
1317
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
1418
:target: https://odoo-community.org/page/development-status
1519
:alt: Beta
16-
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
20+
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
1721
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
1822
:alt: License: LGPL-3
1923
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github

fastapi/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"name": "Odoo FastAPI",
66
"summary": """
77
Odoo FastAPI endpoint""",
8-
"version": "18.0.1.0.0",
8+
"version": "18.0.1.2.0",
99
"license": "LGPL-3",
1010
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
1111
"maintainers": ["lmignon"],

fastapi/fastapi_dispatcher.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@
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):
1415
routing_type = "fastapi"
1516

17+
def __init__(self, request):
18+
super().__init__(request)
19+
# Store exception to later raise it in the dispatch method if needed
20+
self.inner_exception = None
21+
1622
@classmethod
1723
def is_compatible_with(cls, request):
1824
return True
@@ -24,18 +30,17 @@ def dispatch(self, endpoint, args):
2430
root_path = "/" + environ["PATH_INFO"].split("/")[1]
2531
# TODO store the env into contextvar to be used by the odoo_env
2632
# 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-
)
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+
)
3944

4045
def handle_error(self, exc):
4146
headers = getattr(exc, "headers", None)
@@ -46,7 +51,7 @@ def handle_error(self, exc):
4651

4752
def _make_response(self, status_mapping, headers_tuple, content):
4853
self.status = status_mapping[:3]
49-
self.headers = dict(headers_tuple)
54+
self.headers = headers_tuple
5055
self.inner_exception = None
5156
# in case of exception, the method asgi_done_callback of the
5257
# ASGIResponder will trigger an "a2wsgi.error" event with the exception

fastapi/middleware.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
6+
This module provides an ASGI middleware for FastAPI applications. The middleware
7+
is designed to ensure managed the lifecycle of the threads used to as event loop
8+
for the ASGI application.
9+
10+
"""
11+
12+
from collections.abc import Iterable
13+
14+
import a2wsgi
15+
from a2wsgi.asgi import ASGIResponder
16+
from a2wsgi.asgi_typing import ASGIApp
17+
from a2wsgi.wsgi_typing import Environ, StartResponse
18+
19+
from .pools import event_loop_pool
20+
21+
22+
class ASGIMiddleware(a2wsgi.ASGIMiddleware):
23+
def __init__(
24+
self,
25+
app: ASGIApp,
26+
wait_time: float | None = None,
27+
) -> None:
28+
# We don't want to use the default event loop policy
29+
# because we want to manage the event loop ourselves
30+
# using the event loop pool.
31+
# Since the the base class check if the given loop is
32+
# None, we can pass False to avoid the initialization
33+
# of the default event loop
34+
super().__init__(app, wait_time, False)
35+
36+
def __call__(
37+
self, environ: Environ, start_response: StartResponse
38+
) -> Iterable[bytes]:
39+
with event_loop_pool.get_event_loop() as loop:
40+
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
@@ -199,14 +199,14 @@ 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+
"""When the app is reset we clear the cache, the system will signal to
203+
others instances that the cache is not up to date and that they should
204+
invalidate their cache as well. This is required to ensure that any change
205+
requiring a reset of the app is propagated to all the running instances.
206+
"""
202207
self.env.registry.clear_cache()
203208

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

0 commit comments

Comments
 (0)