Skip to content

Commit d1e6b9e

Browse files
authored
perf: resolve FromDI without a worker thread (#85)
1 parent 47def59 commit d1e6b9e

3 files changed

Lines changed: 49 additions & 9 deletions

File tree

‎modern_di_faststream/main.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextvars
12
import dataclasses
23
import typing
34
from collections.abc import Awaitable, Callable
@@ -13,11 +14,10 @@
1314

1415
faststream_message_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=faststream.StreamMessage)
1516

16-
# Keys under which the containers live in FastStream's ``ContextRepo``. Each is
17-
# written in one place and read in another; naming them keeps writer and reader
18-
# in provable agreement instead of relying on two matching string literals.
1917
_ROOT_CONTAINER_KEY = "modern_di_root_container"
20-
_REQUEST_CONTAINER_KEY = "modern_di_request_container"
18+
_request_container: contextvars.ContextVar[Container | None] = contextvars.ContextVar(
19+
"modern_di_request_container", default=None
20+
)
2121

2222
_MISSING_REQUEST_CONTAINER = (
2323
"No request container for this message, so the DI middleware did not run for it. "
@@ -56,11 +56,14 @@ async def consume_scope(
5656
async with self.di_container.build_child_container(
5757
scope=match.scope, context=match.context
5858
) as request_container:
59-
with self.context.scope(_REQUEST_CONTAINER_KEY, request_container):
59+
token = _request_container.set(request_container)
60+
try:
6061
return typing.cast(
6162
typing.AsyncIterator[DecodedMessage],
6263
await call_next(msg),
6364
)
65+
finally:
66+
_request_container.reset(token)
6467

6568

6669
def fetch_di_container(app_: faststream.FastStream | AsgiFastStream) -> Container:
@@ -79,7 +82,7 @@ def setup_di(
7982

8083
# Installed on startup rather than here so a broker added after ``setup_di`` is covered,
8184
# and skipped where present so a restart adds no second copy: docs/adr/0002-install-middleware-on-startup.md
82-
def install_middleware() -> None:
85+
async def install_middleware() -> None:
8386
if not app.brokers:
8487
raise RuntimeError(_NO_BROKER_AT_STARTUP)
8588
for broker in app.brokers:
@@ -91,7 +94,7 @@ def install_middleware() -> None:
9194
close_registered = False
9295

9396
# Appended on startup so the close runs after every after_shutdown hook registered before it.
94-
def open_root() -> None:
97+
async def open_root() -> None:
9598
nonlocal close_registered
9699
container.open()
97100
if not close_registered:
@@ -106,8 +109,8 @@ def open_root() -> None:
106109
class Dependency(typing.Generic[T_co]):
107110
marker: integrations.Marker[T_co]
108111

109-
async def __call__(self, context: faststream.ContextRepo) -> T_co:
110-
request_container: Container | None = context.get_local(_REQUEST_CONTAINER_KEY)
112+
async def __call__(self) -> T_co:
113+
request_container = _request_container.get()
111114
if request_container is None:
112115
raise RuntimeError(_MISSING_REQUEST_CONTAINER)
113116
return self.marker.resolve(request_container)

‎tests/test_faststream_di.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import typing
2+
from unittest import mock
23

4+
import anyio.to_thread
35
import faststream
46
import pytest
57
from faststream import TestApp
@@ -33,6 +35,26 @@ async def index_subscriber(
3335
await br.publish("test", TEST_SUBJECT)
3436

3537

38+
async def test_from_di_resolves_without_a_worker_thread(
39+
app: faststream.FastStream, monkeypatch: pytest.MonkeyPatch
40+
) -> None:
41+
broker = typing.cast(NatsBroker, app.broker)
42+
resolved: list[SimpleCreator] = []
43+
44+
@broker.subscriber(TEST_SUBJECT)
45+
async def index_subscriber(instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)]) -> None:
46+
resolved.append(instance)
47+
48+
run_sync = mock.Mock(wraps=anyio.to_thread.run_sync)
49+
async with TestNatsBroker(broker) as br, TestApp(app):
50+
monkeypatch.setattr(anyio.to_thread, "run_sync", run_sync)
51+
await br.publish(None, TEST_SUBJECT)
52+
monkeypatch.undo()
53+
54+
assert [type(instance) for instance in resolved] == [SimpleCreator]
55+
assert run_sync.call_count == 0
56+
57+
3658
async def test_context_adapter(app: faststream.FastStream) -> None:
3759
broker = typing.cast(NatsBroker, app.broker)
3860

‎tests/test_lifespan.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import typing
22
from collections.abc import Callable
3+
from unittest import mock
34

5+
import anyio.to_thread
46
import faststream
57
import pytest
68
from faststream import TestApp
@@ -146,3 +148,16 @@ def user_hook() -> None:
146148
assert [(instance.finalized, at_hook) for instance, at_hook in seen] == [(1, 0)] * cycle
147149
assert container.closed
148150
assert container.close_calls == cycle
151+
152+
153+
async def test_startup_and_shutdown_run_no_worker_thread(
154+
app: faststream.FastStream, monkeypatch: pytest.MonkeyPatch
155+
) -> None:
156+
run_sync = mock.Mock(wraps=anyio.to_thread.run_sync)
157+
monkeypatch.setattr(anyio.to_thread, "run_sync", run_sync)
158+
broker = typing.cast(NatsBroker, app.broker)
159+
160+
async with TestNatsBroker(broker), TestApp(app):
161+
pass
162+
163+
assert run_sync.call_count == 0

0 commit comments

Comments
 (0)