Open octopoulpe opened 5 years ago
Assuming the sub applications are connected using the Router, startup/shutdown events can be implemented as follows
app = Router([Mount("/api", app=service_app), Mount("", app=root_app)])
async def open_database_connection_pool():
await service_app.service.connect()
async def close_database_connection_pool():
await service_app.service.disconnect()
app.lifespan.add_event_handler("startup", open_database_connection_pool)
app.lifespan.add_event_handler("shutdown", close_database_connection_pool)
Hi. I chose to manage that through the main application handlers.
app = Starlette()
sub_applications = {"/cart": cart_app, "/account": account_app, etc...}
for route, sub_app in sub_applications.items():
app.mount(route, sub_app)
@app.on_event("startup")
async def startup():
for sub_app in sub_applications.values():
await sub_app.router.lifespan.startup()
The same for shutdown. As long as you do not go all 'subsubapplication', I think it is quiet expressive as, in my case, sub application don't know they are "mounted" somewhere.
Shouldn't we, at least, document it ?
I was wondering if sub-applications (mounted on the main application) should receive startup/shutdown events and call their own handlers.
Ideally, yes.
Having taken a look at this it's really quite awkward.
You can't run each submount off the same lifespan task - instead each would need to have their own sub-task. Then you need to make sure you're handling the case where lifespan events are not supported and deal with exception cases from each submount.
At the moment I think we need to just treat it as out-of-scope.
But yeah, we do need to document that.
Having applications that may or may not behave properly depending on whether they're mounted at the root of the server or as a sub-application due to sub-apps not receiving lifespan events is probably going to cause issues if using #799 context-managers becomes the recommended way of handling application-level resources instead of global scope like the doc currently advises. It's one of my main worries in regards to tiangolo/fastapi#617, because it makes applications that rely on lifetime events for initialization non-composable.
Does that issue look any less awkward to fix now than it used to?
I think that if we deprecated the non-contextmanager lifespans we should be able to implement lifespan propagation.
I see 2 ways to go about it:
Router.lifespan
is a public attribute and so are Router.routes
, Mount.app
, etc.async def lifespan(self, app: ASGIApp) -> AsyncContextManager[None]
method to every routing class and have them propagate the lifespan.TLDR when propagating the lifespans, skip the whole ASGI aspect of of it and instead just call the context managers directly.
Coming back to this a couple months later. I don't agree with myself above anymore. I think it is possible to do this in pure ASGI. That is not to say that introspecting into routers and mounts (and maybe adding a public lifespan()
context manger) won't work, it will. But I've also been able to get this to work with pure ASGI.
Some concrete examples to look at:
At this point, it seems like a tradeoff between complexity and functionality. The simplest thing to do is just not support lifespans for mounted apps at all. The least complex (in terms of LOC at least) would be to introspect into the routing system and pull-out lifespans from routing primitives we recognize. The most complex version would be where Mount
does something like this and Router
does something like this.
I tried a solution as well. The solution seems to work quite well as far as I can see. I solved following challenges for me:
The solution can be found here: https://github.com/encode/starlette/commit/bdc9026b642c16e540f2d2d58e43edd927e7062b
app = Router([Mount("/api", app=service_app), Mount("", app=root_app)]) async def open_database_connection_pool(): await service_app.service.connect() async def close_database_connection_pool(): await service_app.service.disconnect() app.lifespan.add_event_handler("startup", open_database_connection_pool) app.lifespan.add_event_handler("shutdown", close_database_connection_pool)
how to import Router?
Here's a lifespan I wrote as a workaround to this. It supports both Host
and Mount
route types.
@contextlib.asynccontextmanager
async def mounted_lifespan(app: Starlette) -> AsyncGenerator[Mapping[str, Any], Any]:
async with contextlib.AsyncExitStack() as stack:
state = {}
for r in app.routes:
if isinstance(r, Host | Mount) and isinstance(r.app, Starlette | Router):
router = r.app
if isinstance(router, Starlette):
router = router.router
result = await stack.enter_async_context(router.lifespan_context(r.app))
if result is not None:
state.update(result)
yield state
Caveats:
Starlette
or Router
mount types.lifespan.startup.complete
to be sent multiple times.Full example:
# mounted app's lifespan
@contextlib.asynccontextmanager
async def test_lifespan(app: Starlette) -> AsyncGenerator[dict, Any]:
print("Hello, lifespan")
yield {"data": "stuff"}
# mounted app endpoint which depends on state created by the lifespan
async def test_endpoint(request: Request) -> Response:
return PlainTextResponse(request.state.data)
# child app with lifespan
test_app = Starlette(
routes=[
Route("/", test_endpoint),
],
lifespan=test_lifespan
)
# root app with mounted apps
app = Starlette(
routes=[
Mount("/test_app", test_app),
],
lifespan=mounted_lifespan,
)
Converting it to a middleware solves caveat 1. Doing some magic with asyncio.Queue
and wrapping send/receive solves caveat 3.
class MountedLifespanMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> Any:
if scope["type"] == "lifespan":
queues: list[Queue[Message]] = []
async def wrap_receive() -> Message:
msg = await receive()
async with anyio.create_task_group() as tasks:
for queue in queues:
tasks.start_soon(queue.put, msg)
return msg
async def wrap_send(message: Message) -> None:
if message["type"] == "lifespan.startup.complete":
return
await send(message)
async with anyio.create_task_group() as tasks:
app = scope.get("app")
if isinstance(app, Starlette):
for r in app.routes:
if isinstance(r, Mount | Host):
queues.append(queue := Queue())
tasks.start_soon(r.app, scope, queue.get, wrap_send)
await self.app(scope, wrap_receive, send)
return
await self.app(scope, receive, send)
Solution:
from fastapi import FastAPI
from contextlib import asynccontextmanager, AsyncExitStack
sub_app = FastAPI()
@asynccontextmanager
async def sub_lifespan(app: FastAPI):
print("sub app is starting up...")
yield
print("sub app is shutting down...")
# Use AsyncExitStack to manage context
async def lifespan(app: FastAPI):
async with AsyncExitStack() as stack:
# Manage the lifecycle of sub_app
await stack.enter_async_context(sub_lifespan(sub_app))
yield
app = FastAPI(lifespan=lifespan)
# Mount the sub application
app.mount("/sub", sub_app)
# Define the main application's route
@app.get("/")
async def read_main():
return {"message": "This is the main app"}
# Define the sub application's route
@sub_app.get("/")
async def read_unipay():
return {"message": "This is the unipay app"}
Hi. I was wondering if sub-applications (mounted on the main application) should receive startup/shutdown events and call their own handlers. I saw it is not the case at the moment. Should we document it or implement the dispatching of lifespan events ?