Skip to content

Commit db52905

Browse files
committed
feat: enhance cleanup hooks with best practices for async callback handling
1 parent fdb803e commit db52905

File tree

1 file changed

+51
-19
lines changed

1 file changed

+51
-19
lines changed

src/asynctasq/utils/cleanup_hooks.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
66
Inspired by asyncio-atexit (https://github.com/minrk/asyncio-atexit) but
77
tailored specifically for AsyncTasQ's cleanup needs.
8+
9+
Best Practices Applied (2025):
10+
- Uses asyncio.get_running_loop() instead of deprecated get_event_loop()
11+
- Proper async/sync callback handling with graceful degradation
12+
- WeakKeyDictionary prevents memory leaks from loop references
13+
- Comprehensive error handling prevents callback failures from cascading
814
"""
915

1016
from __future__ import annotations
@@ -72,6 +78,12 @@ def _run_cleanup_sync(self) -> None:
7278
This is called when the loop is being closed, so we can't use
7379
await or schedule new tasks. We need to handle both sync and
7480
async callbacks appropriately.
81+
82+
Best Practice (2025):
83+
- Callbacks run in registration order for predictable cleanup sequence
84+
- Async callbacks use run_until_complete if loop is still open
85+
- Graceful degradation: logs warning if async callback can't run
86+
- Error isolation: one failing callback doesn't prevent others
7587
"""
7688
if not self.callbacks:
7789
return
@@ -80,24 +92,33 @@ def _run_cleanup_sync(self) -> None:
8092
callbacks = self.callbacks.copy()
8193

8294
for callback in callbacks:
95+
callback_name = getattr(callback, "__name__", str(callback))
8396
try:
8497
if inspect.iscoroutinefunction(callback):
8598
# For async callbacks, we need to run them with run_until_complete
8699
# if the loop is not already closed
87-
if not self.loop.is_closed():
100+
if not self.loop.is_closed() and not self.loop.is_running():
101+
# Safe to run async callback
88102
self.loop.run_until_complete(callback())
89103
else:
90-
callback_name = getattr(callback, "__name__", str(callback))
104+
# Cannot run async callback - loop is closed or running
105+
loop_state = "closed" if self.loop.is_closed() else "running"
91106
logger.warning(
92-
f"Cannot run async cleanup callback {callback_name} "
93-
f"- loop already closed"
107+
f"Cannot run async cleanup callback '{callback_name}' "
108+
f"- loop is {loop_state}. Consider running cleanup earlier."
94109
)
95110
else:
96-
# Sync callback - just call it
111+
# Sync callback - just call it directly
97112
callback()
113+
except asyncio.CancelledError:
114+
# Task was cancelled during cleanup - this is expected during shutdown
115+
logger.debug(
116+
f"Cleanup callback '{callback_name}' was cancelled (expected during shutdown)"
117+
)
98118
except Exception as e:
99119
# Don't let one failing callback prevent others from running
100-
logger.exception(f"Error in cleanup callback {callback}: {e}")
120+
# Log with full traceback for debugging
121+
logger.exception(f"Error in cleanup callback '{callback_name}': {e}")
101122

102123

103124
def register(callback: Callable[[], Any], *, loop: asyncio.AbstractEventLoop | None = None) -> None:
@@ -110,32 +131,40 @@ def register(callback: Callable[[], Any], *, loop: asyncio.AbstractEventLoop | N
110131
callback: A callable (sync or async) to execute during loop cleanup.
111132
Async callbacks will be awaited if the loop is still running.
112133
loop: The event loop to attach to. If None, uses the running loop
113-
(if available) or the current event loop policy's loop.
134+
(preferred) or falls back to get_event_loop() for compatibility.
114135
115136
Example:
116137
>>> async def cleanup():
117138
... await some_async_cleanup()
118-
>>> asyncio_atexit.register(cleanup)
139+
>>> from asynctasq.utils import cleanup_hooks
140+
>>> cleanup_hooks.register(cleanup)
119141
120-
Note:
142+
Best Practices (2025):
121143
- The callback receives no arguments
122144
- Multiple callbacks can be registered
123-
- Callbacks are executed in registration order
124-
- Exceptions in callbacks don't prevent other callbacks from running
145+
- Callbacks execute in registration order (FIFO)
146+
- Exceptions in callbacks don't prevent others from running
147+
- Uses get_running_loop() (preferred) over deprecated get_event_loop()
148+
149+
Note:
150+
If no event loop exists, the callback cannot be registered.
151+
Create/start an event loop before registering cleanup hooks.
125152
"""
126153
if loop is None:
127154
try:
128-
# Try to get the running loop first
155+
# Try to get the running loop first (modern best practice)
129156
loop = asyncio.get_running_loop()
130157
except RuntimeError:
131-
# No running loop, get the event loop from policy
158+
# No running loop - fall back to get_event_loop() for compatibility
159+
# Note: get_event_loop() is deprecated but still needed for some cases
132160
try:
133-
loop = asyncio.get_event_loop()
161+
loop = asyncio.get_event_loop_policy().get_event_loop()
134162
except RuntimeError:
135-
# No event loop, can't register
163+
# No event loop available at all
136164
logger.warning(
137165
"Cannot register cleanup callback - no event loop available. "
138-
"Create an event loop first or pass it explicitly."
166+
"Create an event loop first or pass it explicitly. "
167+
"Use asyncio.run() or asyncio.Runner for automatic loop management."
139168
)
140169
return
141170

@@ -144,13 +173,16 @@ def register(callback: Callable[[], Any], *, loop: asyncio.AbstractEventLoop | N
144173
if entry is None:
145174
entry = _RegistryEntry(loop)
146175
_registry[loop] = entry
147-
# Patch the loop's close method
176+
# Patch the loop's close method to run cleanup
148177
entry.patch_loop_close()
149178

150-
# Add the callback
179+
# Add the callback to the registry
151180
entry.add_callback(callback)
152181
callback_name = getattr(callback, "__name__", str(callback))
153-
logger.debug(f"Registered cleanup callback {callback_name} for event loop {id(loop)}")
182+
logger.debug(
183+
f"Registered cleanup callback '{callback_name}' for event loop {id(loop)} "
184+
f"({len(entry.callbacks)} total callbacks)"
185+
)
154186

155187

156188
def unregister(

0 commit comments

Comments
 (0)