From 533b81361ffca775b43e9e212b15ae30800d9590 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 16:10:18 +0300 Subject: [PATCH] docs: share a container with manage_lifespan=False and one owner Rewrite the shared-container caution from #700 around a single owner, add a recipe for a FastAPI app and a FastStream app sharing one container, and document the manage_lifespan flag on each integration page that has it. Closes #701 --- conftest.py | 13 +- docs/integrations/aiogram.md | 6 +- docs/integrations/aiohttp.md | 6 +- docs/integrations/arq.md | 6 +- docs/integrations/celery.md | 9 +- docs/integrations/fastapi.md | 8 +- docs/integrations/faststream.md | 7 +- docs/integrations/litestar.md | 6 +- docs/integrations/starlette.md | 8 +- docs/integrations/taskiq.md | 6 +- docs/providers/context.md | 5 +- docs/providers/lifecycle.md | 15 +- docs/recipes/shared-container.md | 205 ++++++++++++++++++ .../troubleshooting/container-closed-error.md | 9 +- mkdocs.yml | 2 + 15 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 docs/recipes/shared-container.md diff --git a/conftest.py b/conftest.py index a7986ed8..b0d9e972 100644 --- a/conftest.py +++ b/conftest.py @@ -23,7 +23,18 @@ _UNINSTALLED_PACKAGES = frozenset( - {"aiohttp", "fastapi", "litestar", "modern_di_litestar", "modern_di_pytest", "pydantic_ai", "redis", "sqlalchemy"} + { + "aiohttp", + "fastapi", + "faststream", + "litestar", + "modern_di_litestar", + "modern_di_pytest", + "pydantic_ai", + "redis", + "sqlalchemy", + "uvicorn", + } ) _EXCLUDED_PAGES = ( diff --git a/docs/integrations/aiogram.md b/docs/integrations/aiogram.md index 9b748164..8cff6daa 100644 --- a/docs/integrations/aiogram.md +++ b/docs/integrations/aiogram.md @@ -103,6 +103,10 @@ to a WebSocket. See [the scope hierarchy](../providers/scopes.md#what-each-scope `dispatcher.shutdown`, and closing runs APP-scoped finalizers. `start_polling()` emits both events, and so does an aiohttp app wired with aiogram's `setup_application()`. +Since modern-di-aiogram 4.2.0, `setup_di(dispatcher, container, manage_lifespan=False)` registers +neither hook and leaves the root container's open and close to you, for a container shared with +another app; see [Lifecycle](../providers/lifecycle.md#closing-and-reopening). + ## Framework context objects Two types resolve by annotation: `aiogram.types.Update` and `aiogram.types.TelegramObject`, @@ -356,7 +360,7 @@ await test_report() | Symbol | Description | |---|---| -| `setup_di(dispatcher, container, *, auto_inject=False)` | Stores the container on the dispatcher, registers `aiogram_update_provider`/`aiogram_event_provider`, wires `dispatcher.startup`/`dispatcher.shutdown` to open/close the container, and installs the per-update middleware. With `auto_inject=True`, also wraps every handler already registered on the dispatcher at startup. | +| `setup_di(dispatcher, container, *, auto_inject=False, manage_lifespan=True)` | Stores the container on the dispatcher, registers `aiogram_update_provider`/`aiogram_event_provider`, wires `dispatcher.startup`/`dispatcher.shutdown` to open/close the container (not with `manage_lifespan=False`), and installs the per-update middleware. With `auto_inject=True`, also wraps every handler already registered on the dispatcher at startup. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-update child container. | | `inject` | Decorator for a sync or `async def` aiogram handler; resolves its `FromDI`-annotated parameters. Not needed when `setup_di(..., auto_inject=True)` is used. Raises `RuntimeError` naming `setup_di` when an update reaches it without the middleware installed. | | `fetch_di_container(dispatcher)` | Returns the root `Container` stored on the dispatcher. | diff --git a/docs/integrations/aiohttp.md b/docs/integrations/aiohttp.md index 5ca185be..4084c178 100644 --- a/docs/integrations/aiohttp.md +++ b/docs/integrations/aiohttp.md @@ -121,6 +121,10 @@ The root container is open from the moment you construct it. `setup_di` reopens closes it with `close_async()` on cleanup. An app attached with `add_subapp` receives both signals from its parent, so a `setup_di`-wired sub-app is closed too. +Since modern-di-aiohttp 4.2.0, `setup_di(app, container, manage_lifespan=False)` registers neither +the startup nor the cleanup hook and leaves the root container's open and close to you, for a +container shared with another app; see [Lifecycle](../providers/lifecycle.md#closing-and-reopening). + A sub-app route resolves from the sub-app's container when the sub-app called `setup_di`, and from the parent's otherwise. When both call it, each middleware opens and closes its own child container for the request. Before modern-di-aiohttp 4.1.0, calling `setup_di` on both made every sub-app route @@ -260,7 +264,7 @@ with warnings.catch_warnings(): | Symbol | Description | |---|---| -| `setup_di(app, container)` | Stores the container on the app, registers the request and websocket context providers, reopens the container on startup and closes it on cleanup, and installs the middleware that builds a per-connection child container; returns the container. | +| `setup_di(app, container, *, manage_lifespan=True)` | Stores the container on the app, registers the request and websocket context providers, reopens the container on startup and closes it on cleanup (neither with `manage_lifespan=False`), and installs the middleware that builds a per-connection child container; returns the container. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-connection child container. | | `inject` | Decorator for a sync or `async def` `handler(request: web.Request, ...)`, or an `async def` method of a `web.View` subclass; resolves its `FromDI`-annotated parameters. Raises `TypeError` when the handler receives neither a `web.Request` nor a `web.View`, and `RuntimeError` naming `setup_di` when the request did not pass through the middleware. | | `fetch_di_container(app)` | Returns the root `Container` stored on the app. | diff --git a/docs/integrations/arq.md b/docs/integrations/arq.md index 7ade92e5..c1c05d33 100644 --- a/docs/integrations/arq.md +++ b/docs/integrations/arq.md @@ -114,6 +114,10 @@ comparable to a websocket connection. worker that starts again on the same container, after a restart or in a test that runs the worker twice, reopens it on startup. +Since modern-di-arq 4.1.0, `setup_di(worker_settings, container, manage_lifespan=False)` +leaves the root container's open and close to you, for a container shared with another +app; see [Lifecycle](../providers/lifecycle.md#closing-and-reopening). + A hook you already defined on `WorkerSettings` still runs. Yours runs after ours on startup and job start, and before ours on shutdown and job end. The root container is open in all of them, but for an `@inject` task the job's child is @@ -169,7 +173,7 @@ async def test_run_report() -> None: | Symbol | Description | |---|---| -| `setup_di(worker_settings, container)` | Seed the root container into arq's `ctx` and wire root + per-job lifecycle onto arq's `on_startup`/`on_shutdown`/`on_job_start`/`on_job_end` hooks. Accepts a `WorkerSettings` class/object or a settings `dict`; composes with existing hooks; returns the container. Raises `TypeError` if called twice on the same `worker_settings`. | +| `setup_di(worker_settings, container, *, manage_lifespan=True)` | Seed the root container into arq's `ctx` and wire root + per-job lifecycle onto arq's `on_startup`/`on_shutdown`/`on_job_start`/`on_job_end` hooks; with `manage_lifespan=False`, the hooks leave the root alone. Accepts a `WorkerSettings` class/object or a settings `dict`; composes with existing hooks; returns the container. Raises `TypeError` if called twice on the same `worker_settings`. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. | | `@inject` | Decorator that resolves `FromDI`-annotated parameters from the per-job `Scope.REQUEST` child container. Order-insensitive; passthrough for tasks with no `FromDI`; raises `TypeError` at decoration if the task also declares `*args`/`**kwargs`; raises `RuntimeError` naming `setup_di` when a job reaches it without the modern-di hooks installed. | | `fetch_di_container(ctx)` | Returns the root container from an arq `ctx` dict. | diff --git a/docs/integrations/celery.md b/docs/integrations/celery.md index de862a6f..fa2675a1 100644 --- a/docs/integrations/celery.md +++ b/docs/integrations/celery.md @@ -114,6 +114,13 @@ harmless. Earlier releases store the container on `app.conf`, which cannot be pickled, and the pool fails at startup under spawn. +Since modern-di-celery 4.1.0, `setup_di(app, container, manage_lifespan=False)` stops the +signals in the table from opening or closing the root and leaves that to you, for a container +shared with other code in the process; see +[Lifecycle](../providers/lifecycle.md#closing-and-reopening). A prefork child then inherits the +open container and nothing closes it there. If the child's APP-scoped finalizers must run, call +`container.close_sync()` from your own `worker_process_shutdown` handler. + Code that calls tasks without a worker, such as a script or a test in eager mode, fires none of these signals. Close the root yourself when it is done, for example with `with container:`, as in [Testing](#testing). @@ -179,7 +186,7 @@ with container: | Symbol | Description | |---|---| -| `setup_di(app, container)` | Wire the APP-scope container into Celery: registers it for the app and opens/closes it on `worker_process_init`/`worker_process_shutdown` and `worker_init`/`worker_shutdown`. Returns the container. | +| `setup_di(app, container, *, manage_lifespan=True)` | Wire the APP-scope container into Celery: registers it for the app and opens/closes it on `worker_process_init`/`worker_process_shutdown` and `worker_init`/`worker_shutdown`, unless `manage_lifespan=False`. Returns the container. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. | | `@inject` | Decorator that builds a `Scope.REQUEST` child container per call, resolves `FromDI`-annotated parameters from it, and closes the child container with `close_sync()` afterwards. Raises `RuntimeError` naming `setup_di` when a task reaches it without `setup_di` called. A task with `FromDI` parameters that also declares `*args`/`**kwargs` raises `TypeError` at decoration. | | `DITask` | `Task` subclass that applies `@inject` to a task's `run` method automatically; pass `task_cls=DITask` to `Celery(...)` or `base=DITask` to `@app.task(...)`. | diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index cd3e8473..d7b85ced 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -129,6 +129,12 @@ a `lifespan=` you passed to `FastAPI()` stays the outer context and the state it through, the container reopens on startup, and it closes with `close_async()` on shutdown. The lifespan shutdown is the only thing that closes the root container. +Since modern-di-fastapi 4.1.0, `setup_di(app, container, manage_lifespan=False)` leaves the root +container's open and close to you, for a container shared with another app; see +[FastAPI and FastStream sharing a container](../recipes/shared-container.md). The flag covers only +this app's own container. The lifespan still opens and closes the container of each +[mounted app](#mounted-apps), unless that app's own `setup_di` set the flag to `False`. + ### Mounted apps A FastAPI app mounted with `app.mount` can call `setup_di` with its own container, and its routes @@ -243,7 +249,7 @@ with TestClient(request_app) as client: | Symbol | Description | |---|---| -| `setup_di(app, container)` | Stores the container on the app, registers the request and websocket context providers, and wraps the app's lifespan (any `lifespan=` you passed included) so the container reopens on startup and closes on shutdown, along with the root container of each mounted FastAPI app that called `setup_di`; returns the container. | +| `setup_di(app, container, *, manage_lifespan=True)` | Stores the container on the app, registers the request and websocket context providers, and wraps the app's lifespan (any `lifespan=` you passed included) so the container reopens on startup and closes on shutdown, along with the root container of each mounted FastAPI app that called `setup_di`; with `manage_lifespan=False`, this app's own container is left to you. Returns the container. | | `FromDI(dependency, *, use_cache=True)` | A `fastapi.Depends` that resolves a provider or type from the connection's child container (REQUEST for HTTP, SESSION for a websocket). Raises `RuntimeError` naming `setup_di` when a request reaches it without `setup_di` called. | | `build_di_container(connection)` | A `fastapi.Depends` callable that yields the connection's child container: REQUEST scope for an HTTP request, SESSION scope for a websocket. | | `fastapi_request_provider` | `ContextProvider` for `fastapi.Request` (REQUEST scope), auto-registered. | diff --git a/docs/integrations/faststream.md b/docs/integrations/faststream.md index 9c517ec7..ca22b183 100644 --- a/docs/integrations/faststream.md +++ b/docs/integrations/faststream.md @@ -88,6 +88,11 @@ installs the middleware on every broker. An `after_shutdown` hook closes the roo `close_async()`, which runs APP-scoped finalizers. Because startup reopens the container, an app that stops and starts again on the same container works. +Since modern-di-faststream 4.1.0, `setup_di(app, container, manage_lifespan=False)` registers +neither the open hook nor the close hook and leaves the root container's open and close to you, for +a container shared with another app; see +[FastAPI and FastStream sharing a container](../recipes/shared-container.md). + Since modern-di-faststream 4.0.1, the integration keeps its containers in the FastStream context under the keys `modern_di_root_container` and `modern_di_request_container`, so a context value of your own named `di_container` or `request_container` no longer replaces them. Code that read @@ -242,7 +247,7 @@ Between `setup_di` and startup no broker carries the middleware yet; see | Symbol | Description | |---|---| -| `setup_di(app, container)` | Wire the APP-scope container into FastStream: at startup, installs the middleware that creates a REQUEST child container per message on every broker of the app, and raises `RuntimeError` if there is none by then; closes the APP container at shutdown. | +| `setup_di(app, container, *, manage_lifespan=True)` | Wire the APP-scope container into FastStream: at startup, installs the middleware that creates a REQUEST child container per message on every broker of the app, and raises `RuntimeError` if there is none by then; opens the APP container at startup and closes it at shutdown, unless `manage_lifespan=False`. | | `FromDI(dependency, *, use_cache=True, cast=False)` | A `faststream.Depends` wrapper for `Annotated[T, FromDI(...)]` in subscriber signatures; accepts a provider instance or a plain type. `use_cache` and `cast` are passed through to `faststream.Depends`. Raises `RuntimeError` naming `setup_di` when a message reaches it without the middleware installed. | | `fetch_di_container(app)` | Returns the APP-scope container registered with the FastStream app. | | `faststream_message_provider` | `ContextProvider` for the current `faststream.StreamMessage`. | diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 169dbd4d..8dfb0f7b 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -146,6 +146,10 @@ thing that closes the root container, so when the server skips the lifespan, req succeed but its finalizers never run. Close it yourself (`await container.close_async()`) at shutdown in that case. +Since modern-di-litestar 4.1.0, `ModernDIPlugin(container, manage_lifespan=False)` adds no lifespan +hook and leaves the root container's open and close to you, for a container shared with another +app; see [Lifecycle](../providers/lifecycle.md#closing-and-reopening). + ## Websockets In a websocket handler, `di_container` is the connection's `Scope.SESSION` container, which lives @@ -234,7 +238,7 @@ with TestClient(request_app) as client: | Symbol | Description | |---|---| -| `ModernDIPlugin(container, autowired_groups=None)` | Litestar `InitPlugin` that stores the container on the app state, registers the request and websocket context providers and the `di_container` dependency, appends a lifespan hook that closes the container on shutdown, and (if `autowired_groups` is given) exposes each provider in those groups as a Litestar dependency keyed by attribute name. | +| `ModernDIPlugin(container, autowired_groups=None, *, manage_lifespan=True)` | Litestar `InitPlugin` that stores the container on the app state, registers the request and websocket context providers and the `di_container` dependency, appends a lifespan hook that closes the container on shutdown (none with `manage_lifespan=False`), and (if `autowired_groups` is given) exposes each provider in those groups as a Litestar dependency keyed by attribute name. | | `FromDI(dependency)` | Returns a Litestar `Provide` that resolves a provider or type from the connection's child container (REQUEST for HTTP, SESSION for a websocket). | | `di_container` | The dependency name the plugin registers for the connection's child container; declare `di_container: NamedDependency[Container]` to receive it. | | `fetch_di_container(app)` | Returns the root `Container` stored on the Litestar app. | diff --git a/docs/integrations/starlette.md b/docs/integrations/starlette.md index 68fec06d..98b56629 100644 --- a/docs/integrations/starlette.md +++ b/docs/integrations/starlette.md @@ -176,6 +176,12 @@ a `lifespan=` you passed to `Starlette()` stays the outer context and the state through, the container reopens on startup, and it closes with `close_async()` on shutdown. The lifespan shutdown is the only thing that closes the root container. +Since modern-di-starlette 4.2.0, `setup_di(app, container, manage_lifespan=False)` leaves the root +container's open and close to you, for a container shared with another app; see +[Lifecycle](../providers/lifecycle.md#closing-and-reopening). The flag covers only this app's own +container. The lifespan still opens and closes the container of each [mounted app](#mounted-apps), +unless that app's own `setup_di` set the flag to `False`. + ### Mounted apps An app mounted with `Mount` can call `setup_di` with its own container. Its routes then resolve @@ -302,7 +308,7 @@ with TestClient(request_app) as client: | Symbol | Description | |---|---| -| `setup_di(app, container)` | Stores the container on `app.state`, registers the request and websocket context providers, wraps the lifespan so the container reopens on startup and closes on shutdown, along with the root container of each mounted app that called `setup_di`, and installs the middleware that builds a per-connection child container; returns the container. | +| `setup_di(app, container, *, manage_lifespan=True)` | Stores the container on `app.state`, registers the request and websocket context providers, wraps the lifespan so the container reopens on startup and closes on shutdown, along with the root container of each mounted app that called `setup_di`, and installs the middleware that builds a per-connection child container. With `manage_lifespan=False`, this app's own container is left to you. Returns the container. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-connection child container. | | `inject` | Decorator for a sync or `async def` `handler(connection: Request | WebSocket, ...)`, a function endpoint or a method of an `HTTPEndpoint` / `WebSocketEndpoint` subclass; resolves its `FromDI`-annotated parameters. Raises `TypeError` when the handler receives no `Request` or `WebSocket` positionally, and `RuntimeError` naming `setup_di` when the connection did not pass through the middleware. | | `fetch_di_container(app)` | Returns the root `Container` stored on `app.state`. | diff --git a/docs/integrations/taskiq.md b/docs/integrations/taskiq.md index f36bcd04..95ed9438 100644 --- a/docs/integrations/taskiq.md +++ b/docs/integrations/taskiq.md @@ -87,6 +87,10 @@ comparable to websockets. `close_async()` on `WORKER_SHUTDOWN`, which runs APP-scoped finalizers. `taskiq worker` fires both. With an `InMemoryBroker`, `async with broker:` fires both too. +Since modern-di-taskiq 4.1.0, `setup_di(broker, container, manage_lifespan=False)` hooks neither +event and leaves the root container's open and close to you, for a container shared with another +app; see [Lifecycle](../providers/lifecycle.md#closing-and-reopening). + !!! warning "`run_receiver_task` never closes the root" A receiver embedded with `taskiq.api.run_receiver_task(...)` never calls `broker.shutdown()`, so `WORKER_SHUTDOWN` never fires and APP-scoped finalizers @@ -167,7 +171,7 @@ assert result.return_value == {"service": "catalog"} | Symbol | Description | |---|---| -| `setup_di(broker, container)` | Wire the APP-scope container into taskiq: a REQUEST child container is then built through `TaskiqDepends` for each task that uses `FromDI`; opens/closes the APP container on worker startup/shutdown. | +| `setup_di(broker, container, *, manage_lifespan=True)` | Wire the APP-scope container into taskiq: a REQUEST child container is then built through `TaskiqDepends` for each task that uses `FromDI`; opens/closes the APP container on worker startup/shutdown, unless `manage_lifespan=False`. | | `FromDI(provider_or_type, *, use_cache=True)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. `use_cache` is passed through to `TaskiqDepends`. Raises `RuntimeError` naming `setup_di` when a task reaches it without `setup_di` called. | | `fetch_di_container(broker)` | Returns the APP-scope container registered with the taskiq broker. | | `taskiq_message_provider` | `ContextProvider` for the current `taskiq.TaskiqMessage`. | diff --git a/docs/providers/context.md b/docs/providers/context.md index db3d7a50..461f7858 100644 --- a/docs/providers/context.md +++ b/docs/providers/context.md @@ -119,8 +119,9 @@ assert with_request.resolve(AuditLog).client_host == "10.0.0.1" The same works with an integration's provider. With `modern-di-fastapi` set up, an `AuditLog(request: fastapi.Request | None = None)` gets the real request inside a FastAPI handler and `None` in a FastStream consumer that shares the container. The provider itself stays required, -so `container.resolve(fastapi.Request)` outside a request still raises. Both apps then close the -same container at shutdown; see [Closing and reopening](lifecycle.md#closing-and-reopening). +so `container.resolve(fastapi.Request)` outside a request still raises. A container shared by +two apps needs one owner to open and close it; see +[FastAPI and FastStream sharing a container](../recipes/shared-container.md). The fallback has limits: diff --git a/docs/providers/lifecycle.md b/docs/providers/lifecycle.md index 658def61..102e5538 100644 --- a/docs/providers/lifecycle.md +++ b/docs/providers/lifecycle.md @@ -291,13 +291,14 @@ What survives close and reopen depends on `CacheSettings`: not the outer one. Use one `with` block per container, or build a child container for the inner scope. -!!! caution "Apps that share a container close it for each other" - Integrations open the root container at startup and close it at shutdown, and opening an - open container does nothing. When two apps share one root container, for example a FastAPI - app and a FastStream app in one process, the first app to stop closes it and runs its APP - finalizers. After that, any resolve in the other app that reaches an APP-scoped provider - raises `ContainerClosedError`. Stop the apps together, or give each app its own container - when they stop independently. +!!! caution "Apps that share a container need one owner" + By default an integration opens the root container at startup and closes it at shutdown, so + when two apps share one root, the first app to stop closes it under the other. Pass + `manage_lifespan=False` to every integration that shares the root, and open and close it + yourself, for example with `async with container:` around running the apps. APP finalizers + then run once, when your block exits. Flask, gRPC and Typer never open or close the root and + have no flag. See + [FastAPI and FastStream sharing a container](../recipes/shared-container.md). ## Validation diff --git a/docs/recipes/shared-container.md b/docs/recipes/shared-container.md new file mode 100644 index 00000000..6b89c471 --- /dev/null +++ b/docs/recipes/shared-container.md @@ -0,0 +1,205 @@ +# FastAPI and FastStream sharing a container + +An HTTP API and a message consumer that run in one process can share one container, so both get +the same APP-scoped instances, such as one connection pool. By default each integration opens the +root container at startup and closes it at shutdown, so the first app to stop closes it under the +other. Pass `manage_lifespan=False` to both `setup_di` calls and open and close the container +yourself. The flag needs modern-di-fastapi 4.1.0 and modern-di-faststream 4.1.0 or later. + +## Setup + +```python +import dataclasses +import typing + +import fastapi +import faststream +import modern_di_fastapi +import modern_di_faststream +from faststream.nats import NatsBroker +from modern_di import Container, Group, Scope, providers + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ConnectionPool: + closed: bool = False + + async def close(self) -> None: + self.closed = True + + +class Dependencies(Group): + pool = providers.Factory( + ConnectionPool, scope=Scope.APP, cache=providers.CacheSettings(finalizer=ConnectionPool.close) + ) + + +container = Container(groups=[Dependencies]) + +app = fastapi.FastAPI() +modern_di_fastapi.setup_di(app, container, manage_lifespan=False) + +broker = NatsBroker() +stream = faststream.FastStream(broker) +modern_di_faststream.setup_di(stream, container, manage_lifespan=False) +container.validate() + + +@app.get("/pool") +async def pool_status( + pool: typing.Annotated[ConnectionPool, modern_di_fastapi.FromDI(ConnectionPool)], +) -> dict[str, bool]: + return {"closed": pool.closed} + + +@broker.subscriber("orders") +async def handle_order( + pool: typing.Annotated[ConnectionPool, modern_di_faststream.FromDI(ConnectionPool)], +) -> dict[str, bool]: + return {"closed": pool.closed} +``` + +With the flag off, each integration still registers its context providers and builds a child +container for every request or message. Only the root is left to you. + +## Running the apps side by side + +Run the uvicorn server and the FastStream app in one `asyncio.gather`, inside +`async with container:`: + +```python +import asyncio + +import uvicorn + + +async def main() -> None: + server = uvicorn.Server(uvicorn.Config(app)) + async with container: + await asyncio.gather(stream.run(), server.serve()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + +Put `stream.run()` first. On SIGTERM uvicorn stops, puts back the signal handler it replaced at +startup, and raises the signal again, so FastStream stops after it. In the other order, FastStream's +handler replaces uvicorn's, and uvicorn keeps serving after FastStream has stopped. + +The container closes when the `async with` block exits, after both apps have stopped. APP +finalizers run once, at that point. + +## Starting FastStream inside the FastAPI lifespan + +To run one uvicorn server, start the FastStream app from the FastAPI lifespan and enter the +container there, around `stream.start()` and `stream.stop()`: + + + +```python +import contextlib +from collections.abc import AsyncIterator + +import fastapi +import modern_di_fastapi +import modern_di_faststream + + +@contextlib.asynccontextmanager +async def lifespan(_: fastapi.FastAPI) -> AsyncIterator[None]: + async with container: + await stream.start() + try: + yield + finally: + await stream.stop() + + +app = fastapi.FastAPI(lifespan=lifespan) +app.add_api_route("/pool", pool_status) +modern_di_fastapi.setup_di(app, container, manage_lifespan=False) +modern_di_faststream.setup_di(stream, container, manage_lifespan=False) +``` + + + +The container closes after `stream.stop()` returns, so messages still in flight at shutdown resolve +from an open container. With the default flag, modern-di-fastapi would close the container inside +your lifespan, before `stream.stop()` runs. + +## Pitfalls + +- Set the flag on both integrations. One left at the default still closes the root when its app + stops. +- With the flag off, nothing else closes the root. Without your `async with container:` block, + APP finalizers never run. + +## See also + +- [Lifecycle](../providers/lifecycle.md#closing-and-reopening): what closing runs and what survives + a reopen. +- [FastAPI](../integrations/fastapi.md) and [FastStream](../integrations/faststream.md): the two + integrations. +- [ContainerClosedError](../troubleshooting/container-closed-error.md): the error a closed shared + container raises. diff --git a/docs/troubleshooting/container-closed-error.md b/docs/troubleshooting/container-closed-error.md index d18ed237..2b0c9692 100644 --- a/docs/troubleshooting/container-closed-error.md +++ b/docs/troubleshooting/container-closed-error.md @@ -68,10 +68,11 @@ comes from and stop it outliving the container. Reopening there would hide the l cached after the reopen would never be finalized, because the shutdown that should close it has already run. -Two apps that share one root container close it for each other. Each integration closes the root -at its app's shutdown, so when one app stops, the other's next resolve of an APP-scoped provider -raises. Stop the apps together, or give each app its own container. See -[Lifecycle: closing and reopening](../providers/lifecycle.md#closing-and-reopening). +Two apps that share one root container close it for each other. By default each integration +closes the root at its app's shutdown, so when one app stops, the other's next resolve of an +APP-scoped provider raises. Pass `manage_lifespan=False` to each integration and open and close +the container yourself; see +[FastAPI and FastStream sharing a container](../recipes/shared-container.md). A finalizer that resolves from its own container fails the same way, because the container counts as closed while its finalizers run. That `ContainerClosedError` arrives as an entry inside the diff --git a/mkdocs.yml b/mkdocs.yml index 18417bbe..d9600999 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Recipes: - Async SQLAlchemy: recipes/sqlalchemy.md - Async resources via lifespan: recipes/async-lifespan.md + - FastAPI and FastStream sharing a container: recipes/shared-container.md - Multi-group organization: recipes/multi-group.md - Testing with overrides: recipes/testing-overrides.md - Request-scoped engine selection: recipes/request-scoped-engine.md @@ -193,6 +194,7 @@ plugins: Recipes: - recipes/sqlalchemy.md: Wiring an async SQLAlchemy engine, session, and repositories - recipes/async-lifespan.md: Constructing async resources via the framework lifespan + - recipes/shared-container.md: One container shared by a FastAPI app and a FastStream app in one process, with manage_lifespan=False - recipes/multi-group.md: Organizing a large container with multiple Groups - recipes/testing-overrides.md: Swapping dependencies in tests with overrides - recipes/request-scoped-engine.md: Routing requests to different engines by scope