Cistern
An Elixir library that wraps Redix with a Poolboy-managed connection pool, providing a simple, typed API for common Redis operations.
Available on Hex.
Features
- Connection pooling via Poolboy (configurable size and overflow)
- Type coercion on reads (configurable, on by default):
"true"/"false"→boolean, numeric strings →integer - High-level helpers:
get,set(with optional TTL),multiple,set_many,delete,delete_many,increment - Low-level escape hatches:
command/2andpipeline/2for arbitrary Redis commands - Fire-and-forget
noreply_pipeline/2for write-heavy workloads - Composable key validation — accepts binaries, integers, or iodata lists
Requirements
- Erlang 26+
- Elixir 1.17+
- A running Redis instance
Installation
Add cistern to your dependencies in mix.exs:
{:cistern, "~> 0.1"}Then fetch dependencies:
mix deps.get
Configuration
Add the following to your config/config.exs (or environment-specific file):
config :cistern,
host: "localhost",
port: 6379,
password: "", # omit or leave blank for no auth
pool_size: 10, # number of persistent connections
pool_max_overflow: 5, # extra connections allowed under load
pool_timeout: 5_000, # ms to wait for a free connection
sync_connect: true,
exit_on_disconnection: true,
coerce: true # global read coercion (default); override per call with `coerce:`Read coercion
On reads, get/2 and multiple/2 coerce values by default ("true"/"false"
→ boolean, numeric strings → integer). Control it at two levels:
- Globally via
config :cistern, coerce: false— e.g. to keep zero-padded codes or ids intact application-wide. - Per call via the
coerce:option, which overrides the global setting:Cistern.get("zip_key", coerce: false).
The resolution order is: per-call opt → global config → true.
Runtime configuration (production)
For production it's common to read connection settings from environment
variables at boot via config/runtime.exs:
import Config
if config_env() == :prod do
config :cistern,
host: System.fetch_env!("REDIS_HOST"),
port: String.to_integer(System.get_env("REDIS_PORT", "6379")),
password: System.get_env("REDIS_PASSWORD", ""),
pool_size: String.to_integer(System.get_env("REDIS_POOL_SIZE", "10"))
endUsage
Starting under a Supervision tree
children = [
Cistern,
# ...
]
Supervisor.start_link(children, strategy: :one_for_one)Or start manually:
{:ok, _pid} = Cistern.start_link()Basic operations
# Store a value
{:ok, "bar"} = Cistern.set("foo", "bar")
# Store with a TTL (milliseconds)
{:ok, "bar"} = Cistern.set("foo", "bar", ttl: 10_000)
# Retrieve — strings are coerced to boolean or integer by default
{:ok, "bar"} = Cistern.get("foo")
{:ok, true} = Cistern.get("flag_key") # stored as "true"
{:ok, 42} = Cistern.get("count_key") # stored as "42"
{:ok, nil} = Cistern.get("missing")
# Opt out of coercion to keep the raw string (e.g. zero-padded codes)
{:ok, "01001"} = Cistern.get("zip_key", coerce: false) # stored as "01001"
# Bulk write
:ok = Cistern.set_many([{"foo", "bar"}, {"count", 1}])
:ok = Cistern.set_many([{"a", 1}, {"b", 2}], ttl: 60_000)
# Bulk read — coercion applies here too, and is overridable the same way
{:ok, ["bar", 1]} = Cistern.multiple(["foo", "count"])
{:ok, ["bar", "1"]} = Cistern.multiple(["foo", "count"], coerce: false)
# Atomic increment
{:ok, 2} = Cistern.increment("count")
# Delete
{:ok, 1} = Cistern.delete("foo") # returns count of deleted keys
{:ok, 2} = Cistern.delete_many(["a", "b"])Iodata keys
Keys can be iodata lists — they are joined into a single binary before being
sent to Redis. Non-binary elements (e.g. integer ids) are stringified and kept,
so ["user:", 1] and ["user:", 2] produce distinct keys:
key = ["user:", user_id] # user_id may be a binary or an integer
Cistern.set(key, data)
Cistern.get(key)
Cistern.delete(key)Raw Redis commands
# Single command
{:ok, "PONG"} = Cistern.command(["PING"])
{:ok, "OK"} = Cistern.command(["SET", "foo", "bar"])
# Pipelined commands (returns list of results)
{:ok, ["OK", "OK"]} = Cistern.pipeline([["SET", "a", 1], ["SET", "b", 2]])
# Fire-and-forget pipeline (no reply waited for)
:ok = Cistern.noreply_pipeline([["SET", "a", 1], ["SET", "b", 2]])Note:
noreply_pipeline/2usesCLIENT REPLY OFFunder the hood. If your Redis server or proxy does not support theCLIENTcommand, usepipeline/2instead.
Testing
The library ships with a RedixMock test double that replaces the real Redix module via Application.compile_env. Configure it in config/test.exs:
config :cistern, redis_module: RedixMockRun the test suite:
mix test
Run with coverage:
mix test --cover
Development
# Fetch deps
mix deps.get
# Start an interactive session
iex -S mix
# Static analysis
mix credo
# Generate docs
mix docs
Architecture
Cistern — public API and type coercion
└── Cistern.Redis.Pool — Supervisor that owns the Poolboy pool
└── Cistern.Redis.Client — GenServer worker; one per connectionEach command/2, pipeline/2, or noreply_pipeline/2 call checks out a connection from the pool, executes the command via Redix, and returns it — all within a single :poolboy.transaction/3.