Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion modern_di_arq/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions planning/changes/2026-07-20.01-open-per-job-child-container.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions planning/releases/3.0.0.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down