# Vela
A distributed cache library for Elixir with pluggable backends, topologies, and near-cache support.
## Features
- **Pluggable backends** — ETS (in-memory), DETS (disk-persistent), Redis (external)
- **Pluggable topologies** — Local, Replicated, Partitioned (consistent hash ring)
- **Near cache** — local ETS read-through layer in front of any topology
- **Stampede protection** — only one process fetches on a cache miss, others wait
- **Tag-based invalidation** — bulk-evict groups of entries across all topologies
- **TTL management** — per-entry TTL with background sweep
- **Telemetry** — built-in instrumentation for all operations
- **Stats** — lock-free atomic counters for hits, misses, writes, deletes
## Installation
Add `vela` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:vela, "~> 0.1.0"}
]
end
```
## Quick Start
```elixir
defmodule MyApp.Cache do
use Vela.Cache,
backend: Vela.Backend.ETS,
default_ttl: :timer.minutes(5)
end
```
Add it to your supervision tree:
```elixir
children = [
MyApp.Cache
]
```
Use it:
```elixir
MyApp.Cache.put(:user_1, %{name: "Alice"})
MyApp.Cache.get(:user_1)
# => {:ok, %{name: "Alice"}}
MyApp.Cache.get(:missing)
# => {:error, :not_found}
```
## API
```elixir
get(key, opts \\ []) # {:ok, value} | {:error, :not_found}
get!(key, opts \\ []) # value | raises KeyError
put(key, value, opts \\ []) # :ok | {:error, reason}
delete(key, opts \\ []) # :ok
exists?(key, opts \\ []) # boolean
flush(opts \\ []) # :ok
size() # integer
stats() # %{hits: n, misses: n, writes: n, ...}
get_or_fetch(key, fetch_fn, opts \\ []) # {:ok, value} | {:error, reason}
invalidate_tag(tag, opts \\ []) # {:ok, evicted_count}
```
### Options
- `ttl:` — time-to-live in milliseconds, or `:infinity` (default: cache's `default_ttl`)
- `tags:` — list of atoms for group invalidation (e.g., `tags: [:users, :active]`)
## Backends
### ETS (default)
In-memory, fastest. Data lost on restart.
```elixir
use Vela.Cache, backend: Vela.Backend.ETS
```
### DETS
Disk-persistent. Survives restarts. Slower than ETS.
```elixir
use Vela.Cache,
backend: Vela.Backend.DETS,
backend_opts: [data_dir: "/var/data/my_cache"]
```
### Redis
External Redis server. Requires the `:redix` dependency.
```elixir
# Add to deps: {:redix, "~> 1.3"}
use Vela.Cache,
backend: Vela.Backend.Redis,
backend_opts: [url: "redis://localhost:6379"]
```
## Topologies
### Local (default)
Single node. No distribution. Fastest.
```elixir
use Vela.Cache, topology: Vela.Topology.Local
```
### Replicated
Every node holds a full copy. Writes broadcast to all nodes. Reads are always local.
Best for: small datasets, read-heavy workloads, feature flags, config caches.
```elixir
use Vela.Cache, topology: Vela.Topology.Replicated
```
### Partitioned
Each key lives on one node, determined by a consistent hash ring. Reads for remote keys use RPC.
Best for: large datasets that don't fit on a single node.
```elixir
use Vela.Cache, topology: Vela.Topology.Partitioned
```
The hash ring updates automatically when nodes join or leave the cluster.
## Near Cache
Adds a local ETS read-through layer (L1) in front of the real topology (L2). Hot keys are served from local memory without hitting the network.
```elixir
use Vela.Cache,
backend: Vela.Backend.Redis,
topology: Vela.Topology.Local,
near_cache: true,
near_cache_l1_ttl: :timer.seconds(30)
```
Read flow: L1 hit -> return | L1 miss -> L2 -> promote to L1 -> return.
## Stampede Protection
When a cached value expires and many processes request it simultaneously, only one fetches from the source. Others wait and read from cache.
```elixir
MyApp.Cache.get_or_fetch(:expensive_key, fn _key ->
{:ok, MyApp.Repo.get_expensive_data()}
end)
```
Enabled by default. Configure with:
```elixir
use Vela.Cache,
stampede_protection: true,
stampede_timeout: 5_000 # max wait time in ms
```
## Tag-Based Invalidation
Group related entries with tags, then invalidate them in bulk:
```elixir
MyApp.Cache.put(:user_1, alice, tags: [:users])
MyApp.Cache.put(:user_2, bob, tags: [:users])
MyApp.Cache.put(:product_1, widget, tags: [:products])
MyApp.Cache.invalidate_tag(:users)
# => {:ok, 2} — both user entries removed, product untouched
```
Works across all topologies. Replicated and Partitioned broadcast the invalidation to all nodes.
## Telemetry Events
All events are prefixed with `[:vela, :cache]` by default (configurable via `telemetry_prefix`).
| Event | Measurements | Metadata |
|-------|-------------|----------|
| `[:vela, :cache, :get, :stop]` | `duration` | `cache, key, result` |
| `[:vela, :cache, :put, :stop]` | `duration` | `cache, key, ttl` |
| `[:vela, :cache, :fetch, :stop]` | `duration` | `cache, key, result` |
| `[:vela, :cache, :invalidate_tag, :stop]` | `count` | `cache, tag` |
## Configuration
All options with defaults:
```elixir
use Vela.Cache,
backend: Vela.Backend.ETS,
backend_opts: [],
topology: Vela.Topology.Local,
topology_opts: [],
default_ttl: :infinity,
max_size: :infinity,
eviction_policy: :ttl_only,
stampede_protection: true,
stampede_timeout: 5_000,
stats_enabled: true,
telemetry_prefix: [:vela, :cache],
sweep_interval: 30_000,
near_cache: false,
near_cache_l1_ttl: 60_000
```
Options can be overridden at runtime:
```elixir
MyApp.Cache.start_link(default_ttl: :timer.minutes(10))
```
## Multi-Node Setup
Vela reacts to BEAM node connections. Use [libcluster](https://github.com/bitwalker/libcluster) for automatic node discovery in production.
```elixir
# Connect nodes manually:
Node.connect(:"node2@hostname")
# Or use libcluster in your supervision tree
```
Once nodes are connected, Replicated and Partitioned topologies work automatically.
## Benchmarks
Run benchmarks with:
```bash
mix run benchmarks/get_bench.exs
mix run benchmarks/topology_bench.exs
```
## Testing
```bash
# Unit tests
mix test
# Including distributed multi-node tests
elixir --sname primary -S mix test --include distributed
```
## License
MIT