Skip to content

Commit ecdc4e2

Browse files
feat: a container-owned channel pool for the Dishka providers, shared by every upstream told to borrow it
Closes #31
1 parent ce442c4 commit ecdc4e2

9 files changed

Lines changed: 413 additions & 25 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ names the extra to install.
284284
| `interceptors.metrics` | Counters, latency histograms and an in-flight gauge | core¹ |
285285
| `metrics.GrpcClientMetrics` | The Prometheus collector for them, shaped to join `grpc-server-kit`'s series | `metrics` |
286286
| `health.HealthChecker` | Background `grpc.health.v1` probing with per-target backoff | `health` |
287-
| `dishka.*` | Dishka providers owning the factory's lifecycle, one component per upstream | `dishka` |
287+
| `dishka.*` | Dishka providers owning the factory's lifecycle, one component per upstream, one pool shared by several | `dishka` |
288288
| `protocols` / `validation` / `utils` | Settings seams, target validation, channel and metadata helpers | core |
289289

290290
¹ The metrics interceptor records through `GrpcClientMetricsProtocol` and works
@@ -299,7 +299,7 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
299299
| `metrics` | `prometheus-client` | `GrpcClientMetrics`, the Prometheus collector; a custom registry needs no extra |
300300
| `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
301301
| `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
302-
| `dishka` | `dishka` | `grpc_client_providers` and the three providers behind it |
302+
| `dishka` | `dishka` | `grpc_client_providers`, the three providers behind it, and the pool provider several upstreams share |
303303
| `observability` | `metrics` + `tracing` | both of the above |
304304
| `all` | `deadline` + `dishka` + `health` + `metrics` + `settings` + `tracing` | everything |
305305

@@ -335,7 +335,7 @@ Full documentation at [bedrock-python.github.io/grpc-client-kit](https://bedrock
335335
| [Load balancing](https://bedrock-python.github.io/grpc-client-kit/guide/load-balancing/) | round-robin, random and weighted selection with health filtering |
336336
| [Health](https://bedrock-python.github.io/grpc-client-kit/guide/health/) | `grpc.health.v1` probing, per-target backoff and cold starts |
337337
| [Observability](https://bedrock-python.github.io/grpc-client-kit/guide/observability/) | log records, CLIENT spans and the metrics an RPC emits |
338-
| [Dependency injection](https://bedrock-python.github.io/grpc-client-kit/guide/dependency-injection/) | the Dishka providers, what they own, and one component per upstream |
338+
| [Dependency injection](https://bedrock-python.github.io/grpc-client-kit/guide/dependency-injection/) | the Dishka providers, what they own, one component per upstream, one pool shared by several |
339339
| [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation and ownership |
340340
| [API reference](https://bedrock-python.github.io/grpc-client-kit/reference/) | generated from the source |
341341
| [For AI agents](https://bedrock-python.github.io/grpc-client-kit/agents/) | the whole API surface, the rules that break code when broken and a map of the rest, on one page to hand to a coding assistant |

docs/agents.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,15 +325,22 @@ The label names, their order and the buckets are grpc-server-kit's `GrpcServerMe
325325
|---|---|---|
326326
| `GrpcClientSettingsProvider(settings, *, component=None)` | `GrpcClientSettingsProtocol` | holds the settings object; `BaseGrpcClientSettings` or anything structural |
327327
| `PrometheusGrpcClientMetricsProvider(*, prefix=None, component=None)` | `GrpcClientMetricsProtocol \| None` | `get_grpc_client_metrics(prefix)` when `settings.metrics_enabled`, else `None`; `None` with a warning without `[metrics]` |
328-
| `AsyncGrpcClientProvider(*, shutdown_grace=5.0, ready_timeout=10.0, component=None)` | `GrpcClientFactory` | APP scope, async generator: `async with GrpcClientFactory(settings, metrics=...)` entered on first resolution, left on `container.close()` |
329-
| `grpc_client_providers(settings, *, component=None, metrics_prefix=None, shutdown_grace=5.0, ready_timeout=10.0)` | `tuple[Provider, ...]` | the three above, for `make_async_container(*grpc_client_providers(settings))` |
328+
| `AsyncGrpcClientProvider(*, shutdown_grace=5.0, ready_timeout=10.0, shared_pool=False, component=None)` | `GrpcClientFactory` | APP scope, async generator: `async with GrpcClientFactory(settings, metrics=...)` entered on first resolution, left on `container.close()`; `shared_pool=True` borrows the default component's `ChannelPool` (`FromComponent("")`) instead of building one |
329+
| `AsyncChannelPoolProvider(settings=None, *, metrics=None, shutdown_grace=5.0, component=None)` | `ChannelPool` | APP scope, async generator: the pool shared upstreams borrow, sized by a `ChannelPoolSettingsProtocol`, `close_all(grace=shutdown_grace)` on `container.close()`; `metrics` is handed in, not requested |
330+
| `grpc_client_providers(settings, *, component=None, metrics_prefix=None, shutdown_grace=5.0, ready_timeout=10.0, shared_pool=False)` | `tuple[Provider, ...]` | the first three above, for `make_async_container(*grpc_client_providers(settings))` |
330331

331332
The factory provider requests the settings and the registry through their protocols, so a
332333
container with `AsyncGrpcClientProvider` and no provider of `GrpcClientMetricsProtocol | None`
333334
is refused when it is built. Several upstreams are several Dishka components: one bundle per
334335
upstream with `component="users"`, resolved with `container.get(GrpcClientFactory,
335336
component="users")` or `Annotated[GrpcClientFactory, FromComponent("users")]`; each component
336-
resolves its own settings, and all of them hand out the one cached collector.
337+
resolves its own settings, and all of them hand out the one cached collector. One pool for all
338+
of them is `AsyncChannelPoolProvider(settings.grpc_pool, metrics=get_grpc_client_metrics())` in
339+
the default component and `shared_pool=True` on every bundle: the factories borrow it, the
340+
container drains it once, after them, with the pool provider's grace (the bundle's
341+
`shutdown_grace` then applies to nothing), and each upstream keeps its own settings and its own
342+
health checker. A `shared_pool=True` bundle with no `ChannelPool` in the default component is
343+
refused at build.
337344

338345
### Protocols and helpers
339346

@@ -360,7 +367,7 @@ named beside them:
360367
| `create_aio_channel` | `grpc_client_kit.utils` |
361368
| `BaseGrpcClientSettings`, `BaseConnectivitySettings`, `BaseChannelPoolSettings`, `BaseTimeoutSettings`, `BaseRetrySettings`, `BaseCircuitBreakerSettings`, `BaseWaitForReadySettings`, `BaseDeadlineBudgetSettings`, `BaseLoadBalancerSettings`, `BaseHealthCheckerSettings` | `grpc_client_kit.settings` (needs `[settings]`) |
362369
| `GrpcClientMetrics`, `get_grpc_client_metrics`, `DEFAULT_GRPC_BUCKETS` | `grpc_client_kit.metrics` (needs `[metrics]`) |
363-
| `AsyncGrpcClientProvider`, `GrpcClientSettingsProvider`, `PrometheusGrpcClientMetricsProvider`, `grpc_client_providers` | `grpc_client_kit.dishka` (needs `[dishka]`) |
370+
| `AsyncChannelPoolProvider`, `AsyncGrpcClientProvider`, `GrpcClientSettingsProvider`, `PrometheusGrpcClientMetricsProvider`, `grpc_client_providers` | `grpc_client_kit.dishka` (needs `[dishka]`) |
364371

365372
`ChannelWrapper` and `chain_token` in `grpc_client_kit.channel`, and `MethodCircuitState` in
366373
`grpc_client_kit.interceptors.circuit_breaker`, are internals left out of the public surface on
@@ -633,7 +640,7 @@ Fetch a page when the task is the one named beside it.
633640
| [Native gRPC or the kit?](guide/native-vs-kit.md) | deciding which layer owns LB, retries, idling and health |
634641
| [Health checking](guide/health.md) | the probe loop, cold starts, backoff, status callbacks |
635642
| [Observability](guide/observability.md) | what a log record, a metric sample and a span actually contain, and the shipped Prometheus collector |
636-
| [Dependency injection](guide/dependency-injection.md) | the Dishka providers, what resolving and closing does, one component per upstream |
643+
| [Dependency injection](guide/dependency-injection.md) | the Dishka providers, what resolving and closing does, one component per upstream, one pool shared by several |
637644
| [Advanced](guide/advanced.md) | target validation, plain `grpc.aio` interceptors |
638645
| [API reference](reference/index.md) | an exact signature, field or docstring — HTML only, see above |
639646
| [Changelog](changelog.md) | what changed between versions |

docs/guide/advanced.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,6 @@ The pool is application-scoped, the factory too, and clients are cheap enough
5656
to be request-scoped. `grpc_client_kit.dishka` (the `dishka` extra) ships the
5757
providers that own that lifecycle — resolve `GrpcClientFactory`, and closing
5858
the container closes the pool — with one Dishka component per upstream when a
59-
service has several. See [Dependency injection](dependency-injection.md),
60-
which also spells the two `async with` blocks out for any other container.
59+
service has several, and one pool for all of them when they should share it.
60+
See [Dependency injection](dependency-injection.md), which also spells the
61+
two `async with` blocks out for any other container.

docs/guide/channels.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ rather than handed out — a channel the pool no longer tracks would leak past
227227
shutdown.
228228

229229
A factory handed an existing pool (`GrpcClientFactory(pool=pool)`) borrows it
230-
and never closes it: whoever created the pool closes it. And a factory used
230+
and never closes it: whoever created the pool closes it — in a Dishka
231+
container, [the pool provider](dependency-injection.md#one-pool-for-several-upstreams),
232+
once, after the factories that borrowed it. And a factory used
231233
without `async with` never starts its health checker — `create_client` warns
232234
about exactly that, because the resulting client would route traffic by
233235
unverified health data.

docs/guide/configuration.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,10 @@ USERS_GRPC__SUCCESS_LOG_LEVEL=DEBUG
214214

215215
A variable reaches exactly one section, through its prefix; a bare `TARGET`
216216
or `MAX_ATTEMPTS` in the pod reaches nothing, because no section scrapes the
217-
environment by itself. A block that defaults to `None``retry`,
217+
environment by itself. A pool several upstreams share is a section of its
218+
own — `grpc_pool: BaseChannelPoolSettings` next to them, `GRPC_POOL__*`
219+
handed to [the pool provider](dependency-injection.md#one-pool-for-several-upstreams)
220+
rather than read from any one upstream. A block that defaults to `None``retry`,
218221
`circuit_breaker`, `wait_for_ready`, `deadline_budget`, `balancer`,
219222
`health_checker`, `connectivity` — comes into being the moment one of its
220223
variables is set; `pool` and `timeout` are present by default, and

docs/guide/dependency-injection.md

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@ await container.close() # leaves it: stops the checker, closes the pool
2727
```
2828

2929
`grpc_client_providers(settings, *, component=None, metrics_prefix=None,
30-
shutdown_grace=5.0, ready_timeout=10.0)` returns the three providers below,
31-
which can also be registered one by one:
30+
shutdown_grace=5.0, ready_timeout=10.0, shared_pool=False)` returns the first
31+
three providers below, which can also be registered one by one; the fourth is
32+
registered on its own, by the containers whose upstreams
33+
[share one pool](#one-pool-for-several-upstreams):
3234

3335
| Provider | Provides | Notes |
3436
| :--- | :--- | :--- |
3537
| `GrpcClientSettingsProvider(settings)` | `GrpcClientSettingsProtocol` | Holds the settings object: [`BaseGrpcClientSettings`](configuration.md#from-the-environment) or anything structural |
3638
| `PrometheusGrpcClientMetricsProvider(prefix=None)` | `GrpcClientMetricsProtocol \| None` | The [shipped collector](observability.md#the-shipped-collector) when `settings.metrics_enabled`, `None` otherwise |
37-
| `AsyncGrpcClientProvider(shutdown_grace=5.0, ready_timeout=10.0)` | `GrpcClientFactory` | APP scope, from an async generator: entered on first resolution, left when the container closes |
39+
| `AsyncGrpcClientProvider(shutdown_grace=5.0, ready_timeout=10.0, shared_pool=False)` | `GrpcClientFactory` | APP scope, from an async generator: entered on first resolution, left when the container closes; `shared_pool=True` borrows the default component's `ChannelPool` instead of building one |
40+
| `AsyncChannelPoolProvider(settings=None, metrics=None, shutdown_grace=5.0)` | `ChannelPool` | APP scope, from an async generator: the pool several upstreams share, sized by a `ChannelPoolSettingsProtocol`, drained with `shutdown_grace` when the container closes |
3841

3942
The factory provider *requests* the settings and the registry through their
4043
protocols rather than taking them in its constructor, so a settings object
@@ -101,6 +104,62 @@ collector is the exception by design — it is cached per prefix, so both
101104
components hand out the same instance, and the two upstreams' series are told
102105
apart by the `service` label rather than by separate metrics.
103106

107+
### One pool for several upstreams
108+
109+
Registered that way, each component's factory builds a pool of its own, as it
110+
does outside a container. Upstreams whose retry and timeout policy differ but
111+
whose channels should sit in one pool — one set of
112+
[pool statistics](observability.md#pool-statistics), one section configuring
113+
it — register the pool once, in the default component, and tell each bundle to
114+
borrow it:
115+
116+
```python
117+
from grpc_client_kit.dishka import AsyncChannelPoolProvider, grpc_client_providers
118+
from grpc_client_kit.metrics import get_grpc_client_metrics
119+
120+
container = make_async_container(
121+
AsyncChannelPoolProvider(settings.grpc_pool, metrics=get_grpc_client_metrics()),
122+
*grpc_client_providers(settings.users_grpc, component="users", shared_pool=True),
123+
*grpc_client_providers(settings.orders_grpc, component="orders", shared_pool=True),
124+
)
125+
126+
pool = await container.get(ChannelPool) # the one both factories draw from
127+
```
128+
129+
`AsyncChannelPoolProvider` provides `ChannelPool` in APP scope the way the
130+
factory provider provides the factory: built on first resolution, drained with
131+
`close_all(grace=shutdown_grace)` when the container closes. It is sized by a
132+
`ChannelPoolSettingsProtocol` — a
133+
[`BaseChannelPoolSettings`](configuration.md#from-the-environment) nested at
134+
the top of the service's settings is one deployment-wide section,
135+
`GRPC_POOL__MAX_CHANNELS_PER_TARGET` and `GRPC_POOL__IDLE_TIMEOUT`, next to
136+
the per-upstream ones — and is at the pool's own defaults without one.
137+
`metrics=` is the registry its statistics
138+
are reported into, handed in rather than requested: a container with one
139+
component per upstream has a registry per component and none in the default
140+
one, so there is no single seam for the pool to ask; `get_grpc_client_metrics()`
141+
is the instance the bundles hand out.
142+
143+
A factory with `shared_pool=True` resolves `ChannelPool` from the default
144+
component (`Annotated[ChannelPool, FromComponent("")]`, so anything providing
145+
`ChannelPool` there will do) and is `GrpcClientFactory(pool=...)`: it
146+
[borrows the pool and never closes it](channels.md#ownership-and-shutdown),
147+
and owns nothing but its health checker. The container closes generators in
148+
reverse order of entry, so every factory leaves — stopping its checker —
149+
before the pool is drained, and the pool is drained exactly once. The bundle's
150+
`shutdown_grace` then has nothing to apply to; the pool provider's is the one
151+
in-flight RPCs get. The upstream's own `pool` block is not read either: the
152+
pool was built before the factory asked.
153+
154+
Three things stay per upstream. The settings and the policy they carry, which
155+
is the point. The health checker, because it probes that upstream's
156+
`targets`; two health-checked upstreams sharing a pool are two checkers, each
157+
[marking its own addresses](health.md) in the one pool. And the choice: a
158+
bundle registered without `shared_pool` next to shared ones builds and owns
159+
its own pool as before. A `shared_pool=True` bundle in a container with no
160+
`ChannelPool` in the default component is refused when the container is built,
161+
like a missing registry seam.
162+
104163
## By hand, or with another container
105164

106165
Nothing here is Dishka-specific beyond the provider classes. Any container

0 commit comments

Comments
 (0)