Aerospike
An idiomatic Elixir client for the Aerospike database. Connects directly over the Aerospike binary wire protocol — pure Elixir, no NIFs.
Features
- OTP-native — starts under a supervisor, pools connections automatically via NimblePool
- Cluster-aware — discovers nodes, maintains partition maps, routes operations to the correct node
- Single-record CRUD —
put,get,delete,exists,touchwith bang variants - Operate — atomic multi-operation per record (
add,append,prepend, custom op lists) - Batch operations —
batch_get,batch_exists,batch_operatefor multi-key round-trips - Scan & query — full-table scans and secondary-index queries via
stream!,all,count,page - CDT operations — List, Map, Bit, and HLL operations with nested context (
Ctx) - Server-side expressions — filter results with
Aerospike.Expexpressions - Secondary indexes —
create_index/drop_indexwith asyncIndexTaskpolling - UDF management —
register_udf,remove_udf,apply_udffor Lua user-defined functions - Transactions — multi-record transactions (
transaction/2,commit/2,abort/2) on Enterprise Edition - Write policies — TTL, generation checks (CAS), create/update/replace semantics, durable delete
- Read policies — selective bin projection, header-only reads
- Policy defaults — set read/write timeouts and options once at connection time; override per call
- TLS support — optional TLS and mTLS for node connections
- Telemetry — emits
[:aerospike, :command, :start | :stop | :exception]events - Pure Elixir — only runtime dependencies are
nimble_options,nimble_pool, andtelemetry; crypto uses Erlang's:crypto(RIPEMD-160 digests)
See the CHANGELOG for what shipped in each version.
Installation
Add aerospike_driver to your list of dependencies in mix.exs:
def deps do
[
{:aerospike_driver, "~> 0.1.0"}
]
endQuick Start
# Start the client (connects to a local Aerospike instance)
{:ok, _pid} = Aerospike.start_link(name: :aero, hosts: ["localhost:3000"])
# Build a key (namespace, set, user key)
key = Aerospike.key("test", "users", "user:1001")
# Write bins (columns) to the record
:ok = Aerospike.put!(:aero, key, %{"name" => "Ada", "lang" => "Elixir", "score" => 42})
# Read the record back
{:ok, record} = Aerospike.get(:aero, key)
record.bins["name"]
#=> "Ada"
# Clean up
:ok = Aerospike.delete!(:aero, key)Usage
Starting the Client
The client starts a supervision tree that manages cluster discovery and connection pools. Add it to your application supervisor or start it directly:
# In your application supervisor
children = [
{Aerospike,
name: :aero,
hosts: ["node1:3000", "node2:3000"],
pool_size: 8,
defaults: [
write: [timeout: 2_000],
read: [timeout: 1_500]
]}
]
Supervisor.start_link(children, strategy: :one_for_one)Connection Options
| Option | Type | Default | Description |
|---|---|---|---|
:name | atom | — | Required. Registered name for this connection. |
:hosts | list of strings | — | Required. Seed hosts ("host:port" or "host" for port 3000). |
:pool_size | pos_integer | 10 | Connections per discovered node. |
:pool_checkout_timeout | non_neg_integer | 5000 | Pool checkout timeout in ms. |
:connect_timeout | non_neg_integer | 5000 | TCP connect timeout in ms. |
:tend_interval | non_neg_integer | 1000 | Cluster tend interval in ms. |
:recv_timeout | non_neg_integer | 5000 | Socket receive timeout in ms. |
:auth_opts | keyword list | [] | Optional internal authentication (user/credential). |
:tls | boolean | false |
When true, upgrades TCP with TLS (:ssl.connect/3). |
:tls_opts | keyword list | [] |
Options for :ssl.connect/3 (certs, verify, SNI, etc.). |
:defaults | keyword list | [] | Per-command policy defaults (see below). |
Writing Records
key = Aerospike.key("test", "users", "user:42")
# Simple put — merges bins into the record
:ok = Aerospike.put!(:aero, key, %{"name" => "Grace", "age" => 36})
# Atom keys are accepted and normalized to strings
:ok = Aerospike.put!(:aero, key, %{name: "Grace", age: 36})
# Set a TTL of 1 hour (in seconds)
:ok = Aerospike.put!(:aero, key, %{"name" => "Grace"}, ttl: 3600)Reading Records
# Read all bins
{:ok, record} = Aerospike.get(:aero, key)
record.bins #=> %{"name" => "Grace", "age" => 36}
record.generation #=> 1
record.ttl #=> server-reported TTL
# Read specific bins only
{:ok, record} = Aerospike.get(:aero, key, bins: ["name"])
record.bins #=> %{"name" => "Grace"}
# Read header only (generation + TTL, no bin data)
{:ok, record} = Aerospike.get(:aero, key, header_only: true)
record.bins #=> %{}Deleting Records
# Returns whether the record existed before deletion
{:ok, true} = Aerospike.delete(:aero, key)
# Deleting a non-existent key is not an error
{:ok, false} = Aerospike.delete(:aero, key)Checking Existence
{:ok, true} = Aerospike.exists(:aero, key)
{:ok, false} = Aerospike.exists(:aero, missing_key)Refreshing TTL (Touch)
# Reset TTL to the namespace default
:ok = Aerospike.touch!(:aero, key)
# Set a specific TTL (10 minutes)
:ok = Aerospike.touch!(:aero, key, ttl: 600)Write Policies
Control write behavior with per-call options:
# Create only — fails if the record already exists
:ok = Aerospike.put!(:aero, key, bins, exists: :create_only)
# Update only — fails if the record does not exist
:ok = Aerospike.put!(:aero, key, bins, exists: :update_only)
# Replace — like update, but wipes all existing bins first
:ok = Aerospike.put!(:aero, key, bins, exists: :replace_only)
# Create or replace — upsert that wipes old bins on update
:ok = Aerospike.put!(:aero, key, bins, exists: :create_or_replace)Optimistic Concurrency (CAS)
Use generation checks for compare-and-swap semantics:
# Read current state
{:ok, record} = Aerospike.get(:aero, key)
gen = record.generation
# Write only if no one else has modified the record
case Aerospike.put(:aero, key, %{"counter" => 1},
generation: gen,
gen_policy: :expect_gen_equal) do
:ok -> :updated
{:error, %Aerospike.Error{code: :generation_error}} -> :conflict
endInteger and String Keys
User keys can be strings or 64-bit integers:
string_key = Aerospike.key("test", "users", "user:alice")
integer_key = Aerospike.key("test", "counters", 12345)Digest-Only Keys
If you already have the 20-byte RIPEMD-160 digest (e.g., from a secondary index or another client), construct a key directly:
digest_key = Aerospike.key_digest("test", "users", <<digest::binary-20>>)
{:ok, record} = Aerospike.get(:aero, digest_key)Error Handling
All operations return {:ok, result} or {:error, %Aerospike.Error{}}. Bang variants
(put!, get!, delete!, exists!, touch!) unwrap success and raise on error:
# Pattern matching on errors
case Aerospike.get(:aero, key) do
{:ok, record} ->
process(record)
{:error, %Aerospike.Error{code: :key_not_found}} ->
create_default()
{:error, %Aerospike.Error{code: :timeout}} ->
retry_later()
end
# Bang variant raises Aerospike.Error
record = Aerospike.get!(:aero, key)
Error codes are atoms for pattern matching: :key_not_found, :key_exists,
:generation_error, :timeout, :parameter_error, etc.
Data Representation
Aerospike data types map to Elixir as follows:
| Aerospike Type | Elixir Type | Notes |
|---|---|---|
| Integer | integer() | 64-bit signed |
| Double | float() | |
| String | String.t() | UTF-8 binary |
| Bytes | binary() | Raw bytes (blob particle type) |
| Boolean | boolean() | |
| Nil | nil | Server returns nil for absent bins |
| List | list() | Ordered list (MessagePack encoded) |
| Map | map() | Unordered map (MessagePack encoded) |
Bin names are always returned as strings, even if you write them with atom keys.
Telemetry
The client emits telemetry events for every command:
| Event | Measurements | Metadata |
|---|---|---|
[:aerospike, :command, :start] | system_time | namespace, command, node |
[:aerospike, :command, :stop] | duration | namespace, command, node, result |
[:aerospike, :command, :exception] | duration | namespace, command, kind, reason |
:telemetry.attach("my-handler", [:aerospike, :command, :stop], fn
_event, %{duration: d}, %{command: cmd, result: result}, _config ->
Logger.info("#{cmd} completed in #{System.convert_time_unit(d, :native, :millisecond)}ms: #{inspect(result)}")
end, nil)Testing
From the aerospike_driver directory:
mix deps.getDocker Setup
Tests require an Aerospike server running via Docker Compose. There are three profiles depending on which tests you want to run:
# Single node — enough for unit, property, and basic integration tests
docker compose up -d
# 3-node cluster — adds multi-node, partition routing, and peer discovery tests
docker compose --profile cluster up -d
# All services — adds enterprise-only feature tests (durable delete, etc.)
docker compose --profile cluster --profile enterprise up -dRunning Tests
By default, only unit tests and doctests run. Other test categories are opt-in:
# Unit tests only (fast, no external dependencies)
mix test
# Include property-based tests
mix test --include property
# Include integration tests (needs single Aerospike node)
mix test --include integration
# Include multi-node cluster tests (needs --profile cluster)
mix test --include cluster
# Include enterprise feature tests (needs --profile enterprise)
mix test --include enterprise
# Full suite (needs all Docker profiles running)
mix test.allTLS Fixture Generation
TLS unit tests can run with generated certificate fixtures (CA/server/client) instead of ephemeral in-memory certs:
# Generate fixture certs/keys under test/support/fixtures/tls
make tls-fixtures
# Run only TLS connection tests
make test.tlsThe TLS test suite automatically uses these fixtures when present and falls back to ephemeral certs when missing.
Coverage
Coverage runs integration tests via Mix aliases:
mix coveralls # terminal summary
mix test.coverage # HTML report in cover/Quality Checks
mix format --check-formatted
mix credo --strict
mix dialyzerDisclaimer
This is an independent, community-developed project. It is not affiliated with, endorsed by, or sponsored by Aerospike, Inc. "Aerospike" is a trademark of Aerospike, Inc.
License
Copyright 2024–present Gabriel Roldan
Licensed under the Apache License, Version 2.0. See LICENSE for details.