Embedded mode lets you use FerricStore as an in-process key-value store inside your Elixir application. No TCP listener, no RESP3 protocol, no network overhead. You interact with FerricStore through the FerricStore module, which calls directly into the same shard routing and group-commit pipeline that standalone mode uses.
When to Use Embedded Mode
- Your application and cache run on the same BEAM node
- You want microsecond read latency (~1-5us for hot keys) with zero-copy binaries
- You want direct Elixir API access without TCP/RESP3 serialization overhead
- You don't need external clients (redis-cli, Redix from another app) to connect
- You want the full FerricStore feature set (Raft durability, LFU eviction, probabilistic structures) as a library dependency
Setup
1. Add the Dependency
Only add ferricstore -- you do not need ferricstore_server:
# mix.exs
def deps do
[
{:ferricstore, "~> 0.3.2"}
]
end2. Configure
# config/config.exs
config :ferricstore, :data_dir, "priv/ferricstore_data"
config :ferricstore, :shard_count, 0 # 0 = auto-detect from CPU cores
config :ferricstore, :max_memory_bytes, 1_073_741_824 # 1 GB
config :ferricstore, :eviction_policy, :volatile_lruIn embedded mode, these options are not used and can be omitted:
:port(no TCP listener):health_port(no HTTP endpoint):tls_port,:tls_cert_file,:tls_key_file(no TLS):sendfile_threshold(no TCP sends)
3. Start Using It
FerricStore starts automatically with your application (it is an OTP application with mod: {Ferricstore.Application, []}). Once started, call functions on the FerricStore module:
:ok = FerricStore.set("session:abc", session_data, ttl: :timer.minutes(30))
{:ok, data} = FerricStore.get("session:abc")Behavior Differences from RESP3 Mode
The embedded API (FerricStore module) and the RESP3/TCP mode execute the same command handlers, but there are differences to be aware of:
| Aspect | RESP3/TCP mode | Embedded mode |
|---|---|---|
| Return values | Raw RESP3 types (bulk strings, integers, arrays) | Elixir-idiomatic types ({:ok, value}, :ok, etc.) |
| Blocking commands | BLPOP, BRPOP, BLMOVE, BLMPOP, XREAD BLOCK supported | Not available -- return immediately |
| Set algebra | SINTER/SUNION/SDIFF scan the single shard owning the key | Same behavior -- set members are co-located per key |
| Transactions | MULTI/EXEC/WATCH at connection level | FerricStore.multi/1 with function-based API |
| ACL | Per-connection ACL enforcement | No ACL checks |
| Client tracking | CLIENT TRACKING with invalidation messages | Not applicable |
Elixir API -- Complete Reference
Strings
# SET -- store a value with optional TTL
:ok = FerricStore.set("key", "value")
:ok = FerricStore.set("key", "value", ttl: :timer.hours(1))
# GET -- retrieve a value (nil for missing/expired keys)
{:ok, "value"} = FerricStore.get("key")
{:ok, nil} = FerricStore.get("nonexistent")
# DEL -- delete a key (works for any type: string, hash, list, set, zset)
:ok = FerricStore.del("key")
# EXISTS -- check if a key exists
true = FerricStore.exists("key")
false = FerricStore.exists("nonexistent")
# INCR / DECR -- atomic integer operations
# Key initialized to 0 if missing
{:ok, 1} = FerricStore.incr("counter")
{:ok, 2} = FerricStore.incr("counter")
{:ok, 1} = FerricStore.decr("counter")
# INCRBY / DECRBY -- increment/decrement by N
{:ok, 11} = FerricStore.incr_by("counter", 10)
{:ok, 1} = FerricStore.decr_by("counter", 10)
# INCRBYFLOAT -- atomic float increment
{:ok, "3.14"} = FerricStore.incr_by_float("pi", 3.14)
# MGET / MSET -- multi-key operations
:ok = FerricStore.mset(%{"k1" => "v1", "k2" => "v2", "k3" => "v3"})
{:ok, ["v1", nil, "v3"]} = FerricStore.mget(["k1", "missing", "k3"])
# MSETNX -- set multiple only if NONE exist
{:ok, true} = FerricStore.msetnx(%{"a" => "1", "b" => "2"})
{:ok, false} = FerricStore.msetnx(%{"a" => "1", "c" => "3"}) # "a" exists
# SETNX -- set only if key does not exist
{:ok, true} = FerricStore.setnx("new_key", "value")
{:ok, false} = FerricStore.setnx("new_key", "other") # already exists
# SETEX / PSETEX -- set with expiry
:ok = FerricStore.setex("key", 60, "value") # 60 seconds
:ok = FerricStore.psetex("key", 60_000, "value") # 60,000 milliseconds
# GETSET -- atomically set and return old value
:ok = FerricStore.set("key", "old")
{:ok, "old"} = FerricStore.getset("key", "new")
# GETDEL -- atomically get and delete
{:ok, "new"} = FerricStore.getdel("key")
{:ok, nil} = FerricStore.get("key") # gone
# GETEX -- get and update TTL
:ok = FerricStore.set("key", "value")
{:ok, "value"} = FerricStore.getex("key", ttl: 60_000)
# APPEND -- append to string value
:ok = FerricStore.set("greeting", "Hello")
{:ok, 11} = FerricStore.append("greeting", " World")
# STRLEN -- byte length of value
{:ok, 11} = FerricStore.strlen("greeting")
# GETRANGE -- substring by byte range
{:ok, "World"} = FerricStore.getrange("greeting", 6, 10)
# SETRANGE -- overwrite at offset
{:ok, 11} = FerricStore.setrange("greeting", 6, "Redis")
# KEYS -- find keys by glob pattern
{:ok, keys} = FerricStore.keys("user:*")
{:ok, all} = FerricStore.keys()
# DBSIZE / FLUSHDB
{:ok, count} = FerricStore.dbsize()
:ok = FerricStore.flushdb()Hash
Hash fields are stored as individual compound keys, enabling O(1) per-field access.
# HSET -- set one or more fields (pass a map)
:ok = FerricStore.hset("user:42", %{"name" => "alice", "email" => "a@b.com", "age" => "30"})
# HGET -- get a single field
{:ok, "alice"} = FerricStore.hget("user:42", "name")
{:ok, nil} = FerricStore.hget("user:42", "missing")
# HGETALL -- get all fields as a map
{:ok, %{"name" => "alice", "email" => "a@b.com", "age" => "30"}} = FerricStore.hgetall("user:42")
# HDEL -- delete fields
{:ok, 2} = FerricStore.hdel("user:42", ["email", "missing"])
# HEXISTS -- check if field exists
true = FerricStore.hexists("user:42", "name")
false = FerricStore.hexists("user:42", "email")
# HLEN -- field count
{:ok, 2} = FerricStore.hlen("user:42")
# HKEYS / HVALS -- field names / values
{:ok, ["name", "age"]} = FerricStore.hkeys("user:42")
{:ok, ["alice", "30"]} = FerricStore.hvals("user:42")
# HMGET -- multiple fields at once
{:ok, ["alice", nil, "30"]} = FerricStore.hmget("user:42", ["name", "missing", "age"])
# HINCRBY / HINCRBYFLOAT -- atomic field increment
{:ok, 31} = FerricStore.hincrby("user:42", "age", 1)
{:ok, "31.5"} = FerricStore.hincrbyfloat("user:42", "age", 0.5)
# HSETNX -- set field only if it does not exist
{:ok, true} = FerricStore.hsetnx("user:42", "country", "US")
{:ok, false} = FerricStore.hsetnx("user:42", "country", "UK") # already exists
# HRANDFIELD -- random field
{:ok, field} = FerricStore.hrandfield("user:42")
# HSTRLEN -- byte length of field value
{:ok, 5} = FerricStore.hstrlen("user:42", "name")Lists
# LPUSH / RPUSH -- push elements to head/tail
{:ok, 3} = FerricStore.lpush("queue", ["a", "b", "c"]) # head: c, b, a
{:ok, 4} = FerricStore.rpush("queue", ["d"]) # head: c, b, a, d
# LPOP / RPOP -- pop from head/tail
{:ok, "c"} = FerricStore.lpop("queue")
{:ok, "d"} = FerricStore.rpop("queue")
# LRANGE -- get range of elements (supports negative indices)
{:ok, ["b", "a"]} = FerricStore.lrange("queue", 0, -1)
# LLEN -- list length
{:ok, 2} = FerricStore.llen("queue")
# LINDEX -- get element by index
{:ok, "b"} = FerricStore.lindex("queue", 0)
{:ok, "a"} = FerricStore.lindex("queue", -1)
# LSET -- set element at index
:ok = FerricStore.lset("queue", 0, "X")
# LREM -- remove occurrences of element
# count > 0: from head, count < 0: from tail, count = 0: all
{:ok, 1} = FerricStore.lrem("queue", 0, "X")
# LINSERT -- insert relative to pivot
{:ok, 2} = FerricStore.rpush("list", ["a", "c"])
{:ok, 3} = FerricStore.linsert("list", :before, "c", "b")
# list is now: a, b, c
# LMOVE -- atomically move between lists
{:ok, "a"} = FerricStore.lmove("list", "other", :left, :right)
# LPOS -- find position(s) of element
{:ok, 0} = FerricStore.lpos("list", "b")
{:ok, [0, 1]} = FerricStore.lpos("list", "b", count: 0) # all positions
# LTRIM -- trim list to range
:ok = FerricStore.rpush("nums", ["1", "2", "3", "4", "5"])
:ok = FerricStore.ltrim("nums", 1, 3)
# nums is now: 2, 3, 4Blocking list commands (
BLPOP,BRPOP,BLMOVE,BLMPOP) are not available in embedded mode. These commands require a persistent TCP connection to block on. In embedded mode, uselpop/rpopwith a polling loop, or subscribe to list-push events via Phoenix PubSub for a reactive pattern.
Sets
# SADD -- add members
{:ok, 3} = FerricStore.sadd("tags", ["elixir", "rust", "redis"])
# SMEMBERS -- get all members (unordered)
{:ok, members} = FerricStore.smembers("tags") # ["elixir", "rust", "redis"] in any order
# SISMEMBER -- check membership
true = FerricStore.sismember("tags", "elixir")
false = FerricStore.sismember("tags", "python")
# SMISMEMBER -- check multiple memberships
{:ok, [1, 0, 1]} = FerricStore.smismember("tags", ["elixir", "python", "rust"])
# SCARD -- set size
{:ok, 3} = FerricStore.scard("tags")
# SREM -- remove members
{:ok, 1} = FerricStore.srem("tags", ["redis"])
# SRANDMEMBER -- random member without removal
{:ok, member} = FerricStore.srandmember("tags")
# SPOP -- random member with removal
{:ok, member} = FerricStore.spop("tags")
# SDIFF / SINTER / SUNION -- set algebra
FerricStore.sadd("set1", ["a", "b", "c"])
FerricStore.sadd("set2", ["b", "c", "d"])
{:ok, diff} = FerricStore.sdiff(["set1", "set2"]) # ["a"]
{:ok, inter} = FerricStore.sinter(["set1", "set2"]) # ["b", "c"]
{:ok, union} = FerricStore.sunion(["set1", "set2"]) # ["a", "b", "c", "d"]Sorted Sets
# ZADD -- add members with scores
{:ok, 3} = FerricStore.zadd("scores", [{100.0, "alice"}, {200.0, "bob"}, {150.0, "carol"}])
# ZRANGE -- range by index
{:ok, ["alice", "carol", "bob"]} = FerricStore.zrange("scores", 0, -1)
# ZRANGE with scores -- returns list of {member, score} tuples
{:ok, [{"alice", 100.0}, {"carol", 150.0}, {"bob", 200.0}]} =
FerricStore.zrange("scores", 0, -1, withscores: true)
# ZSCORE -- get member's score
{:ok, 100.0} = FerricStore.zscore("scores", "alice")
{:ok, nil} = FerricStore.zscore("scores", "unknown")
# ZCARD -- cardinality
{:ok, 3} = FerricStore.zcard("scores")
# ZRANK / ZREVRANK -- rank (0-based)
{:ok, 0} = FerricStore.zrank("scores", "alice") # lowest score
{:ok, 2} = FerricStore.zrevrank("scores", "alice") # highest rank reversed
# ZRANGEBYSCORE -- range by score (supports -inf, +inf, exclusive prefix)
{:ok, ["carol", "bob"]} = FerricStore.zrangebyscore("scores", "150", "+inf")
# ZCOUNT -- count in score range
{:ok, 2} = FerricStore.zcount("scores", "100", "150")
# ZINCRBY -- increment score
{:ok, "115.0"} = FerricStore.zincrby("scores", 15.0, "alice")
# ZREM -- remove members
{:ok, 1} = FerricStore.zrem("scores", ["alice"])
# ZPOPMIN / ZPOPMAX -- pop lowest/highest scored
{:ok, [{"carol", 150.0}]} = FerricStore.zpopmin("scores", 1)
{:ok, [{"bob", 200.0}]} = FerricStore.zpopmax("scores", 1)
# ZMSCORE -- multiple scores at once
{:ok, scores} = FerricStore.zmscore("scores", ["alice", "unknown"])
# ZRANDMEMBER -- random member
{:ok, member} = FerricStore.zrandmember("scores")TTL / Expiry
# Set TTL via SET option
:ok = FerricStore.set("session", "data", ttl: :timer.minutes(30))
# EXPIRE / PEXPIRE -- set TTL on existing key
{:ok, true} = FerricStore.expire("key", :timer.hours(1))
{:ok, true} = FerricStore.pexpire("key", 30_000)
# EXPIREAT / PEXPIREAT -- set absolute expiry
{:ok, true} = FerricStore.expireat("key", 1700000000)
{:ok, true} = FerricStore.pexpireat("key", 1700000000000)
# TTL / PTTL -- remaining time
{:ok, seconds} = FerricStore.ttl("session") # seconds remaining
{:ok, ms} = FerricStore.pttl("session") # milliseconds remaining
{:ok, nil} = FerricStore.ttl("no_ttl_key") # nil = no expiry
# Returns {:ok, -2} style in RESP3, nil in embedded for missing keys
# EXPIRETIME / PEXPIRETIME -- absolute expiry
{:ok, unix_seconds} = FerricStore.expiretime("key")
{:ok, unix_ms} = FerricStore.pexpiretime("key")
# PERSIST -- remove expiry
{:ok, true} = FerricStore.persist("key")Streams
# XADD -- add entry (auto-generates ID with * by default)
{:ok, id} = FerricStore.xadd("events", ["type", "click", "page", "/home"])
# id => "1679000000000-0"
# XLEN -- stream length
{:ok, 1} = FerricStore.xlen("events")
# XRANGE / XREVRANGE -- range queries
{:ok, entries} = FerricStore.xrange("events", "-", "+")
{:ok, entries} = FerricStore.xrange("events", "-", "+", count: 10)
{:ok, entries} = FerricStore.xrevrange("events", "+", "-")
# Each entry: [id, field1, value1, field2, value2, ...]
# XTRIM -- trim by MAXLEN or MINID
{:ok, trimmed} = FerricStore.xtrim("events", maxlen: 1000)Note: XREAD BLOCK, XGROUP, XREADGROUP, and XACK are only available in TCP/RESP3 mode. The embedded API does not support blocking reads or consumer groups.
Bitmap
# SETBIT / GETBIT -- individual bit operations
{:ok, 0} = FerricStore.setbit("bitmap", 7, 1) # returns old bit value
{:ok, 1} = FerricStore.getbit("bitmap", 7)
# BITCOUNT -- count set bits
{:ok, count} = FerricStore.bitcount("bitmap")
{:ok, count} = FerricStore.bitcount("bitmap", 0, 0) # first byte only
{:ok, count} = FerricStore.bitcount("bitmap", 0, 7, :bit) # first 8 bits
# BITPOS -- find first 0 or 1 bit
{:ok, pos} = FerricStore.bitpos("bitmap", 1)
{:ok, pos} = FerricStore.bitpos("bitmap", 0, 1) # starting from byte 1
# BITOP -- bitwise operations
{:ok, len} = FerricStore.bitop(:and, "dest", ["bitmap1", "bitmap2"])
{:ok, len} = FerricStore.bitop(:or, "dest", ["bitmap1", "bitmap2"])
{:ok, len} = FerricStore.bitop(:xor, "dest", ["bitmap1", "bitmap2"])
{:ok, len} = FerricStore.bitop(:not, "dest", ["bitmap1"])HyperLogLog
# PFADD -- add elements to HLL sketch
{:ok, true} = FerricStore.pfadd("hll", ["a", "b", "c"])
{:ok, false} = FerricStore.pfadd("hll", ["a"]) # no modification
# PFCOUNT -- estimated cardinality
{:ok, 3} = FerricStore.pfcount(["hll"])
# PFMERGE -- merge multiple HLLs
FerricStore.pfadd("hll2", ["d", "e"])
:ok = FerricStore.pfmerge("merged", ["hll", "hll2"])
{:ok, 5} = FerricStore.pfcount(["merged"])Bloom Filter
Bloom filters are backed by mmap files. Auto-created with defaults (error_rate=0.01, capacity=100) on first bf_add.
# BF.RESERVE -- create with specific parameters
:ok = FerricStore.bf_reserve("filter", 0.01, 10_000)
# BF.ADD / BF.MADD -- add elements
{:ok, 1} = FerricStore.bf_add("filter", "hello")
{:ok, results} = FerricStore.bf_madd("filter", ["a", "b", "c"])
# results => [1, 1, 1] (1 = newly added)
# BF.EXISTS / BF.MEXISTS -- check membership
{:ok, 1} = FerricStore.bf_exists("filter", "hello") # may exist
{:ok, 0} = FerricStore.bf_exists("filter", "unknown") # definitely not
{:ok, [1, 0]} = FerricStore.bf_mexists("filter", ["hello", "unknown"])
# BF.CARD -- number of items added
{:ok, 4} = FerricStore.bf_card("filter")
# BF.INFO -- filter metadata
{:ok, info} = FerricStore.bf_info("filter")
# info includes: Capacity, Size, Number of filters, items, error rate, hash functions, bitsCuckoo Filter
Cuckoo filters support deletion (unlike Bloom). Auto-created with capacity 1024 on first cf_add.
# CF.RESERVE
:ok = FerricStore.cf_reserve("cuckoo", 10_000)
# CF.ADD / CF.ADDNX
{:ok, 1} = FerricStore.cf_add("cuckoo", "hello")
{:ok, 1} = FerricStore.cf_addnx("cuckoo", "world") # add only if not present
{:ok, 0} = FerricStore.cf_addnx("cuckoo", "world") # already present
# CF.EXISTS / CF.MEXISTS
{:ok, 1} = FerricStore.cf_exists("cuckoo", "hello")
{:ok, [1, 0]} = FerricStore.cf_mexists("cuckoo", ["hello", "missing"])
# CF.DEL -- delete one occurrence
{:ok, 1} = FerricStore.cf_del("cuckoo", "hello")
# CF.COUNT -- approximate count of fingerprint occurrences
{:ok, 1} = FerricStore.cf_count("cuckoo", "world")
# CF.INFO
{:ok, info} = FerricStore.cf_info("cuckoo")Count-Min Sketch
# CMS.INITBYDIM -- create by dimensions
:ok = FerricStore.cms_initbydim("sketch", 1000, 5)
# CMS.INITBYPROB -- create by target accuracy
:ok = FerricStore.cms_initbyprob("sketch2", 0.001, 0.01)
# CMS.INCRBY -- increment counts
{:ok, counts} = FerricStore.cms_incrby("sketch", [{"page:/home", 3}, {"page:/about", 1}])
# counts => [3, 1] (estimated minimum counts)
# CMS.QUERY -- query counts
{:ok, counts} = FerricStore.cms_query("sketch", ["page:/home", "page:/about"])
# counts => [3, 1]
# CMS.INFO
{:ok, info} = FerricStore.cms_info("sketch")
# info => [width, depth, count]TopK
# TOPK.RESERVE -- create tracker
:ok = FerricStore.topk_reserve("top10", 10)
# Optional: FerricStore.topk_reserve("top10", 10, width: 8, depth: 7, decay: 0.9)
# TOPK.ADD -- add items (returns evicted items or nil)
{:ok, evicted} = FerricStore.topk_add("top10", ["item1", "item2", "item3"])
# TOPK.QUERY -- check if items are in top-K
{:ok, results} = FerricStore.topk_query("top10", ["item1", "unknown"])
# results => [1, 0]
# TOPK.LIST -- list current top-K
{:ok, items} = FerricStore.topk_list("top10")
# TOPK.INFO
{:ok, info} = FerricStore.topk_info("top10")TDigest
T-digests provide accurate quantile estimation with bounded memory, especially at the tails (P99, P99.9).
# TDIGEST.CREATE
:ok = FerricStore.tdigest_create("latency")
# Optional: FerricStore.tdigest_create("latency", compression: 200)
# TDIGEST.ADD
:ok = FerricStore.tdigest_add("latency", [1.0, 2.0, 3.0, 4.0, 5.0, 10.0, 50.0, 100.0])
# TDIGEST.QUANTILE -- estimate values at quantile positions
{:ok, quantiles} = FerricStore.tdigest_quantile("latency", [0.5, 0.95, 0.99])
# TDIGEST.CDF -- cumulative distribution function
{:ok, cdfs} = FerricStore.tdigest_cdf("latency", [2.5, 50.0])
# TDIGEST.RANK / TDIGEST.REVRANK -- rank estimation
{:ok, ranks} = FerricStore.tdigest_rank("latency", [3.0])
{:ok, revranks} = FerricStore.tdigest_revrank("latency", [3.0])
# TDIGEST.BYRANK / TDIGEST.BYREVRANK -- value at rank
{:ok, values} = FerricStore.tdigest_byrank("latency", [0, 4])
{:ok, values} = FerricStore.tdigest_byrevrank("latency", [0])
# TDIGEST.TRIMMED_MEAN -- mean excluding tails
{:ok, mean} = FerricStore.tdigest_trimmed_mean("latency", 0.1, 0.9)
# TDIGEST.MIN / TDIGEST.MAX
{:ok, min} = FerricStore.tdigest_min("latency")
{:ok, max} = FerricStore.tdigest_max("latency")
# TDIGEST.INFO
{:ok, info} = FerricStore.tdigest_info("latency")
# TDIGEST.RESET -- clear data, preserve compression
:ok = FerricStore.tdigest_reset("latency")Geo
Geo is implemented on top of Sorted Sets with geohash-encoded scores.
# GEOADD -- add geo positions
{:ok, 2} = FerricStore.geoadd("places", [
{13.361389, 38.115556, "Palermo"},
{15.087269, 37.502669, "Catania"}
])
# GEODIST -- distance between members
{:ok, dist} = FerricStore.geodist("places", "Palermo", "Catania", "km")
# GEOHASH -- geohash strings
{:ok, hashes} = FerricStore.geohash("places", ["Palermo"])
# GEOPOS -- coordinates
{:ok, positions} = FerricStore.geopos("places", ["Palermo", "Catania"])
# positions => [[13.361389, 38.115556], [15.087269, 37.502669]]JSON
# JSON.SET -- set a JSON document
:ok = FerricStore.json_set("doc", "$", ~s({"name":"alice","age":30,"tags":["elixir"]}))
# JSON.GET -- get value at path
{:ok, value} = FerricStore.json_get("doc", "$.name")
# value => "[\"alice\"]"
# JSON.NUMINCRBY -- increment number
{:ok, result} = FerricStore.json_numincrby("doc", "$.age", "1")
# JSON.TYPE -- get type at path
{:ok, type} = FerricStore.json_type("doc", "$.name")
# type => "string"
# JSON.DEL -- delete at path
{:ok, 1} = FerricStore.json_del("doc", "$.age")
# JSON.OBJKEYS / JSON.OBJLEN
{:ok, keys} = FerricStore.json_objkeys("doc")
{:ok, len} = FerricStore.json_objlen("doc")
# JSON.STRLEN -- string length at path
{:ok, 5} = FerricStore.json_strlen("doc", "$.name")
# JSON.ARRAPPEND / JSON.ARRLEN
{:ok, 2} = FerricStore.json_arrappend("doc", "$.tags", ~s("rust"))
{:ok, 2} = FerricStore.json_arrlen("doc", "$.tags")Compare-and-Swap
Atomic compare-and-swap without Lua scripting. Routed directly through Router.cas/4.
FerricStore.set("version", "1")
{:ok, true} = FerricStore.cas("version", "1", "2") # swap succeeded
{:ok, false} = FerricStore.cas("version", "1", "3") # expected "1" but found "2"
{:ok, nil} = FerricStore.cas("nonexistent", "1", "2") # key does not existDistributed Lock
# Acquire lock for 5 seconds
:ok = FerricStore.lock("resource:1", "worker_a", 5_000)
# Extend TTL while holding lock
{:ok, 1} = FerricStore.extend("resource:1", "worker_a", 10_000)
# Release lock (must be same owner)
{:ok, 1} = FerricStore.unlock("resource:1", "worker_a")Rate Limiting
Sliding window rate limiter.
# 100 requests per 60-second window
{:ok, [allowed, current_count, remaining, retry_after]} =
FerricStore.ratelimit_add("api:user42", 60_000, 100)
# allowed: 1 (allowed) or 0 (rejected)
# current_count: current count in window
# remaining: requests left before limit
# retry_after: ms to wait if rejected (0 if allowed)Cache-Aside with Stampede Protection
FETCH_OR_COMPUTE ensures only one caller computes a missing cache value. All other concurrent callers block until the value is available.
case FerricStore.fetch_or_compute("expensive:key", ttl: 60_000) do
{:ok, {:hit, value}} ->
# Cache hit -- return immediately
value
{:ok, {:compute, _hint}} ->
# Cache miss -- this process is the designated computer
value = expensive_computation()
FerricStore.fetch_or_compute_result("expensive:key", value, ttl: 60_000)
value
endTo report a computation error:
FerricStore.fetch_or_compute_error("expensive:key", "computation failed")Multi/Transaction
{:ok, results} = FerricStore.multi(fn tx ->
tx
|> FerricStore.Tx.set("k1", "v1")
|> FerricStore.Tx.set("k2", "v2")
|> FerricStore.Tx.get("k1")
|> FerricStore.Tx.incr("counter")
end)
# results => [:ok, :ok, {:ok, "v1"}, {:ok, 1}]Pipelines
Batch multiple operations for efficiency:
{:ok, results} = FerricStore.pipeline(fn pipe ->
pipe
|> FerricStore.Pipe.set("key1", "val1")
|> FerricStore.Pipe.set("key2", "val2")
|> FerricStore.Pipe.get("key1")
|> FerricStore.Pipe.incr("counter")
|> FerricStore.Pipe.hset("user:1", %{"name" => "alice"})
|> FerricStore.Pipe.zadd("scores", [{100.0, "alice"}])
end)Key Operations
# TYPE -- get key type
{:ok, "string"} = FerricStore.type("key")
{:ok, "hash"} = FerricStore.type("user:42")
{:ok, "none"} = FerricStore.type("missing")
# COPY
{:ok, true} = FerricStore.copy("src", "dst")
# RENAME / RENAMENX
:ok = FerricStore.rename("old", "new")
{:ok, true} = FerricStore.renamenx("old", "new")
# RANDOMKEY
{:ok, "some_key"} = FerricStore.randomkey()Server
{:ok, "PONG"} = FerricStore.ping()
{:ok, "hello"} = FerricStore.echo("hello")
:ok = FerricStore.flushall()Named Caches
Direct operations to different cache instances:
:ok = FerricStore.set("session:abc", data, cache: :sessions)
{:ok, data} = FerricStore.get("session:abc", cache: :sessions)Performance Characteristics
| Operation | Latency | Notes |
|---|---|---|
| Hot read (ETS) | ~1-5 us | No GenServer roundtrip, lock-free concurrent reads |
| Cold read (Bitcask) | ~50-200 us | pread via Rust NIF, NVMe |
| Write | ~10-50 us | ETS immediate, Raft group-commit async |
| INCR | ~10-20 us | Read-modify-write in shard GenServer |
| Pipeline (N ops) | ~N * 10 us | Commands execute sequentially |
Large Value Handling
Values larger than hot_cache_max_value_size (default: 64 KB) are stored as nil in ETS to avoid binary copy overhead on every read. They are read from Bitcask on access. At startup, FerricStore scans for large values and logs a warning:
[warning] Embedded large value check: 3 value(s) exceed threshold;
largest key="big:blob" (2097152 bytes)If you routinely store values larger than 64 KB, consider:
- Raising
hot_cache_max_value_size(if you have the RAM) - Chunking values into smaller pieces
- Using the standalone mode with sendfile zero-copy
Testing
With the instance-based architecture, each test gets its own fully isolated FerricStore instance -- separate shards, ETS tables, Raft WAL, and data directory. No shared state, supporting async: true.
defmodule MyApp.CacheTest do
use ExUnit.Case, async: true
setup do
{:ok, _} = TestCache.start_link(data_dir: make_temp_dir(), shard_count: 2)
on_exit(fn -> TestCache.stop() end)
:ok
end
test "set and get" do
TestCache.set("key", "value")
assert {:ok, "value"} = TestCache.get("key")
end
test "isolated from other tests" do
# Each test has its own instance -- no shared state
assert {:ok, nil} = TestCache.get("key")
end
endMultiple Instances
By default, FerricStore starts a single :default instance that the
FerricStore module uses. For applications that need isolated cache
domains (separate data directories, memory limits, or eviction policies),
define named instances with use FerricStore:
defmodule MyApp.Sessions do
use FerricStore,
data_dir: "/data/sessions",
shard_count: 2,
max_memory_bytes: 512_000_000,
eviction_policy: :volatile_lfu
end
defmodule MyApp.PageCache do
use FerricStore,
data_dir: "/data/page_cache",
shard_count: 4,
max_memory_bytes: 2_000_000_000,
eviction_policy: :allkeys_lfu
endSupervision Tree
Add each instance as a child in your application supervisor. Each instance starts its own shards, ETS tables, and Raft system independently:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Sessions,
MyApp.PageCache
]
Supervisor.start_link(children, strategy: :one_for_one)
end
endUsage
Each module gets the full FerricStore API. Instances are completely isolated -- keys in one instance are invisible to another:
MyApp.Sessions.set("sess:abc", session_data, ttl: :timer.minutes(30))
{:ok, data} = MyApp.Sessions.get("sess:abc")
MyApp.PageCache.set("page:/home", html, ttl: :timer.hours(1))
{:ok, nil} = MyApp.PageCache.get("sess:abc") # not found hereWhen to Use Multiple Instances
- Separate data directories -- session data on fast NVMe, page cache on larger disk
- Different eviction policies --
:volatile_lfufor sessions (TTL-based),:allkeys_lfufor a general cache - Independent memory budgets -- prevent one workload from evicting another's hot keys
- Isolation in tests -- each test module can use its own instance with
ExUnit.Callbacks.tmp_dir
A single instance with namespace prefixes (session:, cache:) is simpler
and sufficient when you don't need separate eviction pools or data directories.
Integration with Phoenix
A common pattern is to use FerricStore as a session store or cache in a Phoenix application:
# lib/my_app/cache.ex
defmodule MyApp.Cache do
def get(key) do
case FerricStore.get(key) do
{:ok, nil} -> :miss
{:ok, value} -> {:ok, :erlang.binary_to_term(value)}
end
end
def put(key, value, ttl \\ :timer.hours(1)) do
FerricStore.set(key, :erlang.term_to_binary(value), ttl: ttl)
end
def delete(key) do
FerricStore.del(key)
end
def fetch_or_compute(key, ttl, fun) do
case FerricStore.fetch_or_compute(key, ttl: ttl) do
{:ok, {:hit, bin}} -> :erlang.binary_to_term(bin)
{:ok, {:compute, _}} ->
value = fun.()
bin = :erlang.term_to_binary(value)
FerricStore.fetch_or_compute_result(key, bin, ttl: ttl)
value
end
end
endHealth Checks
Even in embedded mode, you can check FerricStore's health programmatically:
Ferricstore.Health.ready?()
# => true
Ferricstore.Health.check()
# => %{status: :ok, shard_count: 4, shards: [...], uptime_seconds: 120}