Infrastructure - Poolboy Integration
This document provides a detailed breakdown of how the foundation
library integrates poolboy
for connection pooling, contrasting the direct poolboy
pattern with the abstractions provided by the ConnectionManager
.
Overview
The standard poolboy
integration pattern involves directly calling :poolboy.start_link/2
with a specific configuration keyword list. The foundation
library abstracts this process within the ConnectionManager
module to provide validation, logging, and a more user-friendly configuration format.
The foundation
library adheres to this pattern but provides a robust abstraction layer through its Infrastructure
and ConnectionManager
modules. This abstraction offers several advantages:
- Centralized Control: Simplifies management of multiple connection pools.
- Configuration Translation: Converts simple config formats into the specific keyword lists that
:poolboy.start_link
expects. - Safety: Wraps the checkout/checkin lifecycle in safe functions that prevent resource leakage.
- Observability: Automatically emits telemetry events for pool operations and performance metrics.
The Integration in Detail
1. Starting a Pool: poolboy:start_link
The standard poolboy
pattern involves directly calling :poolboy.start_link/2
with a specific configuration keyword list. The foundation
library abstracts this process within the ConnectionManager
module to provide validation, logging, and a more user-friendly configuration format.
File: foundation/infrastructure/connection_manager.ex
Functions: start_pool/2
, do_start_pool/2
, build_poolboy_config/2
# foundation/infrastructure/connection_manager.ex
# --- The Public API ---
@spec start_pool(pool_name(), pool_config()) :: {:ok, pid()} | {:error, term()}
def start_pool(pool_name, config) do
GenServer.call(__MODULE__, {:start_pool, pool_name, config})
end
# --- The Private Implementation ---
@spec do_start_pool(pool_name(), pool_config()) :: {:ok, pid()} | {:error, term()}
defp do_start_pool(pool_name, config) do
# ... (validation logic) ...
{poolboy_config, worker_args} = build_poolboy_config(pool_name, config)
# --- THIS IS THE KEY LINE ---
case :poolboy.start_link(poolboy_config, worker_args) do
{:ok, pid} -> {:ok, pid}
{:error, reason} -> {:error, reason}
end
# ...
end
# --- Configuration Translation ---
@spec build_poolboy_config(pool_name(), pool_config()) :: {keyword(), keyword()}
defp build_poolboy_config(pool_name, config) do
merged_config = Keyword.merge(@default_config, config)
poolboy_config = [
name: {:local, pool_name}, # `poolboy` requires the {:local, name} format
worker_module: Keyword.fetch!(merged_config, :worker_module),
size: Keyword.get(merged_config, :size),
max_overflow: Keyword.get(merged_config, :max_overflow),
strategy: Keyword.get(merged_config, :strategy)
]
worker_args = Keyword.get(merged_config, :worker_args, [])
{poolboy_config, worker_args}
end
Analysis:
- Abstraction: Instead of calling
:poolboy.start_link
directly, users call the much cleanerConnectionManager.start_pool(:my_pool, [...])
. - Configuration: The
build_poolboy_config
function acts as a translator. It takesfoundation
's simple config format and transforms it into the specific keyword list that:poolboy.start_link
expects, including enforcing thename: {:local, pool_name}
convention. - Validation: Before starting the pool,
ConnectionManager
validates the configuration (validate_pool_config
) and ensures the worker module exists and is compiled (validate_worker_module
), providing better error messages thanpoolboy
might alone. - Centralization: All pools are managed by the
ConnectionManager
GenServer, which keeps track of their state and configuration in a centralized location.
2. The Worker Module Concept
Poolboy
requires a "worker" module—a GenServer
that implements a start_link/1
function. The foundation
library fully adheres to this pattern and provides a sample implementation with HttpWorker
.
File: foundation/infrastructure/pool_workers/http_worker.ex
Function: start_link/1
# foundation/infrastructure/pool_workers/http_worker.ex
@doc """
Starts an HTTP worker with the given configuration.
This function is called by Poolboy to create worker instances.
"""
@spec start_link(worker_config()) :: GenServer.on_start()
def start_link(config) do
GenServer.start_link(__MODULE__, config)
end
# ... (GenServer implementation for the worker)
Analysis:
- The
HttpWorker.start_link/1
function is the entry point thatpoolboy
will call whenever it needs to create a new worker for the pool. - The
worker_args
from theConnectionManager.start_pool
configuration are passed directly as theconfig
argument to thisstart_link
function. This is how you provide initial state (like a base URL or API keys) to each worker. - This demonstrates that
foundation
's abstraction does not change the fundamental contract required of apoolboy
worker.
3. Using a Pooled Resource: checkout
and checkin
The most significant abstraction ConnectionManager
provides is wrapping the checkout
/checkin
lifecycle. The standard poolboy
pattern requires developers to manually:
-
:poolboy.checkout(...)
to get a worker. - Use the worker inside a
try/after
block. -
:poolboy.checkin(...)
in theafter
clause to guarantee the worker is returned to the pool, even if the main operation fails.
ConnectionManager
encapsulates this entire error-prone process in a single, safe function.
File: foundation/infrastructure/connection_manager.ex
Functions: with_connection/3
, do_with_connection/4
# foundation/infrastructure/connection_manager.ex
# --- The Public API ---
@spec with_connection(pool_name(), (pid() -> term()), timeout()) ::
{:ok, term()} | {:error, term()}
def with_connection(pool_name, fun, timeout \\ @default_checkout_timeout) do
# ... (timeout logic) ...
GenServer.call(__MODULE__, {:with_connection, pool_name, fun, timeout}, genserver_timeout)
end
# --- The Private Implementation ---
@spec do_with_connection(pool_name(), pid(), (pid() -> term()), timeout()) ::
{:ok, term()} | {:error, term()}
defp do_with_connection(pool_name, pool_pid, fun, timeout) do
start_time = System.monotonic_time()
try do
# --- CHECKOUT ---
worker = :poolboy.checkout(pool_pid, true, timeout)
emit_telemetry(
:checkout,
%{ checkout_time: System.monotonic_time() - start_time },
%{pool_name: pool_name}
)
try do
# --- EXECUTE USER'S FUNCTION ---
result = fun.(worker)
{:ok, result}
# ... (error handling) ...
after
# --- CHECKIN (GUARANTEED) ---
:poolboy.checkin(pool_pid, worker)
emit_telemetry(:checkin, %{}, %{pool_name: pool_name})
end
catch
# ... (timeout and other exit handling) ...
end
end
Analysis:
- Abstraction: The developer calls
ConnectionManager.with_connection/2
and simply provides a function that receives the checked-outworker
as an argument. They never see or call:poolboy.checkout
or:poolboy.checkin
. - Safety: The
try/after
block insidedo_with_connection
is the crucial safety mechanism. It guarantees that:poolboy.checkin
is always called for the worker, preventing pool resource leakage even if the user's function (fun.()
) crashes. - Observability: The wrapper emits
:checkout
and:checkin
telemetry events, providing valuable insight into pool usage, checkout times, and potential bottlenecks, which would have to be implemented manually otherwise. - Error Handling: The
catch
block provides standardized error handling for commonpoolboy
exit reasons, like:timeout
, translating them into clean{:error, reason}
tuples.
End-to-End Workflow
This diagram visualizes the end-to-end flow, from starting a pool to using it via the ConnectionManager
.
sequenceDiagram
participant App as "Application Code"
participant CM as "ConnectionManager"
participant Poolboy as ":poolboy"
participant Worker as "Worker Module"
App->>+CM: start_pool(:my_pool, config)
CM->>CM: build_poolboy_config(config)
CM->>+Poolboy: :poolboy.start_link(poolboy_config, worker_args)
Poolboy->>+Worker: start_link(worker_args)
Worker-->>-Poolboy: {:ok, worker_pid}
Poolboy-->>-CM: {:ok, pool_pid}
CM-->>-App: {:ok, pool_pid}
App->>+CM: with_connection(:my_pool, fun)
CM->>+Poolboy: checkout()
Poolboy-->>-CM: worker_pid
Note right of CM: Emits :checkout telemetry
CM->>+Worker: fun(worker_pid)
Worker-->>-CM: result
CM->>+Poolboy: checkin(worker_pid)
Note right of CM: Emits :checkin telemetry
Poolboy-->>-CM: :ok
CM-->>-App: {:ok, result}
Comparison Summary
This table summarizes how foundation
implements the standard poolboy
patterns.
poolboy Documentation Pattern | foundation Library Implementation |
---|---|
Call :poolboy.start_link with specific config. | Call ConnectionManager.start_pool . It handles config translation, validation, and logging. |
Implement a worker_module with start_link/1 . | This is the same. The contract for the worker is unchanged. HttpWorker is provided as an example. |
Manually wrap work in try...after with :poolboy.checkout and :poolboy.checkin . | Call ConnectionManager.with_connection and provide a function. The wrapper handles the entire checkout /checkin lifecycle and associated error handling. |
Implement telemetry and logging for pool events manually. | Telemetry for checkouts, check-ins, and timeouts is built into the ConnectionManager wrapper. |
Manage multiple pools manually. | ConnectionManager is a centralized GenServer that manages the state and configuration of all active pools. |
By using these abstractions, foundation
provides a more integrated, observable, and developer-friendly way to leverage the power of poolboy
's connection pooling capabilities.