diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 28a8c53..6770b0a 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -76,8 +76,13 @@ and still fires `on_job_end`). ## Per-job scope `on_job_start` builds one `Scope.REQUEST` child per job — -`root.build_child_container(scope=Scope.REQUEST)` — and stashes it under -`_CHILD_CONTAINER_KEY` on that job's `ctx`. `on_job_end` closes it with +`root.build_child_container(scope=Scope.REQUEST)` — opens it with a bare +`child.open()` (modern-di 3.x's mandatory-open lifecycle: resolving from an +unopened container raises `ContainerClosedError`), and stashes it under +`_CHILD_CONTAINER_KEY` on that job's `ctx`. The build happens in +`on_job_start` and the close happens in `on_job_end` — two different +hooks — so the child cannot be opened via a `with` block; it is opened with +a bare call instead. `on_job_end` closes it with `close_async()`, unconditionally: because the compose ordering runs the user's `on_job_end` first and the close second, the child is torn down whether the task raised or returned, and it happens even for a task that was diff --git a/modern_di_arq/main.py b/modern_di_arq/main.py index 9134df0..092edc8 100644 --- a/modern_di_arq/main.py +++ b/modern_di_arq/main.py @@ -54,7 +54,9 @@ async def on_shutdown(ctx: dict[str, typing.Any]) -> None: def _wrap_job_start(existing: _Hook | None) -> _Hook: async def on_job_start(ctx: dict[str, typing.Any]) -> None: root = typing.cast(Container, ctx[_ROOT_CONTAINER_KEY]) - ctx[_CHILD_CONTAINER_KEY] = root.build_child_container(scope=Scope.REQUEST) + child = root.build_child_container(scope=Scope.REQUEST) + child.open() + ctx[_CHILD_CONTAINER_KEY] = child if existing is not None: await existing(ctx) diff --git a/planning/changes/2026-07-20.01-open-per-job-child-container.md b/planning/changes/2026-07-20.01-open-per-job-child-container.md new file mode 100644 index 0000000..561b182 --- /dev/null +++ b/planning/changes/2026-07-20.01-open-per-job-child-container.md @@ -0,0 +1,46 @@ +--- +summary: on_job_start now calls child.open() right after building the per-job Scope.REQUEST child, so @inject resolution doesn't raise ContainerClosedError under modern-di 3.x's mandatory-open lifecycle; on_job_end's close_async() is unchanged. +--- + +# Change: Open the per-job request child container + +**Lane:** lightweight — 3 LOC net, 1 file, no new file, no public-API change, +existing tests already cover it (they assert `closed is False` between +`on_job_start` and `on_job_end`). + +## Goal + +modern-di 3.0 made container lifecycle mandatory-open: resolving from (or +building a child of) an unopened container raises `ContainerClosedError`. +`on_job_start` built the per-job `Scope.REQUEST` child but never opened it, +so any `@inject`-decorated task started raising as soon as modern-di 3.x +landed. + +## Approach + +`on_job_start` builds the child and stashes it in `ctx` in one line; the +build and the close live in two different hooks (`on_job_start` / +`on_job_end`), so a `with` block can't span them. Fix: open the child with a +bare `child.open()` right after building it, before stashing it in `ctx`. +`on_job_end`'s existing `close_async()` is untouched — it now closes an +opened child, which is correct. See +`architecture/dependency-injection.md`'s "Per-job scope" section, updated in +this change. + +## Files + +- `modern_di_arq/main.py` — `_wrap_job_start`'s `on_job_start`: build the + child into a local, call `child.open()`, then stash it in `ctx`. +- `architecture/dependency-injection.md` — "Per-job scope" section promoted + to describe the explicit open. + +## Verification + +- [x] Failing test first — `just test-ci` under modern-di 3.0.0: existing + `tests/test_jobs.py`/`tests/test_lifespan.py` assertions on + `ctx["modern_di_request_container"].closed is False` fail (the child is + built already-closed). +- [x] Apply the change — bare `child.open()` in `on_job_start`. +- [x] Test passes — `just test-ci` green. +- [x] `just test-ci` — full suite green, 100% line coverage. +- [x] `just lint-ci` — clean. diff --git a/planning/releases/3.0.0.md b/planning/releases/3.0.0.md new file mode 100644 index 0000000..b0e1fa9 --- /dev/null +++ b/planning/releases/3.0.0.md @@ -0,0 +1,33 @@ +# modern-di-arq 3.0.0 — modern-di 3.x + +Requires **modern-di >= 3, < 4**. **No public API change** — `FromDI`, +`fetch_di_container`, `inject`, and `setup_di` keep their signatures and +behavior. + +## Fix + +- **`on_job_start` now opens the per-job `Scope.REQUEST` child.** modern-di + 3.0 made container lifecycle mandatory-open: resolving from an unopened + container raises `ContainerClosedError`. `on_job_start` builds the child + and `on_job_end` closes it in a *different* hook call, so the two can't + share a `with` block; the fix is a bare `child.open()` right after + `build_child_container`, before the child is stashed in `ctx`. Without + this, every `@inject`-decorated task raised `ContainerClosedError` under + modern-di 3.x. See + [modern-di 3.0's mandatory-open lifecycle](https://modern-di.modern-python.org/migration/to-3.x/). + +## Packaging + +- **Bumped the `modern-di` dependency to `>=3,<4`.** Requires modern-di 3.x; + drops support for modern-di 2.x. + +## Downstream + +Upgrading picks up the fix automatically — no code change needed on the +consumer side. If your own code builds child containers directly, open them +with `with` / `async with` (or `open()`) before resolving — see the +[modern-di 3.x migration guide](https://modern-di.modern-python.org/migration/to-3.x/). + +## Internals + +- 100% line coverage; `ruff`, `ty` clean. diff --git a/pyproject.toml b/pyproject.toml index 076cf4a..213ef3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["arq>=0.25,<1", "modern-di>=2.28,<3"] +dependencies = ["arq>=0.25,<1", "modern-di>=3,<4"] version = "0" [project.urls]