Embedded mode runs FerricStore inside your Elixir application. There is no TCP listener and no RESP serialization; your code calls the public FerricStore module directly.
Use this guide after Getting Started. It covers setup, first commands, behavior differences from TCP/RESP mode, and the embedded API reference.
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.4.0"}
]
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. The public API module is FerricStore; internal supervision modules use the internal Ferricstore.* namespace. Once started, call functions on FerricStore:
:ok = FerricStore.set("session:abc", session_data, ttl: :timer.minutes(30))
{:ok, data} = FerricStore.get("session:abc")Quick Examples
Store a short-lived cache value:
:ok = FerricStore.set("cache:user:42", :erlang.term_to_binary(user), ttl: :timer.minutes(10))
{:ok, encoded} = FerricStore.get("cache:user:42")
user = :erlang.binary_to_term(encoded)Use an atomic counter:
{:ok, 1} = FerricStore.incr("rate:user:42")
:ok = FerricStore.expire("rate:user:42", 60_000)Use a hash for structured fields:
:ok = FerricStore.hset("profile:42", %{"name" => "alice", "plan" => "pro"})
{:ok, "pro"} = FerricStore.hget("profile:42", "plan")Use FerricFlow directly from embedded mode:
:ok = FerricStore.flow_create("email-1", type: "email", state: "queued", payload: "welcome:user-1")
{:ok, [job]} = FerricStore.flow_claim_due("email", state: "queued", worker: "worker-1", limit: 1)
{:ok, _} = FerricStore.flow_complete(job.id, job.lease_token,
fencing_token: job.fencing_token,
result: "sent"
)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 |
API 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 your application's own notification mechanism.
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 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, WARaft segments, 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 an application 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}