This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Context vars set in an async dependency are visible for sync routes but not for sync functions run inside ThreadPoolExecutor #2776
Comments
This is a known Python issue rather than FastAPI. See: python/cpython#9688 I'd love to open PR to fix this on our side. For example this implementation of ThreadPoolExecutor fixes your case. from concurrent.futures import Future, ThreadPoolExecutor
import functools
import contextvars
from typing import Callable, Future
class ContextSafeThreadPoolExecutor(ThreadPoolExecutor):
def submit(self, fn: Callable, *args, **kwargs) -> Future:
ctx = contextvars.copy_context()
return super().submit(functools.partial(ctx.run, functools.partial(fn, *args, **kwargs))) |
Hey, thanks for the insight! As far as I can tell, the linked ticket and (the somewhat confusing) discussion is about I mean, if I'm using a sync route, FastAPI/starlette already spawns a thread. Context var, set in async dependency, is visible within that thread ( But, maybe you just meant that if the linked fix ever gets merged, then we could use However, we would still need the sub-classed
What's the API / utility function that you'd add? |
The problem here is, like you mentioned a thread is already spawned, you are creating another Thread inside it. In here, when you spawn another thread inside a thread, you are doing something like this: As i shown in the diagram, the auth coroutine never changes your original context. asyncio is natively context-safe, but threads are not. Since Python 3.9 we have async def to_thread(func, /, *args, **kwargs):
"""Asynchronously run function *func* in a separate thread.
Any *args and **kwargs supplied for this function are directly passed
to *func*. Also, the current :class:`contextvars.Context` is propogated,
allowing context variables from the main thread to be accessed in the
separate thread.
Return a coroutine that can be awaited to get the eventual result of *func*.
"""
loop = events.get_running_loop()
ctx = contextvars.copy_context()
func_call = functools.partial(ctx.run, func, *args, **kwargs)
return await loop.run_in_executor(None, func_call) Which is pretty similar to what Starlette doing right now: async def run_in_threadpool(
func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
) -> typing.Any:
loop = asyncio.get_event_loop()
if contextvars is not None: # pragma: no cover
# Ensure we run in the same context
child = functools.partial(func, *args, **kwargs)
context = contextvars.copy_context() # <------------------ here
func = context.run
args = (child,)
elif kwargs: # pragma: no cover
# loop.run_in_executor doesn't accept 'kwargs', so bind them in here
func = functools.partial(func, **kwargs)
return await loop.run_in_executor(None, func, *args) So the Starlette done his part right so far. They can't do better than that (I believe)
So coming back to this again, currently you can not retain the context that changes inside of Run in executor basically wraps the function and submit's it to the executor using executor.submit. Which also looks like this. return futures.wrap_future(executor.submit(runner), loop=self)
No, if this gets merged (I don't think this will happen in near future.), we won't need to subclass it anymore, in depth, the threads have their own stack, when you run a function with
This is the most interesting part ( Let's start with: Bugs Python Issue 34014:
I'm not sure about we can do something about this, all we are doing is copying the context that is at global state and sharing it between the states. But in your example, you are creating a new Subclass of Executor which is Someone said in the earlier discussion at #953 "logically dependency should have the same context as route handler.". It is also not right. This will work, or without making this coroutine will work ( @app.get('/sync1', dependencies=[Depends(auth)])
async def sync1():
return {'hello':sync_task()} But we can do nothing about the code below, this is completely up to Python interpreter. with ThreadPoolExecutor() as tp:
return {'hello': tp.submit(sync_task).result()} As far as i seen from the discussions this is also a wont-fix feature request and there are plently amount of reasons that makes this comprehensible. Yury Selivanov said:
|
Impressive diagram! :) Thanks for sketching that out. I would have placed Event Loop directly under Uvicorn/FastAPI but you clearly know this better. Actually, pardon me if I got something wrong, as I don't know this stuff by heart: In short, if I understand right, None of these actually retain the changes made to context vars within. Which is not the topic here, as the problem is seeing the context vars. Propagating back, is indeed #953. However, these are solutions for spawning threads from event loop (async) context, where you |
That's right, that part is not clear enough, but as you mentioned it uses
It is actually the same thing, but unlike try:
import contextvars # Python 3.7+ only or via contextvars backport.
except ImportError: # pragma: no cover
contextvars = None # type: ignore
Exactly, in your case if anything is going to change, it should be in standard library.
Yup, i'd love to see if any other solutions are available because i had a similar situation before and this is what we came up with.
Yup, Django's That also being said, keep this one open, since the context and use case is different from #953 |
Additionally, it seems that async def get_db():
token = DB.set(42)
try:
yield
finally:
DB.reset(token) Sprinkling in
task1 details:
task2 details:
|
We now depend on anyio, and the last release (3.4.0) should have fixed this (even if the original issue was without anyio). |
Thanks for the discussion everyone! As @Kludex says, indeed, the original issue should be solved by the new AnyIO version. It is discussed here: agronholm/anyio#363 , the fix was here: agronholm/anyio#390 About @dimaqq's comment, that's another independent issue that I've been working on for several days. 😅 It is fixed by the just released FastAPI More details in the PR: #4575 |
Quickly glancing, would it be so that the AnyIO improvement also fixes #953? |
Hi, I reran the test @tuukkamustonen sent in the OP with a minimal modification to run it using pytest and it still fails. from concurrent.futures.thread import ThreadPoolExecutor
from contextvars import ContextVar
from fastapi import FastAPI
from fastapi.params import Security
from fastapi.testclient import TestClient
ctx = ContextVar('', default='N/A')
app = FastAPI()
async def auth():
ctx.set('test')
def sync_task():
return ctx.get()
@app.get('/sync1', dependencies=[Security(auth)])
def sync1():
with ThreadPoolExecutor() as tp:
result = tp.submit(sync_task).result()
return {'hello': result}
client = TestClient(app)
def test_context_var_propagation():
response = client.get("/sync1")
assert response.status_code == 200
assert response.json() == {"hello": "test"}
I was able to make the test pass by using a ThreadPoolExecutor's initializer as a workaround: from concurrent.futures.thread import ThreadPoolExecutor
from contextvars import ContextVar
from fastapi import FastAPI
from fastapi.params import Security
from fastapi.testclient import TestClient
ctx = ContextVar('', default='N/A')
app = FastAPI()
async def auth():
ctx.set('test')
def sync_task():
return ctx.get()
@app.get('/sync1', dependencies=[Security(auth)])
def sync1():
current_ctx = ctx.get()
with ThreadPoolExecutor(initializer=lambda: ctx.set(current_ctx)) as tp:
result = tp.submit(sync_task).result()
return {'hello': result}
client = TestClient(app)
def test_context_var_propagation():
response = client.get("/sync1")
assert response.status_code == 200
assert response.json() == {"hello": "test"}
|
Some related references tiangolo/fastapi#953 tiangolo/fastapi#2776 tiangolo/fastapi#2619
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
Potentially related tickets #953 and #2619.
Example
Description
/sync1
and you'll get{"hello": "N/A"}
.{"hello": "test"}
instead.So, context vars are not visible within futures run via
ThreadPoolExecutor
. Why is that? What needs to be done to make them visible? Where is it documented?Attempt to run via contextvars'
run()
makes no difference (but I'm noob here):The result is the same.
Howevever, a direct call works just fine:
This returns
{"hello": "test"}
.Async routes work always:
These return
{"hello": "test"}
.Environment
0.61.1
0.13.6
0.12.2
3.7.9
The text was updated successfully, but these errors were encountered: