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/2 and pipeline/2 for arbitrary Redis commands
  • Fire-and-forget noreply_pipeline/2 for 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"))
end

Usage

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/2 uses CLIENT REPLY OFF under the hood. If your Redis server or proxy does not support the CLIENT command, use pipeline/2 instead.

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: RedixMock

Run 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 connection

Each 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.