55
66Inspired by asyncio-atexit (https://github.com/minrk/asyncio-atexit) but
77tailored 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
1016from __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
103124def 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
156188def unregister (
0 commit comments