Skip to content
Open
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
2 changes: 2 additions & 0 deletions grpc/config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ import Config
config :logger, level: :info

config :grpc, :dns_adapter, GRPC.Client.Resolver.DNS.MockAdapter

config :grpc, pool_enabled: true
90 changes: 90 additions & 0 deletions grpc/guides/advanced/connection_pool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Connection Pool

The Elixir gRPC client maintains a **connection pool** for every target address. Rather than opening a fresh HTTP/2 connection for each RPC, callers share a fixed set of long-lived connections. This reduces connection overhead, improves throughput under concurrency, and enables fine-grained control over how many connections and concurrent streams your client opens.

The pool is managed transparently: when you call a stub, the client checks out a connection, tracks the open stream count, and returns the connection when the call finishes. All of this happens without any additional code on your side.

---

## How It Works

When `GRPC.Stub.connect/2` is called, a supervised pool of connections is started for the target address. Each connection is an independent HTTP/2 session capable of multiplexing many concurrent streams.

On each RPC call the client:

1. Picks first connection from the pool with number open streams that is still below the configured limit.
2. Increments that connection's open-stream counter and records a lease.
3. Runs the RPC.
4. Decrements the counter and releases the lease when the call completes.

If every connection in the pool has reached `max_streams`, the client opens an **overflow** connection up to `max_overflow` extra connections. Overflow connections aren't discarded once the load subsides, but if pool dies or connections are otherwise dropped we reset to initial count.

---

## Configuration

Pool behaviour is controlled via the `:pool` option passed to `GRPC.Stub.connect/2` or `GRPC.Client.Connection.connect/2`.

| Option | Type | Default | Description |
|:-------------------------|:----------------------------|:--------|:-------------------------------------------------------------------------------------------------------------------|
| `:size` | `non_neg_integer` | `1` | Number of persistent connections kept open. |
| `:max_overflow` | `non_neg_integer or nil` | `0` | Maximum number of extra connections created when the pool is fully saturated. `nil` means no client-side limit. |
| `:max_streams` | `pos_integer or nil` | `nil` | Maximum concurrent streams per connection. `nil` means no client-side limit (server limit applies). |
| `:health_check_enabled` | `boolean` | `false` | When `true`, a periodic gRPC health-check ping is sent on each connection every 10 minutes. |

---

## Examples

### Default pool (single connection)

```elixir
iex> {:ok, channel} = GRPC.Stub.connect("localhost:50051")
iex> {:ok, reply} = channel |> MyService.Stub.my_rpc(request)
```

### Multiple persistent connections

Open three connections upfront to distribute concurrent load:

```elixir
iex> {:ok, channel} = GRPC.Stub.connect("localhost:50051", pool: %{size: 3})
iex> {:ok, reply} = channel |> MyService.Stub.my_rpc(request)
```

### Overflow connections

Keep two persistent connections and allow up to five overflow connections during traffic spikes:

```elixir
iex> {:ok, channel} =
...> GRPC.Stub.connect("localhost:50051",
...> pool: %{size: 2, max_overflow: 5}
...> )
```

### Limiting streams per connection

Cap each connection at 100 concurrent streams. Requests beyond this limit will use a new overflow connection (if allowed):

```elixir
iex> {:ok, channel} =
...> GRPC.Stub.connect("localhost:50051",
...> pool: %{size: 2, max_overflow: 1, max_streams: 100}
...> )
```

---

## Disconnect

Calling `GRPC.Stub.disconnect/1` stops the pool supervisor and closes all underlying connections:

```elixir
iex> {:ok, channel} = GRPC.Stub.connect("localhost:50051")
iex> {:ok, _} = GRPC.Stub.disconnect(channel)
```

The returned channel has `pool: nil`, indicating the pool is no longer active.

---
6 changes: 4 additions & 2 deletions grpc/lib/grpc/channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ defmodule GRPC.Channel do
interceptors: [],
compressor: module(),
accepted_compressors: [module()],
headers: list()
headers: list(),
pool: reference()
}
defstruct host: nil,
port: nil,
Expand All @@ -41,5 +42,6 @@ defmodule GRPC.Channel do
interceptors: [],
compressor: nil,
accepted_compressors: [],
headers: []
headers: [],
pool: nil
end
1 change: 1 addition & 0 deletions grpc/lib/grpc/client/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule GRPC.Client.Application do
def start(_type, _args) do
children = [
{Registry, [keys: :unique, name: GRPC.Client.Registry]},
{Registry, [keys: :unique, name: GRPC.Client.Pool.Registry]},
{DynamicSupervisor, [name: GRPC.Client.Supervisor]}
]

Expand Down
34 changes: 28 additions & 6 deletions grpc/lib/grpc/client/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ defmodule GRPC.Client.Connection do
use GenServer
alias GRPC.Channel
alias GRPC.Client.Connection.EndpointResolver
alias GRPC.Client.Pool

require Logger

Expand Down Expand Up @@ -165,6 +166,14 @@ defmodule GRPC.Client.Connection do
* `:codec` – request/response codec (default: `GRPC.Codec.Proto`)
* `:compressor` / `:accepted_compressors` – message compression
* `:headers` – default metadata headers
* `:pool` – connection pool options (map):
* `:size` – number of persistent connections (default: `1`)
* `:max_overflow` – max extra connections when pool is saturated; `nil` for no limit (default: `0`)
* `:max_streams` – max concurrent streams per connection; `nil` for no client-side limit (default: `nil`)
* `:health_check_enabled` – send periodic gRPC health-check pings (default: `false`)

The pool can be disabled entirely by setting `config :grpc, pool_enabled: false` in your
application config, which restores the pre-pool behaviour (single direct connection per `connect/2` call).
* `:resolve_interval` – DNS re-resolution interval in ms (default: 30000)
* `:max_resolve_interval` – backoff cap in ms (default: 300000)
* `:min_resolve_interval` – rate-limit floor in ms (default: 5000)
Expand Down Expand Up @@ -277,7 +286,7 @@ defmodule GRPC.Client.Connection do
:persistent_term.erase({__MODULE__, channel.ref})
disconnect_real_channels(state.real_channels, adapter)

resp = {:ok, %Channel{channel | adapter_payload: %{conn_pid: nil}}}
resp = {:ok, %Channel{channel | adapter_payload: %{conn_pid: nil}, pool: nil}}
{:reply, resp, state, {:continue, :stop}}
end

Expand Down Expand Up @@ -462,6 +471,10 @@ defmodule GRPC.Client.Connection do
{:via, Registry, {GRPC.Client.Registry, {__MODULE__, ref}}}
end

defp do_disconnect(_adapter, %Channel{pool: pool_ref}) when is_reference(pool_ref) do
Pool.stop_for_address(pool_ref)
end

defp do_disconnect(adapter, channel) do
adapter.disconnect(channel)
rescue
Expand All @@ -488,7 +501,8 @@ defmodule GRPC.Client.Connection do
resolver: GRPC.Client.Resolver,
resolve_interval: @default_resolve_interval,
max_resolve_interval: @default_max_resolve_interval,
min_resolve_interval: @default_min_resolve_interval
min_resolve_interval: @default_min_resolve_interval,
pool: %{size: 1, max_overflow: 0, max_streams: nil, health_check_enabled: false}
)

resolver = Keyword.get(opts, :resolver, GRPC.Client.Resolver)
Expand Down Expand Up @@ -675,15 +689,23 @@ defmodule GRPC.Client.Connection do
defp choose_lb(_), do: GRPC.Client.LoadBalancing.PickFirst

defp connect_real_channel(%Channel{scheme: "unix"} = vc, path, port, opts, adapter) do
%Channel{vc | host: path, port: port}
|> adapter.connect(opts[:adapter_opts])
connect_channel(%Channel{vc | host: path, port: port}, opts, adapter)
end

defp connect_real_channel(%Channel{} = vc, host, port, opts, adapter) do
%Channel{vc | host: host, port: port}
|> adapter.connect(opts[:adapter_opts])
connect_channel(%Channel{vc | host: host, port: port}, opts, adapter)
end

defp connect_channel(%Channel{host: host, port: port} = channel, opts, adapter) do
if pool_enabled?() do
Pool.start_for_address(channel, host, port, opts)
else
adapter.connect(channel, opts[:adapter_opts])
end
end

defp pool_enabled?, do: Application.get_env(:grpc, :pool_enabled, false)

defp init_interceptors(interceptors) do
Enum.map(interceptors, fn
{interceptor, opts} -> {interceptor, interceptor.init(opts)}
Expand Down
71 changes: 71 additions & 0 deletions grpc/lib/grpc/client/pool.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
defmodule GRPC.Client.Pool do
@moduledoc false

alias GRPC.Channel
alias GRPC.Client.Pool.Config

@call_timeout 7_000

@spec start_for_address(Channel.t(), term(), non_neg_integer(), keyword()) ::
{:ok, Channel.t()} | {:error, any()}
def start_for_address(%Channel{} = vc, host, port, norm_opts) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously RPC.Client.Connection was doing this:

defp connect_real_channel(%Channel{scheme: "unix"} = vc, path, port, opts, adapter) do
  %Channel{vc | host: path, port: port}
  |> adapter.connect(opts[:adapter_opts])
end

defp connect_real_channel(%Channel{} = vc, host, port, opts, adapter) do
  %Channel{vc | host: host, port: port}
  |> adapter.connect(opts[:adapter_opts])
end

Should GRPC.Client.Pool.start_for_address/4 also take care of the path vs host thing?

I worry doing this would break for anyone who tries to do something like:

GRPC.Stub.connect("unix:///tmp/grpc.sock")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defp connect_real_channel(%Channel{scheme: "unix"} = vc, path, port, opts, adapter) do
  %Channel{vc | host: path, port: port}
  |> adapter.connect(opts[:adapter_opts])
end

defp connect_real_channel(%Channel{} = vc, host, port, opts, adapter) do
  %Channel{vc | host: host, port: port}
  |> adapter.connect(opts[:adapter_opts])
end

as far as I can tell these two functions are identical - except for second parameter name. That's why I decided to remove it (the resolution happens before this function call)

pool_opts = norm_opts[:pool]
address_pool_ref = make_ref()

config = %Config{
pool_ref: address_pool_ref,
channel: %Channel{vc | host: host, port: port},
pool_size: Map.get(pool_opts, :size, 1),
max_pool_overflow: Map.get(pool_opts, :max_overflow, 0),
max_client_streams_per_connection: Map.get(pool_opts, :max_streams),
adapter_opts: norm_opts[:adapter_opts] || [],
health_check_enabled: Map.get(pool_opts, :health_check_enabled, false)
}

case DynamicSupervisor.start_child(
GRPC.Client.Supervisor,
{GRPC.Client.Pool.Supervisor, config}
) do
{:ok, _} ->
{:ok, %Channel{vc | host: host, port: port, pool: address_pool_ref}}

{:error, reason} ->
{:error, reason}
end
end

@spec stop_for_address(reference()) :: :ok
def stop_for_address(pool_ref) do
case Registry.lookup(GRPC.Client.Pool.Registry, {GRPC.Client.Pool.Supervisor, pool_ref}) do
[{sup_pid, _}] -> Supervisor.stop(sup_pid, :normal)
[] -> :ok
end
rescue
_ -> :ok
end

@spec checkout(reference()) :: {GRPC.Client.Pool.Server.State.Channel.t(), Channel.t()} | nil
def checkout(pool_ref) do
case Registry.lookup(GRPC.Client.Pool.Registry, {GRPC.Client.Pool.Server, pool_ref}) do
[{pool_pid, _}] ->
case GenServer.call(pool_pid, :take_channel, @call_timeout) do
nil -> nil
%GRPC.Client.Pool.Server.State.Channel{channel: channel} = wrapped -> {wrapped, channel}
end

[] ->
nil
end
end

@spec checkin(reference(), GRPC.Client.Pool.Server.State.Channel.t()) :: :ok
def checkin(pool_ref, wrapped_channel) do
case Registry.lookup(GRPC.Client.Pool.Registry, {GRPC.Client.Pool.Server, pool_ref}) do
[{pool_pid, _}] ->
GenServer.cast(pool_pid, {:return_channel, wrapped_channel, self()})

[] ->
:ok
end
end
end
23 changes: 23 additions & 0 deletions grpc/lib/grpc/client/pool/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule GRPC.Client.Pool.Config do
@moduledoc false

defstruct [
:pool_ref,
:channel,
:pool_size,
:max_pool_overflow,
:max_client_streams_per_connection,
:adapter_opts,
:health_check_enabled
]

@type t :: %__MODULE__{
pool_ref: reference(),
channel: GRPC.Channel.t(),
pool_size: non_neg_integer(),
max_pool_overflow: non_neg_integer() | nil,
max_client_streams_per_connection: non_neg_integer() | nil,
adapter_opts: keyword(),
health_check_enabled: boolean()
}
end
35 changes: 35 additions & 0 deletions grpc/lib/grpc/client/pool/health_check/dynamic_supervisor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule GRPC.Client.Pool.HealthCheck.DynamicSupervisor do
@moduledoc """
Supervises health-check GenServers, restarts them in case of crush,
allows starting new ones dynamically
"""

use DynamicSupervisor

alias GRPC.Client.Pool.Config
alias GRPC.Client.Pool.HealthCheck.Server
alias GRPC.Client.Pool.Server.State.Channel

@spec start_link(Config.t()) :: Supervisor.on_start()
def start_link(%Config{} = config) do
DynamicSupervisor.start_link(__MODULE__, config, name: via_tuple(config.pool_ref))
end

@impl DynamicSupervisor
def init(_args), do: DynamicSupervisor.init(strategy: :one_for_one)

@spec start(Channel.id(), Process.dest(), reference()) :: DynamicSupervisor.on_start_child()
def start(channel_id, conn_pid, pool_ref) do
[{dynamic_supervisor_pid, _value}] =
Registry.lookup(GRPC.Client.Pool.Registry, {__MODULE__, pool_ref})

DynamicSupervisor.start_child(
dynamic_supervisor_pid,
{Server, %{channel_id: channel_id, conn_pid: conn_pid, pool_ref: pool_ref}}
)
end

defp via_tuple(pool_ref) do
{:via, Registry, {GRPC.Client.Pool.Registry, {__MODULE__, pool_ref}}}
end
end
Loading