eysql

Topology-aware load balancing and failover for epgsql, for YugabyteDB and PostgreSQL.

YugabyteDB ships "smart drivers" for Java, Go, Python, Node.js, C#, Rust and Ruby. They discover the cluster's servers, spread connections across them, prefer servers in your zone, and skip servers that fail. Nothing like that existed for Erlang. eysql adds it on top of epgsql, without replacing epgsql: your connections are ordinary epgsql connections.

It is written in plain Erlang with epgsql as its only dependency, so Erlang, Elixir and Gleam projects can all use it.

Install

rebar3:

{deps, [{eysql, "~> 0.1"}]}.

Mix:

{:eysql, "~> 0.1"}

Gleam:

gleam add eysql

Use

{ok, Pool} = eysql:start_link(#{hosts => ["yb-tservers.db.svc.cluster.local"],
                               username => <<"app">>,
                               password => <<"secret">>,
                               database => <<"app">>,
                               load_balance => true,
                               topology_keys => "gcp.us-east1.us-east1-b:1,gcp.us-east1.*:2"}),

%% One statement.
{ok, _Columns, Rows} = eysql:equery(Pool, "SELECT id, name FROM accounts WHERE realm = $1", [Realm]),

%% Several statements on one connection. eysql_conn:equery/3 and squery/2
%% are epgsql's, bounded by socket_timeout; epgsql's own functions work on
%% the connection too, without the bound.
eysql:with_connection(Pool, fun(Conn) ->
    {ok, _, _} = eysql_conn:equery(Conn, "SELECT ...", []),
    eysql_conn:equery(Conn, "SELECT ...", [])
end),

%% A transaction, retried on serialization failure.
{ok, Id} = eysql:transaction(Pool, fun(Conn) ->
    case eysql_conn:equery(Conn, "INSERT INTO t (name) VALUES ($1) RETURNING id", [Name]) of
        {ok, 1, _, [{Id}]} -> {ok, Id};
        {error, _} = Error -> Error
    end
end).

Under a supervisor, eysql:child_spec(my_db, Options) gives a pool registered as my_db.

Elixir:

{:ok, pool} = :eysql.start_link(%{hosts: [~c"yb-tservers"], username: "app", password: "secret", database: "app",
                                  load_balance: true})
{:ok, _cols, rows} = :eysql.equery(pool, "SELECT 1", [])

Options

The load-balancing options keep the smart drivers' names and units (seconds). Every other duration is in milliseconds. An option with a counterpart in the YugabyteDB JDBC driver defaults as it does there, so load balancing is off until you set load_balance.

Text, such as username, a host or topology_keys, is a string or a UTF-8 binary. A binary that is not UTF-8 is an invalid option.

Option Default Meaning
hosts ["localhost"] Seed hosts: names, or {Name, Port}. A DNS name that resolves to several servers, such as a headless Kubernetes service, is a good seed.
port 5433 Port for hosts given without one. PostgreSQL uses 5432.
username, password, database yugabyte, empty, the username As for epgsql. password may be any binary, or a zero-arity fun that returns it; fun Module:Function/0 keeps it out of the start arguments a supervisor prints. database defaults to the username, as in pgjdbc and libpq.
ssl, ssl_opts false, [] Passed to epgsql, with the host each connection dialled as its server_name_indication unless ssl_opts sets one. true uses TLS when the server offers it, and required insists on it. OTP 26 and later verify the server's certificate by default, so ssl_opts need a CA. See TLS.
connect_timeout 10000 Per connection attempt, for the TCP connect and the TLS handshake. pgjdbc's connectTimeout defaults to the same 10 seconds.
statement_timeout none Set on each connection with SET statement_timeout.
socket_timeout infinity How long a call on a pooled connection waits for the server, in milliseconds. When it runs out, eysql closes the connection and the call returns {error, {connection_lost, socket_timeout}}. It covers equery/3, squery/2, the BEGIN, COMMIT and ROLLBACK of transaction/2,3, and eysql_conn:equery/3 and squery/2 called inside with_connection or transaction, or on a connection you checked out. epgsql's own functions, called on the connection directly, wait for as long as the server takes. Off by default, as pgjdbc's socketTimeout is.
application_name eysql Shown in pg_stat_activity.
epgsql_opts #{} Anything else epgsql:connect/1 accepts, such as tcp_opts with TCP keepalive settings.
load_balance false false uses the configured hosts only, in the order given, with no discovery, as the drivers do by default: every connection goes to the first host that works. true/any: all servers. only_primary, only_rr: primary or read-replica nodes only. prefer_primary, prefer_rr: that type first, the other if none is up. The drivers' spellings work too, as strings or binaries in any case: "true", "false", "any", "only-primary", "only-rr", "prefer-primary", "prefer-rr".
topology_keys none "cloud.region.zone:N,…". Zone * means any zone in the region, and preference N (1–10, default 1) orders the levels. Names match in any case, as in the JDBC driver: GCP.US-East1.* matches the servers yb_servers() places in gcp.us-east1.
fallback_to_topology_keys_only false With no server up in any listed placement, fail instead of using other servers or the seeds. It needs topology_keys, and prefer_primary and prefer_rr ignore it, as the drivers do.
yb_servers_refresh_interval 300 Seconds between refreshes, 0 to 600. A refresh reads yb_servers() again, except on PostgreSQL, and probes the servers that have failed, so this also bounds how long a failed server that is back waits to be used. A connection failure brings the next refresh forward. With 0 there is no timer: eysql refreshes each time it opens a connection, in the background, so the connection does not wait for it.
failed_host_reconnect_delay_secs 5 How long a server that failed a connection is left out at least, 0 to 60 seconds, as in the JDBC driver. The same every time, as in the drivers. The first refresh after it ends probes the server, and a probe that succeeds brings it back. With 0 the server is due at the next refresh, which its failure brings forward.
failed_host_max_delay_secs none Set it to double the delay on each consecutive failure, up to this many seconds. It must be at least failed_host_reconnect_delay_secs, and a delay of 0 takes none, since 0 doubled stays 0.
target_session_attrs any read_write checks pg_is_in_recovery() and moves on from standbys, as libpq's target_session_attrs and pgjdbc's targetServerType=primary do.
pool_size 10 Connections the pool keeps open.
max_lifetime 1800000 A connection is replaced after this…
lifetime_jitter 300000 …plus a random share of this, so they do not all reconnect together.
rebalance_interval 30000 How often the pool moves connections, and closes idle ones past their lifetime…
rebalance_batch 2 …and at most how many it moves at a time.
after_connect none Prepares each connection the pool opens before anyone gets it: a fun of one argument, the connection, or {Module, Function, Args}, called as apply(Module, Function, [Conn | Args]). See Preparing connections.
after_connect_timeout 60000 How long after_connect may run on one connection, in milliseconds, or infinity. A hook still running then fails the connection.

There are no health check options. health_check_interval and health_check_timeout are unknown options, and naming one is an error. Set socket_timeout so a query on a server that has died silently fails instead of hanging.

TLS

ssl => required encrypts every connection and fails if the server does not offer TLS. ssl => true goes on unencrypted when the server does not offer TLS, as libpq's sslmode=prefer does. When the server does offer TLS, a failed certificate check fails the connection with either setting.

eysql passes ssl_opts to epgsql, adding only the name to check, and never turns certificate checks off. The ssl application in OTP 26 and later verifies the server's certificate by default, so TLS needs a CA to check it against:

ssl => required,
ssl_opts => [{cacertfile, "/etc/ssl/certs/yugabyte-ca.crt"}]

For the operating system's trust store, use {cacerts, public_key:cacerts_get()} instead. Without a CA, OTP refuses to connect, and every connection fails with:

{error, {ssl_negotiation_failed, {options, incompatible, [{verify, verify_peer}, {cacerts, undefined}]}}}

With a CA, OTP checks the certificate chain, then the server's name, as libpq's and pgjdbc's sslmode=verify-full do: the certificate must list the host each connection dialled as a subject alternative name. That is the seed host for the first connection, and each server's host or public_ip from yb_servers() after that, so on YugabyteDB every server's certificate lists its own name and the seed's. A certificate can list several names; the check passes when the one dialled is among them. A host given as an IP address is checked against the address, so the certificate must list that address.

epgsql starts TLS on a TCP connection it has already opened, which leaves OTP knowing the server only by its address. eysql therefore passes the host it dialled, when that is a name, as the connection's server_name_indication, which OTP both sends and checks. A certificate that fails the check fails the connection with a TLS alert that includes hostname_check_failed.

To check something else, set server_name_indication in ssl_opts yourself. It then applies to every server:

To encrypt without verifying, as libpq's sslmode=require does, set {verify, verify_none} yourself. The connection then accepts any certificate, so nothing stops a man in the middle.

PostgreSQL

With PostgreSQL, yb_servers() does not exist. With load_balance off, the default, eysql never asks for it. With it on, eysql notices on the first discovery and uses the configured hosts from then on, in the order given, whatever the mode. With several hosts and target_session_attrs => read_write, it connects to the first that accepts writes.

Preparing connections

after_connect runs on each connection the pool opens, before the pool hands it to anyone. On YugabyteDB a new connection is a new backend, and its first statements wait while it loads the catalog entries they need from the masters. A hook that runs the application's hot statements once keeps that wait off the first caller.

{ok, Pool} = eysql:start_link(#{hosts => ["yb-tservers"],
                               load_balance => true,
                               after_connect => {my_db, warm, []}}).

%% In my_db. Each statement runs once, with parameters that match no row,
%% so the backend parses and plans it: it loads what the planner reads, such
%% as the table's indexes and statistics, as well as the names the parser
%% looks up. epgsql:parse/2 alone would warm only the parser's lookups.
warm(Conn) ->
    lists:foreach(fun({Sql, Params}) -> {ok, _, _} = eysql_conn:equery(Conn, Sql, Params) end,
                  [{"SELECT id, doc FROM accounts WHERE id = $1", [0]},
                   {"SELECT id FROM devices WHERE account_id = $1 AND name = $2", [0, <<>>]}]).

Without a pool

eysql_cluster does placement and failover for code that manages its own connections. connect/1 waits for the first discovery, then tries hosts as above, and returns the last connect error, or {error, {no_node_available, Type}} in the modes that refuse:

{ok, Config} = eysql_config:normalize(Options),
{ok, Cluster} = eysql_cluster:start_link(Config),
{ok, Conn} = eysql_cluster:connect(Cluster).   % linked to the caller, like epgsql:connect/1

Tests

rebar3 eunit                 # logic, against a fake driver
integration/run.sh           # PostgreSQL and a three-zone YugabyteDB cluster in Docker

The integration suite includes stopping a YugabyteDB node under load and starting it again. On both engines it also checks that a connection left inside a transaction, or a failed one, is closed rather than handed to the next holder, that a query in flight when its pool stops returns an error, that socket_timeout cuts off a pg_sleep and the pool recovers, and that every pooled connection, replacements included, runs after_connect before it serves a query. eysql_driver is the behaviour the fake driver implements; the driver option swaps it in.

Compatibility

Licence

Apache-2.0. See LICENSE.