Isolated dependency context / local exit stacks #229
|
Hi, In the context of an event consumer, I am defining an asynchronous, injectable function that is called every time an event is consumed; let's call it async def process_event(...): # Deps, message contents, etc
...
async def main():
async with TaskGroup() as tg:
async for event in event_queue:
tg.create_task(process_event(...)) # Include event/message contents.However, note that this does not include cleanup. Just like FastAPI routers, I would like to have cleanup for every event processing task, in an isolated manner so that cleanup for event A processing does not impact event B processing that is happening in parallel. Currently, the options I have at my disposal with fastapi-injectable, from what I get, are the following:
async def process_event(...): # Deps, message contents, etc
...
async def call_process_event(*args, **kwargs):
await process_event(*args, **kwargs)
await cleanup_exit_stack_of_func(process_event, raise_exception=True)
async def main():
async with TaskGroup() as tg:
async for event in event_queue:
tg.create_task(call_process_event(...)) # Include event/message contents.For some reason when trying this the cleanup part of my dependencies doesn't seem called, but even if they were, this does not pair well with parallel executions of the event processing function. For my use case, what would be preferrable, rather than using global exit stacks, would be to instantiate and use a local exit stack. Something like: async with AsyncExitStack() as exit_stack:
await process_event(..., exit_stack=exit_stack)
# or something specific to fastapi-injectable:
async with fastapi_injectable.create_exit_stack() as exit_stack:
await process_event(..., exit_stack=exit_stack)Does something like this exist in fastapi-injectable, or is planned? Thanks :) |
Replies: 2 comments 2 replies
|
I don't see a first-class per-invocation/local exit stack API in 1.4.7 rn. The current stack is keyed by callable, not by each call. I tested this with two overlapping tasks and Cleaning after the whole The workaround I could make behave correctly was using a fresh injectable wrapper per event, ideally around the raw body: async def call_process_event(event):
@injectable(use_cache=False)
async def run_once(dep: Annotated[Dep, Depends(get_dep)]):
await process_event_body(event, dep)
try:
await run_once()
finally:
await cleanup_exit_stack_of_func(run_once, raise_exception=True)That gave me per-task cleanup in the test, but it's still a workaround. A real |
|
Thanks @sueun-dev for the precise analysis and workaround, the default exit stack is keyed by the function, so concurrent calls of the same injectable share one stack, which is why per-event So I made a from fastapi_injectable import async_get_injected_obj, injectable_scope
async def process_event(event):
async with injectable_scope():
dep = await async_get_injected_obj(get_dep)
await handle(event, dep)
# only THIS event's resources are cleaned up here — siblings are untouched
async def main():
async with asyncio.TaskGroup() as tg:
async for event in event_queue:
tg.create_task(process_event(event))If you'd rather own the stack yourself (closer to your create_exit_stack() sketch), InjectableScope is usable directly, and you can route individual resolutions into it with scope=: from fastapi_injectable import InjectableScope, async_get_injected_obj
scope = InjectableScope()
async with scope:
dep = await async_get_injected_obj(get_dep, scope=scope)
scope.exit_stack.push_async_callback(my_cleanup) # rides the same lifecycle
# dep and my_cleanup are torn down together when the block exitsOne caveat: it's async-first, under the background_thread loop strategy contextvars don't propagate across threads, so stick to the async API ( Available in v1.5.0:
See the new Isolated dependency scopes for parallel work section in the README for details. Give it a try and let me know if it fits your consumer, thanks @thomas-touhey! |
Thanks @sueun-dev for the precise analysis and workaround, the default exit stack is keyed by the function, so concurrent calls of the same injectable share one stack, which is why per-event
cleanup_exit_stack_of_func()races with parallel work (one event's cleanup tears down another's in-flight resources), and why cleaning only after theTaskGroupleaks for long-running consumers.So I made a
injectable_scopein v1.5.0,injectable_scope()gives each unit of work its own exit stack and cache, withcontextvarskeeping parallel tasks isolated — the same request-scoped model FastAPI uses internally. Cleanup runs when each scope'sasync withblock exits, so there's no global accumulation. Your…