#36714: Async signals lose ContextVar state due to use of asyncio.gather
-------------------------------------+-------------------------------------
Reporter: Mykhailo Havelia | Type:
| Uncategorized
Status: new | Component: HTTP
| handling
Version: dev | Severity: Normal
Keywords: asyncio, signals | Triage Stage:
| Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
-------------------------------------+-------------------------------------
The natural way to share global, per-request state in asyncio is through
contextvars. In Django, this is typically used via `asgiref.local.Local`.
However, Django's async signal dispatch currently uses `asyncio.gather`,
which internally creates new tasks (`asyncio.create_task`). This breaks
context propagation, since each task gets its own copy of the context. As
a result, it's impossible to set a global (context-based) variable inside
a signal handler and have it shared with other signal handlers or parts of
the same request/response cycle.
Example
{{{
from django.core import signals
from django.http import (
HttpRequest,
HttpResponse,
)
import contextvars
from django.urls import path
request_id = contextvars.ContextVar('request_id', default=None)
async def set_global_variable(*args, **kwargs):
# set global variable
request_id.set('request_id_value')
print('get value', request_id.get())
signals.request_started.connect(set_global_variable)
async def index(request: HttpRequest) -> HttpResponse:
# get global variable
print('request_id', request_id.get())
return HttpResponse(content=request_id.get())
urlpatterns = [path("", index), ]
}}}
result
{{{
get value request_id_value
request_id None
}}}
The value set inside the signal handler is lost, because the handler runs
in a separate task with its own context.
If we are talking exactly about `signals.request_started` and
`signals.request_finished`, they are typically used for setting up and
cleaning up per-request resources. With `asyncio.gather`, cleanup logic
that relies on `ContextVar` cannot work properly.
{{{
from django.core import signals
from django.http import (
HttpRequest,
HttpResponse,
)
import contextvars
from django.urls import path
db_connection = contextvars.ContextVar('db_connection', default=None)
async def get_or_create_connection():
if not db_connection.get():
db_connection.set('connection')
return db_connection.get()
async def close_connection(*args, **kwargs):
connection = db_connection.get()
if not connection:
print('cannot clean - connection does not exist')
return
print('close connection')
connection.set(None)
signals.request_finished.connect(close_connection)
async def index(request: HttpRequest) -> HttpResponse:
# create connection inside handler
connection = await get_or_create_connection()
# await get_data(connection)
return HttpResponse(content="ok")
urlpatterns = [path("", index), ]
}}}
result
{{{
cannot clean - connection does not exist
}}}
**Expected behavior**
Signal handlers should run in the same async context as the request,
preserving `ContextVar` and `asgiref.local.Local` state.
**Proposed solution**
Signal:
Dispatch async signal handlers sequentially (or via direct await) instead
of using `asyncio.gather`, so that the existing execution context is
preserved throughout the request lifecycle. Yes, this change removes
parallelism, but that shouldn’t be a major concern. The only real benefit
of running signal handlers in parallel would be for IO-bound operations -
yet in most cases, these handlers interact with the same database
connection. Since database operations aren’t truly parallel under the
hood, the performance gain from `asyncio.gather` is negligible.
ASGIHandler:
{{{
async def handle(self, scope, receive, send):
...
await signals.request_started.asend(sender=self.__class__,
scope=scope)
tasks = [
asyncio.create_task(self.listen_for_disconnect(receive)),
asyncio.create_task(process_request(request, send)),
]
...
await signals.request_finished.asend(sender=self.__class__)
}}}
Global variables created inside `process_request` are not visible to
`request_finished`, because each task runs in a separate context. We can
try using `contextvars.copy_context()` to preserve and share the same
context between tasks and signal handlers.
{{{
async def handle(self, scope, receive, send):
...
await signals.request_started.asend(sender=self.__class__,
scope=scope)
ctx = contextvars.copy_context()
tasks = [
asyncio.create_task(self.listen_for_disconnect(receive)),
asyncio.create_task(process_request(request, send), context=ctx),
]
...
await
asyncio.create_task(signals.request_finished.asend(sender=self.__class__),
context=ctx)
}}}
Here is a simple example
{{{
import asyncio
import contextvars
global_state = contextvars.ContextVar('stage', default=0)
async def inc():
value = global_state.get()
print('value: ', value)
global_state.set(value + 1)
async def main():
await asyncio.create_task(inc())
await asyncio.create_task(inc())
await asyncio.create_task(inc())
print('first: ', global_state.get())
ctx = contextvars.copy_context()
await asyncio.create_task(inc(), context=ctx)
await asyncio.create_task(inc(), context=ctx)
await asyncio.create_task(inc(), context=ctx)
print('second: ', ctx.get(global_state))
await main()
}}}
result
{{{
value: 0
value: 0
value: 0
first: 0
value: 0
value: 1
value: 2
second: 3
}}}
--
Ticket URL: <
https://code.djangoproject.com/ticket/36714>
Django <
https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.