Performance

  • perf: values are no longer run through :erlang.term_to_binary/1 for 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/1 of a ~10KB body drops from 29,981 ns to 6,211 ns (4.8x) and put/2 from 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.PersistentTerm now hands out the stored term itself on every read, restoring the zero-copy property the adapter exists for.
  • Cache.RefreshAhead resolves encoding against the adapter it wraps, so a RefreshAhead over Cache.ETS stops 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/3 wrote the layers on the calling node only, and delete/1 was the only cross-layer remover, so there was no way to invalidate another node's L1 without also dropping the shared layer. Each Cache.MultiLayer cache now runs a per-node Cache.MultiLayer.Coordinator that joins a :pg group named after the cache, which doubles as the registry of nodes holding it. With broadcast_mode set, a successful write notifies every other member, which applies it to its own broadcast_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 :pg members, no acks — so backfill_ttl and the layer TTLs remain the correctness floor for a member that misses a message. The :pg scope 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?/1 callback, 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 Cache macro now emits a single adapter_options!/1 clause matching the configured adapter's opts shape rather than a clause per shape plus a catch-all, and the generated get/1 drops the {:error, _} branches that 1.20's type checker proves unreachable. Both were dead-clause warnings under --warnings-as-errors.

Bug Fixes

  • fix: :compression_level is reachable. It was unusable on every path — no adapter declares it, so NimbleOptions rejected it on compile-time adapter opts, and it resolved to nil before it could reach the encoder otherwise. It is now an option on the use Cache line (compression_level: 6), it is taken off the adapter opts before they are validated so the opts: [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/3 followed by get/1 no longer raises. get_or_store/3 writes through ConCache directly, bypassing the encode in put/3, so the matching get/1 tried to binary_to_term/1 a raw term.
  • fix: a binary value wrapped in braces but not valid JSON (eg "{oops}") no longer raises Jason.DecodeError on read. Cache.TermEncoder.decode/1 used Jason.decode!/1, and now falls back to returning the binary unchanged.
  • fix: raw Cache.ETS operations (match_object/1, select/1, tab2list/0, foldl/2) now see the terms that were put, rather than the opaque encoded binaries they used to return.
  • fix: caching a JSON string hands back the string. encode/2 stored a brace-wrapped binary unencoded, so decode/1 had to guess what it was looking at — put(:k, ~s({"a": 1})) followed by get(:k) returned %{"a" => 1}, a String in and a Map out. Binaries are now always run through :erlang.term_to_binary/1, and decode/1 keys off the external term format version byte rather than the shape of the payload, so nothing is guessed.
  • fix: decode/1 no longer raises on a binary that is not an encoded term. It used to reach :erlang.binary_to_term/1 for anything that was not digits or brace-wrapped, which raised ArgumentError on 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/3 and delete/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.DETS is unchanged and still encodes, so existing .dets files stay readable. Cache.ETS with :rehydration_path also still encodes, so existing table dumps stay loadable.
  • Cache.HashRing, and Cache.MultiLayer under broadcast_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): :credo 1.7.13 -> 1.7.18. 1.7.13 crashes on Elixir 1.20's sigil token format. The newer Credo flagged one length/1 > 0 check, rewritten as !== [].
  • chore(deps): dropped the :faker test 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 under test/support.
  • chore(mix): moved :preferred_cli_env into def 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/2 on the next line, could acquire the freed lock and refresh the value the following assertion expected to be untouched. The race predated this release; making get/1 faster widened the window enough for CI to hit it.

0.4.9

Performance

  • perf(sandbox): make Cache.SandboxRegistry.register_caches/2 post-register sleep configurable via Cache.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 to 0 in config/test.exs to 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_link now 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/2 in select/2,3, select_count/2, select_delete/2, and select_replace/2

Chores

  • ci: run workflows on pull_request; restrict push trigger to main to avoid duplicate runs

0.4.7

Bug Fixes

  • fix: apply maybe_sandbox_key to hash_get_many keys
  • fix: support sandbox? option with strategy adapters (HashRing, MultiLayer, RefreshAhead)
  • fix: isolate Cache.Sandbox scan results per sandbox

Refactors

  • refactor: scope Cache.Sandbox state by sandbox_id internally
  • 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/2 to 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_term for 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 :counters module. Provides lock-free increment/decrement operations via put/4 (values 1 or -1) and injects increment/1,2 and decrement/1,2 into consumer modules through use Cache. Counter references and index maps are stored in :persistent_term for 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 using libring. Distributes keys across Erlang cluster nodes, forwarding operations to the owning node via :erpc (or a configurable rpc_module). The ring tracks node membership automatically via HashRing.Managed with monitor_nodes: true. Includes read-repair: on a miss, previous ring snapshots (maintained by Cache.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 optional on_fetch callback on total miss and a backfill_ttl for backfilled entries.
  • Cache.RefreshAhead — proactively refreshes hot keys in the background before their TTL expires. On get, if the value is within the refresh_before window, the current value is returned immediately and an async Task refreshes it. Uses a per-cache ETS deduplication table and :global distributed locking to prevent redundant refreshes across nodes. Requires a refresh_before (ms) opt and a refresh/1 callback or on_refresh opt.

Test Utilities

  • Cache.CaseTemplate — new ExUnit case template for applications with many test files. Define a CacheCase module once with default_caches or supervisors, then use MyApp.CacheCase in any test file to get automatic sandboxed cache setup. Supports per-file additional caches via :caches and detects duplicate cache registrations at setup time.

Bug Fixes

  • fix(ets): suppress no_warn_undefined for 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.ConCache to accept ets_options (strict NimbleOptions validation + normalization)
  • feat: allow Cache.ETS write_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 strategy option to Cache.Redis for poolboy

0.1.1

  • Expose pipeline and command functions on redis adapters

0.1.0

  • Initial Release