arterial is a high-performance Erlang/Elixir connection-pooling library for
clients that talk to a server (or cluster of servers) over SSL/TCP/UDP.
The design is structurally a shared-nothing/lock-free concurrent model.
The pool, per-connection backlog tracking, and rate throttling are implemented
in a C++ NIF for performance; connection lifecycle, supervision, and socket
I/O stay on the Erlang side.
It's protocol-agnostic: you supply a module that knows how to encode requests and decode responses for your wire protocol, and Arterial handles checking out a connection, tracking in-flight requests, throttling, reconnecting on failure, and timing out asynchronous requests that never get a reply.
Supported features:
- Lock-free, atomic CAS–based connection pool (C++ NIF) with FIFO checkout order and ABA-safe versioned node indices.
- Per-connection request backlog, in either FIFO order (no wire-level request ID required) or random-access order (wire-level request ID, out-of-order replies supported), up to 65,536 in-flight requests per connection.
- Per-connection rate throttling (token-bucket style) checked on every checkout.
- A synchronous request API (
arterial_client:call/3) where the caller owns the socket for the duration of one request/reply, and a send-and-forget API (arterial_client:cast/2) for protocols with no reply at all -- returns as soon as the request is handed to the transport, without ever holding a backlog slot in flight. - An in-flight request registry (
UnorderedTTLMap, owned by each pool'sConnectionPoolinstance) for the asynchronous path: register a request witharterial_nif:track_inflight/5, and if no matchingcheckin_connection/4happens before its TTL expires, the owning process receives{arterial_timeout, Pool, ReqID}and the request's backlog slot (and the connection itself) is automatically released back to the pool. TTLs can be either a single fixed duration per pool or set individually per request. - An optional, lock-free, bounded queue-when-busy wait-list
(
arterial_nif:checkout_async/3): when every connection is busy, a caller can be queued (up to a fixed capacity set at pool creation) instead of failing immediately, and is serviced automatically as soon as a connection frees up, or timed out if it waits too long. Disabled by default (zero behavioral/cost impact unless explicitly enabled); see Queue-when-busy checkouts. - A per-pool
arterial_sweeperprocess that periodically evicts expired in-flight requests and expired queued waiters by callingarterial_nif:sweep_timeouts/1. - Automatic reconnect with exponential backoff per connection
(
arterial_connection). - Smart DNS resolution with Kubernetes awareness: Enhanced hostname
resolution that automatically detects and handles Kubernetes service names,
supports search domain expansion (
redis→redis.namespace.svc.cluster.local), environment-aware caching (60s TTL for K8s services, 300s for external), and maintains full backwards compatibility. See DNS Resolution and Kubernetes for details. - Disconnect notification for the asynchronous path: when a connection's
socket dies, every process with a still-outstanding (
track_inflight/5- registered) request on it receives{arterial_disconnected, Pool, ReqID}-- one message per request -- before the backlog slot is released, so it can resubmit or persist that request outsidearterialinstead of silently losing it. Seearterial_nif:connection_down/2. - An optional, per-pool
arterial_bouncerthat recycles connections one at a time on a fixed rotation (bounce_interval_ms) -- e.g. to pick up DNS/load-balancer changes for long-lived connections -- waiting for each connection's backlog to drain (up tobounce_drain_timeout_ms) before disconnecting and reconnecting it. Disabled by default. - Six supported request/reply matching modes covering all common wire protocol patterns: native/surrogate request IDs with random-access backlogs, FIFO-ordered matching (single or multi-request), send-and-forget, and acknowledgement-based protocols. See Operating Modes and Protocol for detailed descriptions.
- Pluggable observability:
call/cast/checkout/connectspans plusdisconnect/sweepevents through a generic facade (arterial_observe), with built-intelemetryandprometheusbackends (both strictly optional dependencies) or a custom backend module -- see Observability.
Add arterial as a hex.pm package dependency:
Erlang: in your project's rebar.config
{deps, [
{:arterial, "~> 0.1"}
]}.Elixir: in your project's mix.exs
def deps do
[
{:arterial, "~> 0.1"}
]
endArterial supports six distinct request/reply matching modes to accommodate different wire protocol patterns. Choose the mode that best fits your protocol's message structure and reply ordering guarantees:
Best for: Protocols with built-in request/correlation IDs (32-bit or 64-bit)
Wire messages contain a native request ID field. Replies can arrive out-of-order and are matched by ID. Supports up to 64k concurrent requests per connection.
- Configuration:
fifo => false,backlogup to 65,536 - Use case: HTTP/2, gRPC, custom protocols with correlation IDs
- Performance: Highest throughput with full async multiplexing
- Implementation:
arterial_client:call/3+arterial_codecbehavior
Best for: Protocols without native request IDs but with repurposable fields
Similar to mode 1, but your encode_request/3 injects a synthetic ID into an extensible field (header, tag, cookie) that the server echoes back. Same random-access matching and performance characteristics.
- Configuration:
fifo => false,backlogup to 65,536 - Use case: Protocols with opaque fields, custom headers, or client cookies
- Performance: Same as mode 1 - full async multiplexing
- Implementation:
arterial_client:call/3+ customencode_request/3
Best for: Simple protocols without request IDs, one request at a time
No request ID available. Client reserves connection for one request, sends synchronously, awaits reply in FIFO order. Connection is owned for the entire request duration.
- Configuration:
fifo => true,backlog => 1 - Use case: Simple TCP protocols, legacy systems, debugging
- Performance: Lower throughput due to connection monopolization
- Status: Requires FIFO backlog implementation in NIF layer
Best for: Protocols without IDs but with guaranteed FIFO reply ordering
Multiple requests can be outstanding on one connection, matched purely by send order. Requires server guarantee of FIFO reply ordering - violations silently corrupt the backlog.
- Configuration:
fifo => true,backlog > 1 - Use case: Redis pipeline, protocols with strict ordering guarantees
- Performance: Good throughput but depends on server FIFO compliance
- Status: Requires FIFO backlog implementation + connection reservation
⚠️ Warning: Single out-of-order reply breaks all subsequent matches
Best for: Fire-and-forget operations like logging, metrics, notifications
Uses arterial_client:cast/2 instead of call/3. Returns immediately after transport accepts the bytes - no backlog slot allocated, no reply expected.
- Configuration: Backlog/FIFO settings irrelevant
- Use case: Logging, metrics, notifications, pub/sub publishing
- Performance: Highest throughput - no reply processing overhead
- Implementation:
arterial_client:cast/2
Best for: Protocols using acknowledgements instead of data replies
Two variants with different implementation status:
Per-message ACK ✅ IMPLEMENTED: Server replies with simple acknowledgement (with or without ID). Uses same matching as modes 1/2 - your decode_reply/2 returns ack or similar term.
Bulk/Cumulative ACK ❌ NOT IMPLEMENTED: Single acknowledgement confirms multiple previous requests (e.g., "processed through sequence N"). Would use arterial_nif:checkin_up_to/3 to release multiple FIFO slots at once.
- Configuration: Per-message:
fifo => false; Bulk:fifo => true - Use case: Message queues, streaming protocols, batch processing
- Performance: Reduced reply traffic, efficient for high-volume streams
- Status: Per-message ACK works via modes 1/2; bulk ACK needs FIFO implementation
✅ Currently Available (3/6 modes):
- Mode 1: Native Request ID - Full async multiplexing
- Mode 2: Surrogate Request ID - Full async multiplexing
- Mode 5: Send-and-Forget - No reply needed
❌ Requires Implementation (2.5/6 modes):
- Mode 3: FIFO Single Request - Needs FIFO backlog + connection reservation
- Mode 4: FIFO Multi-Request - Needs FIFO backlog + pipelining support
- Mode 6 (partial): Bulk/Cumulative ACK - Needs
checkin_up_to/3+ FIFO support
The existing arterial_pool backend (NIF-based) explicitly supports only random-access protocols with correlation IDs (modes 1/2/5). To implement the missing FIFO modes (3/4/6-bulk), arterial would need:
- FIFO backlog data structures in the C++ NIF layer
- Connection reservation mechanisms for single-request ownership
- Order-preserved request/reply matching for pipelined protocols
- Batch checkin operations for cumulative acknowledgements
- Have native request IDs? → ✅ Use Mode 1
- Can inject synthetic IDs? → ✅ Use Mode 2
- No replies needed? → ✅ Use Mode 5
- Simple ACKs with IDs? → ✅ Use Mode 6 (per-message variant)
- No IDs, simple protocol? → ❌ Mode 3 (planned)
- No IDs, need pipelining? → ❌ Mode 4 (planned)
- Bulk acknowledgements? → ❌ Mode 6-bulk (planned)
For complete implementation details, configuration examples, and advanced features, see the Protocol section.
flowchart TD
AS[fa:fa-person-military-pointing arterial_sup] -->|one_for_one| NIF(fa:fa-table arterial_nif)
AS -->|one_for_one| OBS(fa:fa-chart-line arterial_observe)
AS -->|one_for_one| PS[fa:fa-person-military-pointing arterial_pool_sup]
PS -->|simple_one_for_one| P1[fa:fa-layer-group arterial_pool pool1]
PS -.->|simple_one_for_one| PN[fa:fa-layer-group arterial_pool poolN]
subgraph pool [one arterial_pool supervisor]
P1 -->|one_for_one| C1(fa:fa-link arterial_connection 0)
P1 -->|one_for_one| C2(fa:fa-link arterial_connection 1)
P1 -->|one_for_one| CN(fa:fa-link arterial_connection N)
P1 -->|one_for_one| SW(fa:fa-broom arterial_sweeper)
P1 -.->|optional| BN(fa:fa-repeat arterial_bouncer)
end
B>fa:fa-arrow-down-9-1 ConnectionPool NIF resource] -.-> P1
style B stroke-width:1px,color:#666,stroke-dasharray: 5 5
C1 o-.-o S([Server])
C2 o-.-o S
CN o-.-o S
arterial_sup is the application's static one_for_one top supervisor,
with three permanent children: arterial_nif (the singleton owner of
every pool's per-connection buffer ETS table), arterial_observe (owns
the configured observability backend's lifecycle — see
Observability — a no-op if none is configured), and
arterial_pool_sup, a simple_one_for_one supervisor that spawns one
arterial_pool supervisor per named pool started via
arterial_pool:start_link/2.
Each arterial_pool supervisor creates the pool's C++-backed
ConnectionPool NIF resource (which tracks which connections are
checked out/available, their per-connection request backlog, throttles,
and in-flight async request registry — drawn as the dashed box above,
since it's NIF-resident state the supervisor owns a reference to, not a
supervised child), then starts one arterial_connection worker per
connection slot, one arterial_sweeper that periodically evicts
timed-out in-flight requests, and — only if the pool's
bounce_interval_ms option is set — one arterial_bouncer that
recycles connections on a rotation (see arterial_bouncer's moduledoc
for why and how).
| Module | Responsibility |
|---|---|
arterial_app |
The arterial OTP application; defines arterial_sup, the top-level one_for_one supervisor (arterial_nif + arterial_observe + arterial_pool_sup). |
arterial_pool |
Per-pool supervisor; creates the NIF pool resource, then starts one arterial_connection worker per connection slot, one arterial_sweeper, and (if configured) one arterial_bouncer. |
arterial_connection |
One gen_server per pooled connection. Owns the socket, runs the connect/reconnect state machine with exponential backoff, and drives the user-supplied client callback module (arterial_client). |
arterial_sweeper |
Per-pool timer process; calls arterial_nif:sweep_timeouts/1 on an interval to evict expired in-flight async requests. |
arterial_bouncer |
Optional per-pool timer process; recycles connections one at a time on a rotation via arterial_connection:bounce/2, only started when bounce_interval_ms is configured. |
arterial_observe |
Generic, pluggable observability facade (span/3/event/2,3); dispatches to a configurable backend module — see Observability. |
arterial_observe_telemetry / arterial_observe_prometheus |
Built-in arterial_observe backends, forwarding to telemetry or recording directly into Prometheus metrics, respectively. |
arterial_socket |
Thin wrapper over Erlang's socket module for tcp/udp connect, plus ssl (requires OTP 28+ — see SSL/TLS guide). |
arterial_client |
Behaviour for the connection lifecycle/codec (init/1, setup/2, handle_request/2, handle_data/2, handle_timeout/2, terminate/2); also implements call/3 (synchronous request API) and cast/2 (send-and-forget API). |
arterial_protocol |
Behaviour for the wire transport and per-request codec (connect/3, send/2, recv/2, encode_request/3, decode_reply/2). |
arterial_proxy |
Not yet working. Originally intended as the asynchronous request/queuing path; largely superseded by arterial_nif:checkout_async/3 (see Implementation status) — whether this module still needs a rewrite is an open follow-up. |
arterial_nif |
Thin Erlang wrapper around the C++ NIF (pool create/destroy, checkout/checkin, checkout_async, availability, in-flight tracking, sweep). |
arterial_util |
Small helpers: random element selection, timeout/expiration arithmetic. |
arterial |
Shared public types (client/0, socket/0, protocol/0, etc). |
| File | Responsibility |
|---|---|
arterial.hpp / arterial.cpp |
ConnectionPool: wraps the lock-free FIFO pool of connections, the in-flight async request registry, the owner table, and the optional queue-when-busy wait-list; NIF entry points (create_nif, checkout_nif, checkin_nif, checkin_up_to_nif, checkout_async_nif, make_available_nif/make_unavailable_nif, connection_drained_nif, connection_down_nif, track_inflight_nif, sweep_timeouts_nif). |
connection.hpp |
Connection: one pooled connection's id, socket term, request backlog, and vector of throttles. |
backlog.hpp |
Per-connection in-flight request tracking — see Backlog modes. |
pool_fifo.hpp |
BaseObjectPoolFIFO/ObjectPoolFIFO<T>: a lock-free, atomic CAS–based Vyukov-style ring-buffer object pool used to hand out Connection* objects with CheckOut/CheckIn/MakeAvailable/MakeUnavailable semantics and ABA-safe versioned node indices. |
wait_list.hpp |
WaitList: a bounded, lock-free MPMC ring buffer (Vyukov-style) of queued checkout_async callers, used by the optional queue-when-busy feature — see Queue-when-busy checkouts. |
owner_table.hpp |
OwnerTable: a fixed-capacity, lock-free, open-addressing table mapping a checked-out connection's owning ErlNifPid to its held backlog reservations, used to auto-release a connection if its owning process dies. |
throttle.hpp |
basic_time_spacing_throttle<T> — a token-bucket-style rate limiter (ported from utxx's rate_throttler) that checks elapsed time on each call instead of running a timer thread. |
hashmap_with_ttl.hpp |
UnorderedTTLMap: hash map + sorted LRU eviction list, used to track in-flight asynchronous requests and their deadlines (either one fixed TTL per pool, or a per-request deadline kept sorted on insert); also used to track deadlines for queued waiters. |
sharded_ttl_map.hpp |
ShardedTTLMap: a concurrency-safe wrapper partitioning an UnorderedTTLMap across N independently-locked shards, so callers touching different keys never contend with each other. |
arterial_util.hpp |
Small bit-twiddling helpers (e.g. RoundUpPow2) shared across the lock-free/sharded data structures above. |
enif.hpp |
Generic C++/Erlang NIF interop helpers (term conversion, resource wrapping) — a fork of the third-party nifpp library. |
test_pool.cpp |
Standalone tests for the FIFO object pool. |
ConnectionPool::CheckOut (c_src/arterial.hpp) walks the FIFO pool of
connections; for each candidate it checks whether all of the connection's
throttles currently have room for the requested number of samples, and
whether its backlog has free request slots. If both checks pass, the slot(s)
are reserved and the connection is returned to the caller; otherwise the
connection is checked back in at the tail of the pool and the next one is
tried, up to one full pass over the pool.
A connection's backlog tracks requests that have been sent but not yet
answered. Two implementations exist, chosen per the wire protocol's
capabilities (backlog.hpp):
FIFOBackLog— for protocols whose messages carry no request ID. Replies are assumed to arrive in the same order requests were sent; checkout/checkin happens at the head/tail of a circular buffer.RandomAccessBackLog— for protocols that embed a request ID in each message, so replies can come back out of order. Slots are tracked in astd::setof free indices and looked up by decoded request ID on checkin.
Both are bounded by a 16-bit internal index (BaseReqID), so a connection
supports at most 65,536 in-flight requests.
Each ConnectionPool owns an UnorderedTTLMap<ReqID, {pid, Connection*}> registry, separate from the per-connection backlog, keyed by
the request's wire-level ext_req_id:
- After checking out a connection for an asynchronous request, call
arterial_nif:track_inflight(Pool, ConnID, ReqID, self(), TtlUs)to register it.TtlUsis ignored if the pool was created with a fixed timeout (create/6's last argument); otherwise each request keeps its own deadline, and the registry's eviction list stays sorted by deadline regardless of insertion order. - If a reply arrives, call
checkin_connection/4as usual — this also untracks the request from the registry. - If nothing calls
checkin_connection/4before the deadline, the nextarterial_nif:sweep_timeouts/1call (run periodically byarterial_sweeper) sends{arterial_timeout, Pool, ReqID}to the owning process and releases the request's backlog slot and returns the connection to the pool — equivalent to whatcheckin_connection/4would have done, just triggered by the timeout instead of an explicit checkin.
By default, checking out a connection when every connection in the pool is
busy (throttled or backlog-full) fails immediately with
{error, no_connection} — this is unchanged and is what both
checkout_connection/2 and checkout_async/3 do when the feature described
here is not enabled.
arterial_nif:checkout_async/3 adds an optional alternative: instead of
failing immediately, the caller can be queued and serviced automatically as
soon as capacity frees up. The feature is opt-in per pool, controlled by
create/7's MaxWaiters argument (0, the default via create/5 and
create/6, disables it entirely at effectively zero cost — a disabled
WaitList never allocates more than a single dummy ring slot and every
checkout_async/3 call behaves exactly like checkout_connection/2).
The wait-list itself (c_src/wait_list.hpp) is a fixed-capacity, lock-free
MPMC ring buffer (Vyukov-style) of queued callers — bounded so it can never
grow without limit, and built independently of pool_fifo.hpp because that
structure is unsafe under repeated claim/release cycling (see the comment at
the top of wait_list.hpp for why).
checkout_async(Pool, Pid, TtlUs) has three possible outcomes:
- A connection is available immediately — returns
{ok, Map}, exactly likecheckout_connection/2. No message follows. - Every connection is busy, but the wait-list has room — the request is
queued and
{queued, WaiterID}is returned immediately.Pidwill later receive exactly one of:{arterial_ready, Pool, ReqID, ConnID, Socket, ReqIDs}once a connection is assigned — callcheckin_connection/4with the real wire-levelReqID/ReqIDsfrom this message when done, exactly as for a synchronous checkout.{arterial_timeout, Pool, WaiterID}ifTtlUsmicroseconds pass while still queued, without ever being assigned a connection.
- Every connection is busy and the wait-list is disabled or full —
returns
{error, no_connection}, same ascheckout_connection/2.
TtlUs bounds the entire call, from the moment checkout_async/3 is
invoked until a reply is checked in — covering both time spent queued and
time spent in-flight once a connection is assigned. It's ignored (like
track_inflight/5's TtlUs) if the pool uses a fixed per-pool timeout.
Queued waiters are serviced from two places, both of which drain the wait-list in FIFO order (oldest queued caller first):
checkin_connection/2,4— after releasing a connection back to the pool, it immediately tries to service the head of the wait-list with that connection before the connection is offered to any new, non-queued checkout.sweep_timeouts/1— besides evicting expired in-flight requests (see above), it also evicts expired queued waiters (sending them{arterial_timeout, Pool, WaiterID}), and any connection capacity freed up by evicting a different, timed-out in-flight request is immediately offered to the head of the wait-list.
It is the responsibility of the user to provide a module for encoding and decoding messages over the selected socket transport. Which configuration to use depends entirely on what the wire protocol's messages look like. There are 6 supported modes:
-
Native request ID. Each wire-level message contains a 32-bit (or 64-bit) request ID, and replies are not required to arrive in FIFO order. Use
RandomAccessBackLog(fifo => false), anybacklogvalue below 65,536 (the 16-bit internal index cap — see Backlog modes). Requests can be sent asynchronously, with up to 64k in-flight per connection. -
Surrogate request ID. Same as above, but for protocols that don't define a "request ID" concept of their own and instead expose some extensible/free-form field your
encode_request/3can repurpose as one (a correlation tag, an unused header word, an opaque client cookie the server is required to echo back unmodified).arterialdoesn't care whether the ID is one the protocol natively defines or one you synthesize and inject yourself —RandomAccessBackLogmatches replies purely by the bits inext_req_id, with no notion of which side "owns" the ID's meaning. Samefifo => falseconfiguration as mode 1; see Client Implementation Guide's "Surrogate request IDs" for a worked example. -
FIFO, backlog = 1 (synchronous reservation). Wire-level messages don't encode a request ID (and no field can be repurposed as one), so replies are matched purely by FIFO send order. A client reserves the connection for the lifetime of one request and awaits the response synchronously — no concurrent in-flight requests, so no backlog capacity is needed beyond the default of 1.
-
FIFO, backlog > 1 (asynchronous multiplexing). Same FIFO-order matching as mode 3, but with
backlog > 1, fifo => trueso several requests can be outstanding on one connection at once. This depends on a guaranteearterialcannot verify: the server must actually preserve send order in its replies.FIFOBackLog::CheckInalways releases the oldest outstanding slot — it has no way to detect a server that violates this (e.g. by replying out of order once), and a single violation silently misattributes every reply after it with no error raised anywhere. Prefer mode 1 or 2 whenever the protocol allows it; reserve this mode for protocols that truly have no ID-capable field, and consider pairing it with a shortfixed_timeout_usso a confused backlog at least self-heals via the sweeper instead of wedging indefinitely.To actually multiplex several outstanding requests on one connection, reserve them all in a single call:
arterial_nif:checkout_connection/3andarterial_nif:checkout_async/4take aSamplesargument that reserves that many backlog slots on one connection at once, returning that manyreq_ids. This is required, not just convenient: a connection is removed from the pool's available set on its first successful checkout and isn't returned until something checks it back in, so repeated single-sample (Samples = 1, the/2//3-arity default) checkouts against an idle connection never land on the same connection twice in a row — they get a different connection or, if none are free, queue/fail. Seearterial_nif:checkout_connection/3's andcheckout_async/4's docs for the full contract, andtest/arterial_nif_tests.erl'sfifo_backlog_multiplexing_test/0/fifo_backlog_multiplexing_async_test/0for worked examples. -
Send-and-forget (no reply at all). Use
arterial_client:cast/2instead ofcall/3— it hands the request tosend/2and returns as soon as the bytes are accepted by the transport, never blocking on (or even allocating a backlog slot for) a reply. Since nothing is ever in-flight, none of the backlog-mode tradeoffs above apply:backlog/fifosettings don't matter for a connection'scast/2traffic, and there's noreq_idsfor the caller to hold or check in later. Typical use: fire-and-forget logging/metrics. -
Acknowledgement-based protocols, two variants:
- Per-message ack: the server's "reply" to a request is just an ack
(carrying the id back, or not even that, as long as your framing can
tell which message it closes out) rather than a real response payload.
This needs nothing extra — it's the same matching as mode 1 or 2
(
fifo => false);decode_reply/2simply returns whatever small ack term applies (even just the atomack) once it recognizes the match. See Client Implementation Guide's "Per-message acks" for a worked example. - Bulk/cumulative ack: a single ack confirms several previous requests
at once (e.g. a heartbeat carrying "processed through sequence N"). This
is a genuinely different mechanism, using
arterial_nif:checkin_up_to/3: it releases every outstanding FIFO slot up to and including a givenreq_idin one call, even across requests originally reserved by different callers. Only meaningful withfifo => true(mode 3 or 4); a no-op against a random-access backlog (mode 1/2). See Client Implementation Guide's "Bulk/cumulative acks" for the full contract andtest/arterial_nif_tests.erl'scheckin_up_to_releases_fifo_span_test/0for a worked example.
- Per-message ack: the server's "reply" to a request is just an ack
(carrying the id back, or not even that, as long as your framing can
tell which message it closes out) rather than a real response payload.
This needs nothing extra — it's the same matching as mode 1 or 2
(
Disconnect notification applies across modes 1–4 and 6 (anywhere a request
can be genuinely in-flight): if a connection's socket dies while it still has
unconfirmed asynchronous (track_inflight/5-registered) requests, each owning
process receives {arterial_disconnected, Pool, ReqID} so it can resubmit or
persist that request outside arterial. See arterial_nif:connection_down/2,
called automatically from arterial_connection's disconnect path. Not
applicable to mode 5 (nothing is ever in-flight to lose).
Two behaviours decouple the wire transport from request/response encoding:
arterial_protocol(src/arterial_protocol.erl) — the transport and per-request codec:connect/3,close/1,send/2,recv/2,setopts/2,encode_request/3,decode_reply/2.arterial_client(src/arterial_client.erl) — the connection lifecycle:init/1,setup/2,handle_request/2,handle_data/2,handle_timeout/2(optional),terminate/2. Also implementscall/3, the synchronous request API, andcast/2, the send-and-forget API (see Protocol mode 5) — both used by callers directly; the caller's own process owns the socket for the call's duration viaarterial_protocol, andarterial_connection'sgen_serveris not involved in either call itself.
Pool/connection options (t:arterial_client:options/0) include address/ip
(or addresses, for failover across several), port, protocol (tcp |
udp | ssl — see SSL/TLS guide for ssl, which
needs OTP 28+), reconnect, reconnect_time, bounce_interval_ms,
sock_opts, and tls_options (only used when protocol => ssl).
t:arterial_pool:options/0 (passed to arterial_pool:start_link/2)
additionally controls size, backlog, fifo, fixed_timeout_us,
sweep_interval_ms, protocol (here, the wire codec module — not the
same protocol key as above), client, and client_opts (the
per-connection options map above).
For a full step-by-step walkthrough of implementing both behaviours,
wiring them into a pool, and testing against a real server, see
Arterial Client Implementation Guide. For the ssl transport
specifically (OTP 28+ required), see SSL/TLS guide.
Arterial emits events for call/cast/checkout/connect (each as a
start/stop/exception span), plus one-shot disconnect and sweep
events, through a pluggable facade,
src/arterial_observe.erl — see
its moduledoc for the full event/measurement/metadata catalog.
The backend that actually receives these events is chosen via the
arterial application's observability env var, read once by
arterial_app's top-level supervisor at boot:
{arterial, [{observability, undefined}]} % default: no backend, span/3 just runs Fun/0
{arterial, [{observability, telemetry}]} % forwards to telemetry:execute/3
{arterial, [{observability, prometheus}]} % records straight into Prometheus metrics
{arterial, [{observability, {telemetry, Opts}}]} % built-in backend + Opts passed to its start/1
{arterial, [{observability, {prometheus, Opts}}]}
{arterial, [{observability, my_module}]} % your own arterial_observe callback module
{arterial, [{observability, {my_module, Opts}}]}Both telemetry and prometheus are strictly optional dependencies:
neither is in arterial's own default deps (rebar3 has no native
"optional dependency" mechanism), nor in arterial.app.src's
applications — only the configured backend's dependency is ever
started, the same lazy pattern arterial_socket uses for ssl. A plain
rebar3 compile of arterial itself never fetches either library.
Since rebar3 profiles aren't transitive across dependencies, an
application embedding arterial and wanting one of the built-in backends
must add that backend's dependency directly to its own rebar.config
(version-pinned to match arterial's own rebar.config):
{deps, [
{arterial, {git, "https://github.com/saleyn/arterial.git", {branch, "main"}}},
{prometheus, "6.1.2"} %% or {telemetry, "1.3.0"}, depending on backend
]}.Arterial's own rebar.config additionally defines telemetry and
prometheus profiles (each pulling in just that one dependency) plus a
test profile (pulling in both) — these exist for arterial's own
development/test suite (rebar3 as test eunit, etc.), not for embedding
applications to depend on.
arterial_app's supervisor starts a permanent arterial_observe
child that calls the configured backend's start/1 once at boot (e.g.
arterial_observe_prometheus:start/1 declares its metrics —
do this before traffic starts flowing, or the first few observations may
race metric registration) and stop/0 on shutdown (each backend only
stops the underlying application — telemetry/prometheus — if it was
the one that started it, so a host application already running either
for its own purposes is left alone). With the prometheus backend, wire
up your own exporter (prometheus_httpd, prometheus_cowboy2, etc.)
separately — arterial_observe_prometheus only owns the metric
definitions and the events that feed them.
Writing your own backend means implementing the arterial_observe
behaviour: start/1, stop/0, and event/3 — see that module's
moduledoc's "Writing your own backend" section for the exact contract.
Arterial provides enhanced DNS resolution with automatic Kubernetes service discovery, making it seamless to connect to services whether running in-cluster, externally, or in development environments.
- Smart Service Detection: Automatically recognizes Kubernetes service patterns (
.svc.domains) - Search Domain Expansion: Short names like
"redis"auto-expand to"redis.namespace.svc.cluster.local" - Environment Awareness: Detects in-cluster vs external execution using service account tokens (
/var/run/secrets/kubernetes.io/serviceaccount/token) and environment variables - Optimized Caching: 60-second TTL for Kubernetes services, 300 seconds for external hostnames
- Backwards Compatible: Existing IP addresses and hostnames work unchanged
- Headless Service Support: Returns multiple IPs for StatefulSets and headless services
% Works in any environment - automatically detects service type
{ok, _Pool} = arterial_pool:start_link(redis_pool, #{
size => 5,
address => "redis.production", % Auto-expands in K8s environments
port => 6379,
protocol => tcp
}).
% IP addresses and external hostnames work as before
{ok, _Pool} = arterial_pool:start_link(external_pool, #{
address => "api.example.com", % External hostname
% or "192.168.1.100" % IP address
port => 8080
}).
% Direct DNS resolution API
{ok, IPs} = arterial_connection:resolve_address("localhost").
{ok, IPs} = arterial_connection:resolve_address("redis.default.svc.cluster.local").% Explicit Kubernetes service resolution
{ok, IPs} = arterial_connection:resolve_k8s_service(#{
name => "cassandra",
namespace => "database",
cluster_domain => "cluster.local",
headless => true % Returns multiple pod IPs
}).
% Multi-address pools with mixed service types
{ok, _Pool} = arterial_pool:start_link(multi_pool, #{
addresses => [
"redis.cache", % K8s service (short name)
"postgres.db.svc.cluster.local", % K8s service (FQDN)
"192.168.1.50" % IP address
],
port => 6379
}).Arterial automatically detects the execution environment:
- In-Cluster: Reads current namespace from service account, uses K8s-optimized DNS settings
- External with K8s Access: Uses
$KUBERNETES_NAMESPACEor defaults to"default" - Development/External: Falls back to standard DNS resolution
- IP Addresses: ~2μs (no DNS lookup)
- Cached Hostnames: ~100μs (cache hit)
- Fresh DNS Lookups: ~50-500μs (depends on resolver)
- Service Detection: ~5μs overhead for pattern matching
For complete documentation, examples, and advanced configuration:
- DNS_RESOLUTION.md: Complete DNS resolution guide including standard features, caching behavior, and backwards compatibility
- KUBERNETES_DNS.md: Kubernetes-specific DNS features, service discovery, environment detection, and advanced usage patterns
Arterial's pool/checkout design was inspired by
shackle, an existing Erlang
connection-pooling library; test/shackle_bench.erl exists specifically to
let arterial be compared against the library that inspired it, using the
same wire protocol, payload, and workload shape (test/arterial_bench.erl
drives the same workload against arterial itself).
make bench BENCH_OPTS='pool_size=8 duration=5' # arterial
make bench-shackle BENCH_OPTS='pool_size=8 duration=5' # shackle
make bench-poolboy BENCH_OPTS='pool_size=8 duration=5' # poolboyE.g.
$ make bench BENCH_OPTS='pool_size=8'
┌─────────────┬─────────────┬─────────┬─────────┬─────────┬─────────┬──────────┐
│ Library │ Throughput │ P50 │ P95 │ P99 │ Max │ Rejected │
│ │ (req/s) │ (µs) │ (µs) │ (µs) │ (µs) │ │
├─────────────┼─────────────┼─────────┼─────────┼─────────┼─────────┼──────────┤
│ arterial │ 106155.8 │ 62 │ 144 │ 247 │ 3704 │ 0 │
│ shackle │ 41197.2 │ 134 │ 418 │ 1340 │ 19114 │ 90 │
│ poolboy │ 23933.6 │ 265 │ 777 │ 1411 │ 11229 │ 0 │
└─────────────┴─────────────┴─────────┴─────────┴─────────┴─────────┴──────────┘All three accept a comma-separated key=value list (or a literal #{...}
map for non-trivial values like binaries) via BENCH_OPTS; run
make bench-help / make bench-shackle-help / make bench-poolboy-help
to print each module's full option/example documentation
(arterial_bench:help/0 / shackle_bench:help/0 /
poolboy_bench:help/0). An unrecognized option key raises
{unrecognized_bench_opts, Keys} rather than being silently ignored.
For a fair head-to-head, both benchmarks default to architecturally comparable setups rather than just sharing parameter names:
- Dispatch model.
arterial_bench's defaultmode => asyncroutes requests throughtest/arterial_async_driver.erl, a small fixed pool of dispatcher processes built onarterial_nif:checkout_async/3plussocket:recv(Sock, 0, nowait)— OTP's completion-based, active-mode- equivalent recv. This matchesshackle_server's own architecture (a long-lived process owning its socket, replies delivered into its mailbox), so neither side pays for a blocking-recv-per-request reader process that the other doesn't.arterial_bench'smode => sync(the caller blocks directly on the socket viaarterial_client:call/3, no dispatcher at all) is also available, but is architecturally a different comparison point, not a fair one against shackle's dispatch-based model. - Dispatcher/connection selection.
arterial_async_driverpicks its next dispatcher round-robin (anatomics-backed counter), matchingshackle_pool's defaultround_robinpool_strategy— uniform-random selection under concurrency lets two callers collide on the same busy dispatcher purely by chance while others sit idle, which has nothing to do with either library's real throughput. - Retry budget. Shackle's
pool_strategyprobes exactly one fixed slot per attempt with no pool-wide fallback, soshackle_benchsetsmax_retries => pool_size - 1by default (enough attempts to reach every slot once) —max_retries => 0would reject a large fraction of requests to busy-but-not-actually-exhausted pools purely from round-robin collisions, which arterial's full-pool-scan checkout never does in the first place. - Backlog. Both benchmarks default to
backlog/backlog_size => 1(one in-flight request per connection) and print the value they ran with, but it's a real, settable option on both sides (arterial_bench'sbacklog,shackle_bench'sbacklog_size), not a hardcoded constant.
Residual throughput differences after matching the above are expected:
shackle is a mature, heavily-optimized production library, while
arterial_async_driver/arterial_bench are minimal test-only drivers
built directly on arterial's public NIF API, not part of arterial's
shipped functionality.
test/poolboy_bench.erl compares against
poolboy instead, a generic worker
pool with no protocol/wire awareness of its own (unlike shackle) and no
NIF-backed connection management (unlike arterial) — poolboy:checkout/3
just hands back a pid(), and test/poolboy_echo_worker.erl (a plain
gen_server wrapping one persistent gen_tcp socket, reusing
test_echo_protocol's framing) is the only thing that knows how to talk
to test_tcp_server. That structural shape — the caller's own process
blocks on a gen_server:call/3 into the worker, which itself blocks on
gen_tcp:send/2 + gen_tcp:recv/2 — is the architecture comparable to
arterial_bench's mode => sync (no async dispatcher process in the
loop on either side), not its default mode => async; pass
BENCH_OPTS='mode=sync' to make bench for the matching arterial run.
poolboy_bench defaults max_overflow => 0 and pool_strategy => fifo
(closest match to arterial's default fifo pool order) and workers to
pool_size so every connection stays saturated, same default rationale
as the other two benchmarks.
This README is the primary documentation; module-level @doc/-spec
comments cover the public API of each module listed above. Arterial is not
yet published to Hex, so there are no hosted hexdocs.
Arterial uses MIT License. You can use the source code freely in any project, including commercial applications, as long as you give credit by publishing the contents of the LICENSE file somewhere in your documentation.