-
Notifications
You must be signed in to change notification settings - Fork 89
Added a draft introduction for the Hybrid Signal handler #103
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| --- | ||
| DEP: XXXX Hybrid Signals | ||
| Author: Mykhailo Havelia | ||
| Implementation Team: | ||
| Shepherd: | ||
| Status: Draft | ||
| Type: Feature | ||
| Created: 2025-11-06 | ||
| Last-Modified: 2025-11-06 | ||
| --- | ||
|
|
||
| # DEP XXXX: Hybrid Signal | ||
|
|
||
| Table of Contents | ||
| - [Abstract](#abstract) | ||
| - [Motivation](#motivation) | ||
| - [Specification](#specification) | ||
| - [Rationale](#rationale) | ||
| - [Backwards Compatibility](#backwards-compatibility) | ||
| - [Copyright](#copyright) | ||
|
|
||
| ## Abstract | ||
|
|
||
| This DEP proposes the introduction of hybrid signal handlers in Django - signal | ||
| receivers that can define different implementations for synchronous (`send`) and | ||
| asynchronous (`asend`) dispatch. The goal is to eliminate the need for expensive | ||
| `sync_to_async` and `async_to_sync` conversions when signals are triggered in mixed | ||
| contexts, while keeping backward compatibility with existing Signal usage. | ||
|
|
||
| ## Motivation | ||
|
|
||
| Currently, Django's signal system doesn't distinguish between synchronous and | ||
| asynchronous receivers. A signal can connect either sync or async functions, but | ||
| internally Django uses thread-based wrappers (`sync_to_async` or `async_to_sync`) when | ||
| dispatching to handlers that don’t match the context type. | ||
|
|
||
| Example: | ||
|
|
||
| ```python | ||
| from django.core.signals import request_finished | ||
| from django.dispatch import receiver | ||
|
|
||
| @receiver(request_finished) | ||
| def request_logger(sender, **kwargs): | ||
| print("Request finished") | ||
| ``` | ||
|
|
||
| When this signal is triggered using `asend`, Django wraps sync functions using | ||
| `sync_to_async`, spawning a thread for each invocation. This adds unnecessary overhead. | ||
| A hybrid signal would allow developers and third-party libraries to register both sync | ||
| and async versions of a handler explicitly. This way, Django can directly dispatch to | ||
| the appropriate function without any cross-context wrapping or thread spawning. | ||
|
|
||
| # Specification | ||
|
|
||
| A hybrid signal handler can define both sync and async implementations under a single | ||
| registration. | ||
|
|
||
| Example API: | ||
|
|
||
| ```python | ||
| class HybridSignalHandler: | ||
|
|
||
| def sync_call(self, receiver, **kwargs): | ||
| raise NotImplementedError( | ||
| "send is not supported for this handler" | ||
| ) | ||
|
|
||
| async def async_call(self, receiver, **kwargs): | ||
| raise NotImplementedError( | ||
| "asend is not supported for this handler" | ||
| ) | ||
|
Comment on lines
+62
to
+72
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Generally, this DEP is a good idea IMO 🚀 I'm a little stuck on this implementation and wanted to see if you had thought of other approaches. Did you consider revising the @receiver(signal, sync=False, _async=True)
async def async_call(receiver, **kwargs):
...
@receiver(signal, sync=True, _async=False)
def sync_call(receiver, **kwargs):
...This would avoid requiring people to use a inheritance in their handlers and still provide the flexibility you're looking for. I haven't thought this entirely through though, so I could be missing something.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I considered that approach, but it's much easier to make mistakes with it. In most cases, a handler should run regardless of whether the context is async or sync. For example: @receiver(request_finished, with_async=False)
def close_db_connection(sender, **kwargs):
# close sync DB connection
...When async def index(request):
# do async work
...
await sync_to_async(do_sync_work_with_sync_db)()Additionally, even if production uses only async connections, the development server wraps all async views with That’s why I think using a class-based design is a better choice. It encourages developers to implement both async and sync versions explicitly, and Django can easily raise an exception if one is missing. We can skip inheritance and just use duck typing (if an object has I don’t know, I just kind of like the idea of using classes — it feels like a better fit 😅 class LogginHandler:
def log(self):
print('done')
def sync_call(self, receiver, **kwargs):
self.log()
async def async_call(self, receiver, **kwargs):
self.log()
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here it seems like we want the accept a pair of callables, for the sync and async case respectively, rather than just the one. The old fashioned way to do that might have been to pass a tuple... (Or similar) Tuples are OK until you need to remember which field stands for what. Is it sync, async or async, sync? So we have (Or similar) Here the data class is just a namespace for the pair of callables we want to pass. If we used a runtime checkable protocol in (Or something along those lines)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could leave the receiver decorator untouched and make changes only to the signal API(decorator is only useful for wrapping a function or a class). request_finished.hybrid_connect(async_handler=async_version, sync_handler=sync_version)However, I prefer using a class-based approach. In my experience, it's much easier to keep things organized this way, sync and async versions often share common logic, and with a class, it's simple to move that into a method with proper encapsulation. FieldExtension from the strawberry is a perfect example of how it can look.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, there's no reason to not allow people to use a class, especially if there's shared logic as you say. My proposal was to define a protocol that would also allow named pairs of receivers (which is conceptually what's needed). Then you pass either a single callable (as now) or an instance of the protocol (to provide both versions). We already check what kind of receiver was passed at connection, so it's just another branch to handle the both case suggested here. Does that make sense? Maybe I'm missing something?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes sense. I just wanted to emphasize that request_finished.hybrid_connect(async_handler=async_version, sync_handler=sync_version)or request_finished.hybrid_connect(handler_obj)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. And kwargs might serve the same purpose of keeping which callable is which clear in the code. (These seem like colour of the bike shed questions. We don't have to settle them exactly at this point 🙂) |
||
| ``` | ||
|
|
||
| Usage: | ||
|
|
||
| ```python | ||
| @receiver(request_finished) | ||
| class RequestLogger(HybridSignalHandler): | ||
| def sync_call(self, sender, **kwargs): | ||
| print("Request finished (sync)") | ||
|
|
||
| async def async_call(self, sender, **kwargs): | ||
| print("Request finished (async)") | ||
| ``` | ||
|
|
||
| ## Rationale | ||
|
|
||
| Django’s current signal system calls receivers the same way in all contexts. | ||
| When an async signal is sent, Django wraps sync receivers with `sync_to_async()`, | ||
| which runs them in a thread. This works but adds unnecessary overhead for short signals | ||
| and makes async code less efficient. The idea of hybrid signals is simple: allow | ||
| developers to define both sync and async versions of the same handler. Then Django | ||
| can call the right one directly, without using wrappers or threads. | ||
|
|
||
| This design: | ||
|
|
||
| - avoids extra thread spawning and context switching | ||
| - keeps signals simple and backward compatible | ||
| - and gives developers clear control over how their handlers run | ||
|
|
||
| Similar patterns already exist in other frameworks, for example in `Strawberry GraphQL`, | ||
| where extensions can define both `resolve()` and `resolve_async()`. | ||
|
|
||
|
|
||
| ## Backwards Compatibility | ||
|
|
||
| This proposal is fully backward compatible: | ||
|
|
||
| - Existing Signal and receiver decorators continue to work as is. | ||
| - Libraries can safely migrate incrementally. | ||
|
|
||
| Potential risks: | ||
|
|
||
| - None for existing code. | ||
| - Minimal risk if developers misdefine hybrid handlers (guarded by `NotImplementedError`). | ||
|
|
||
| ## Copyright | ||
|
|
||
| This document has been placed in the public domain per the Creative | ||
| Commons CC0 1.0 Universal license | ||
| (<http://creativecommons.org/publicdomain/zero/1.0/deed>). | ||
|
|
||
| (All DEPs must include this exact copyright statement.) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: What happens if someone doesn't define both? For example, if a
HybridSignalHandleronly implementssync_call, but is triggered in an async context, does Django automatically wrap it insync_to_async? Or should it be validated at definition time that you must define both methods?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
raise an exception
HybridSignalHandleris attempting to removesync_to_asyncfrom the request/response cycle.