Performance
- perf: values are no longer run through
:erlang.term_to_binary/1for adapters that store Erlang terms natively (Cache.ETS,Cache.Agent,Cache.PersistentTerm,Cache.ConCache,Cache.Counter). The encode/decode round trip was pure overhead on those adapters, and the decode dominated the lookup it was attached to. On a 500k-entry ETS table,get/1of a ~10KB body drops from 29,981 ns to 6,211 ns (4.8x) andput/2from 23,542 ns to 10,804 ns (2.2x). The win holds across payload sizes — a small map goes from 2,105 ns to 302 ns (7.0x). Cache.PersistentTermnow hands out the stored term itself on every read, restoring the zero-copy property the adapter exists for.Cache.RefreshAheadresolves encoding against the adapter it wraps, so a RefreshAhead overCache.ETSstops encoding too. Wrapping a byte-storing adapter is unchanged.
Features
feat(multi_layer): cross-node layer coherence. A node-local fast layer went stale on every node except the writer —
put/3wrote the layers on the calling node only, anddelete/1was the only cross-layer remover, so there was no way to invalidate another node's L1 without also dropping the shared layer. EachCache.MultiLayercache now runs a per-nodeCache.MultiLayer.Coordinatorthat joins a:pggroup named after the cache, which doubles as the registry of nodes holding it. Withbroadcast_modeset, a successful write notifies every other member, which applies it to its ownbroadcast_layers::invalidate— remote nodes drop the key from their local layers and lazily re-read through the shared layer. Messages are key-sized, so this is the choice for large values.:replicate— remote nodes write the new value immediately. That is a full value copy per member, so it is for small values only.
Delivery is best-effort — sends to
:pgmembers, no acks — sobackfill_ttland the layer TTLs remain the correctness floor for a member that misses a message. The:pgscope is started unlinked so it does not die with whichever coordinator started it, and each coordinator re-joins when it sees the scope go down.feat: added the optional
Cache.native_term_storage?/1callback, letting an adapter declare that it stores Erlang terms natively. It is resolved at compile time, so there is no runtime branch on the read or write path. The callback is optional and defaults to encoding, so third-party adapters keep their current behaviour unchanged.feat: Elixir 1.20 support. The
use Cachemacro now emits a singleadapter_options!/1clause matching the configured adapter's opts shape rather than a clause per shape plus a catch-all, and the generatedget/1drops the{:error, _}branches that 1.20's type checker proves unreachable. Both were dead-clause warnings under--warnings-as-errors.
Bug Fixes
- fix:
:compression_levelis reachable. It was unusable on every path — no adapter declares it, soNimbleOptionsrejected it on compile-time adapter opts, and it resolved tonilbefore it could reach the encoder otherwise. It is now an option on theuse Cacheline (compression_level: 6), it is taken off the adapter opts before they are validated so theopts: [compression_level: 6]spelling works too, and it is never handed to the adapter. Setting it forces encoding on adapters that hold terms natively — asking for compression is asking for bytes. A cache using a strategy adapter raises at compile time rather than ignoring the option. - fix:
Cache.ConCache.get_or_store/3followed byget/1no longer raises.get_or_store/3writes through ConCache directly, bypassing the encode input/3, so the matchingget/1tried tobinary_to_term/1a raw term. - fix: a binary value wrapped in braces but not valid JSON (eg
"{oops}") no longer raisesJason.DecodeErroron read.Cache.TermEncoder.decode/1usedJason.decode!/1, and now falls back to returning the binary unchanged. - fix: raw
Cache.ETSoperations (match_object/1,select/1,tab2list/0,foldl/2) now see the terms that wereput, rather than the opaque encoded binaries they used to return. - fix: caching a JSON string hands back the string.
encode/2stored a brace-wrapped binary unencoded, sodecode/1had to guess what it was looking at —put(:k, ~s({"a": 1}))followed byget(:k)returned%{"a" => 1}, aStringin and aMapout. Binaries are now always run through:erlang.term_to_binary/1, anddecode/1keys off the external term format version byte rather than the shape of the payload, so nothing is guessed. - fix:
decode/1no longer raises on a binary that is not an encoded term. It used to reach:erlang.binary_to_term/1for anything that was not digits or brace-wrapped, which raisedArgumentErroron a value written into the store by something other than this library.
Breaking Changes
- Values held by native-term adapters are now stored as terms rather than encoded binaries. This is not observable through
get/1,put/3anddelete/1, which round-trip exactly as before. It is observable if you read the underlying store directly (:ets.lookup/2,:persistent_term.get/1,ConCache.get/2) or through the raw ETS API — those now return terms, which is what they were always meant to return. Cache.DETSis unchanged and still encodes, so existing.detsfiles stay readable.Cache.ETSwith:rehydration_pathalso still encodes, so existing table dumps stay loadable.Cache.HashRing, andCache.MultiLayerunderbroadcast_mode: :replicate, also still encode. Those strategies hand the stored value to another node, so a rolling deploy has 0.4.x and 0.5.x reading each other's writes for the same key and they have to agree on the representation. Their wire format is unchanged from 0.4.x and a mixed-version cluster is safe.- An in-memory cache populated by an older version and read by this one would return raw binaries, but ETS, Agent, PersistentTerm and ConCache do not survive a restart, and every representation that outlives a node — disk, Redis, another node — is still encoded, so there is no upgrade path on which that can happen.
- A brace-wrapped or all-digit binary is now stored encoded rather than raw. Keys written by an earlier version are not in external term format, so they still decode the way they always did: a raw JSON string in Redis reads back as a map, a raw digit string as an integer. Only values written from this version on are type-stable. Code that was reading those keys out of Redis with another tool and expecting readable JSON gets an encoded term instead — write JSON through
json_set/3(RedisJSON), which is a separate path and unchanged. - The minimum Elixir version is now
~> 1.15, up from~> 1.11.
Chores
- chore(deps):
:credo1.7.13 -> 1.7.18. 1.7.13 crashes on Elixir 1.20's sigil token format. The newer Credo flagged onelength/1 > 0check, rewritten as!== []. - chore(deps): dropped the
:fakertest dependency, which does not compile on Elixir 1.20 — a raw U+0085 byte is a hard syntax error there. It backed three random-string helpers, now a small module undertest/support. - chore(mix): moved
:preferred_cli_envintodef cli/0, where Elixir 1.20 expects it. - fix(test):
Cache.RefreshAhead's "global lock prevents refresh while lock is held" test drains the in-flight refresh task while the lock is still held. The task raced the:global.del_lock/2on the next line, could acquire the freed lock and refresh the value the following assertion expected to be untouched. The race predated this release; makingget/1faster widened the window enough for CI to hit it.
0.4.9
Performance
- perf(sandbox): make
Cache.SandboxRegistry.register_caches/2post-register sleep configurable viaCache.Config.sandbox_sleep_ms/0(config :elixir_cache, :sandbox_sleep_ms, 50). Default is unchanged (50 ms). Test suites that don't need the sleep can set it to0inconfig/test.exsto save ~50 ms per cache registered per test — material on apps with many cache modules.
0.4.8
Bug Fixes
- fix: ETS/DETS/Counter
start_linknow waits for the table/counter ref to be ready before returning, eliminating a startup race where supervisors saw a started child before the underlying table existed - fix(sandbox): match real ETS match-spec semantics via
:ets.match_spec_compile/1+:ets.match_spec_run/2inselect/2,3,select_count/2,select_delete/2, andselect_replace/2
Chores
- ci: run workflows on
pull_request; restrictpushtrigger tomainto avoid duplicate runs
0.4.7
Bug Fixes
- fix: apply
maybe_sandbox_keytohash_get_manykeys - fix: support
sandbox?option with strategy adapters (HashRing,MultiLayer,RefreshAhead) - fix: isolate
Cache.Sandboxscan results per sandbox
Refactors
- refactor: scope
Cache.Sandboxstate bysandbox_idinternally - chore(metrics): fix cardinality leak in
extract_error_metadata/1
0.4.6
0.4.5
- chore: fix dialyzer
0.4.4
Bug Fixes
- refactor(counter): restrict
get/2to integer keys only and add bounds checking
0.4.3
Features
- feat(counter): add direct integer key indexing for deterministic slot access
0.4.2
Refactors
- refactor(counter): replace dynamic index map with deterministic hash-based indexing
0.4.1
New Adapters
Cache.PersistentTerm— new adapter backed by Erlang's:persistent_termfor extremely fast reads on rarely-written data such as configuration values. TTL is not supported; values persist until explicitly deleted.Cache.Counter— new atomic integer counter adapter backed by Erlang's:countersmodule. Provides lock-free increment/decrement operations viaput/4(values1or-1) and injectsincrement/1,2anddecrement/1,2into consumer modules throughuse Cache. Counter references and index maps are stored in:persistent_termfor zero-latency access from any process.
Strategy Adapters
Cache.Strategy— new behaviour for strategy-based adapters. Strategies compose over existing cache adapters and receive the underlying adapter module and its resolved opts so they can delegate operations appropriately. Adapter tuple format:adapter: {StrategyModule, UnderlyingAdapterOrConfig}.Cache.HashRing— consistent hash ring strategy usinglibring. Distributes keys across Erlang cluster nodes, forwarding operations to the owning node via:erpc(or a configurablerpc_module). The ring tracks node membership automatically viaHashRing.Managedwithmonitor_nodes: true. Includes read-repair: on a miss, previous ring snapshots (maintained byCache.HashRing.RingMonitor) are consulted to lazily migrate keys after rebalancing. Configurable options:ring_opts,node_weight,rpc_module,ring_history_size.Cache.MultiLayer— cascades reads and writes through multiple cache layers (e.g. ETS → Redis). Reads walk fastest → slowest with automatic backfill on a slower-layer hit. Writes go slowest → fastest to ensure durability. Supports an optionalon_fetchcallback on total miss and abackfill_ttlfor backfilled entries.Cache.RefreshAhead— proactively refreshes hot keys in the background before their TTL expires. Onget, if the value is within therefresh_beforewindow, the current value is returned immediately and an asyncTaskrefreshes it. Uses a per-cache ETS deduplication table and:globaldistributed locking to prevent redundant refreshes across nodes. Requires arefresh_before(ms) opt and arefresh/1callback oron_refreshopt.
Test Utilities
Cache.CaseTemplate— new ExUnit case template for applications with many test files. Define aCacheCasemodule once withdefault_cachesorsupervisors, thenuse MyApp.CacheCasein any test file to get automatic sandboxed cache setup. Supports per-file additional caches via:cachesand detects duplicate cache registrations at setup time.
Bug Fixes
- fix(ets): suppress
no_warn_undefinedfor OTP 26+ ETS functions on older OTP versions.
0.4.0
feat: add all ets/dets functions and ability for ets to rehydrate fix(con cache): allow concache to accept ets options
0.3.13
- fix: allow
Cache.ConCacheto acceptets_options(strict NimbleOptions validation + normalization) - feat: allow
Cache.ETSwrite_concurrency: :auto(OTP 25+)
0.3.12
- chore: fix warnings
0.3.11
- fix: redis
0.3.10
- fix: sandbox fix for smembers & sadd
0.3.9
- chore: add docs
- fix: set fix
0.3.8
- feat: add functions for ets & dets caches
0.3.7
- feat: add metrics module
0.3.6
- chore: fix child_spec type
0.3.5
- fix: Cache child spec for starting under a supervisor
0.3.4
add
get_or_create(key, (() -> {:ok, value} | {:error, reson}))to allow for create or updates
0.3.3
- use adapter options to allow for runtime options
- update sandbox hash_set_many behaviour to be consistent
- ensure dets does a mkdir_p at startup incase directory doesn't exist
0.3.2
- Update nimble options to 1.x
0.3.1
- add some more json sandboxing
- update redis to remove uri from command options
0.3.0
- add con_cache
- add ets cache
- fix hash opts for redis
0.2.1
- Adds support for application configuration and runtime options
0.2.0
- Stop redis connection errors from crashing the app
- Fix hash functions for
Cache.Redis - Support runtime cache config
- Support redis JSON
- Add
strategyoption toCache.Redisfor poolboy
0.1.1
- Expose
pipelineandcommandfunctions on redis adapters
0.1.0
- Initial Release