Values stop being serialised for adapters that can already hold an Erlang term, which makes reads on Cache.ETS several times faster, and the encoder stops guessing at what a stored binary was.
0.4.9 was never published, so its change ships here too.
Performance
- 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 round trip was pure overhead there, 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). A small map goes from 2,105 ns to 302 ns (7.0x). Cache.PersistentTermhands 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 RefreshAhead overCache.ETSstops encoding too. Wrapping a byte-storing adapter is unchanged.Cache.SandboxRegistry.register_caches/2post-register sleep is configurable viaCache.Config.sandbox_sleep_ms/0(config :elixir_cache, :sandbox_sleep_ms, 50). The default is unchanged; suites that don't need it can set0and save ~50 ms per cache per test.
Features
-
Cache.MultiLayercross-node 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. Each cache 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 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. A full value copy per member, so 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. -
c:Cache.native_term_storage?/1— a new optional callback letting an adapter declare that it stores Erlang terms natively. Resolved at compile time, so there is no runtime branch on the read or write path. Optional and defaults to encoding, so third-party adapters are unaffected. -
Elixir 1.20 support. The
use Cachemacro emits a singleadapter_options!/1clause matching the configured opts shape instead of a clause per shape plus a catch-all, and the generatedget/1drops the{:error, _}branches 1.20's type checker proves unreachable.
Bug Fixes
:compression_levelis reachable. It was unusable on every path — no adapter declares it, soNimbleOptionsrejected it on compile-time adapter opts, and it resolved tonilbefore reaching 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 soopts: [compression_level: 6]works too, and it is never handed to the adapter. Setting it forces encoding on adapters that hold terms natively. A cache using a strategy adapter raises at compile time rather than ignoring it.- 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}))thenget(:k)returned%{"a" => 1}, aStringin and aMapout. Binaries are always encoded now, anddecode/1keys off the external term format version byte rather than the shape of the payload. 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 neither digits nor brace-wrapped, anArgumentErroron any value written into the store by something else.- A brace-wrapped binary that is not valid JSON (eg
"{oops}") no longer raisesJason.DecodeErroron read. 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.- Raw
Cache.ETSoperations (match_object/1,select/1,tab2list/0,foldl/2) see the terms that wereputrather than the opaque encoded binaries they used to return.
Breaking Changes
- Values held by native-term adapters are stored as terms rather than encoded binaries. 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. - Nothing that outlives the process that wrote it changed format.
Cache.DETSandCache.ETSwith:rehydration_pathstill encode, so existing files and table dumps stay readable.Cache.HashRing, andCache.MultiLayerunderbroadcast_mode: :replicate, still encode as well — they 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. A mixed-version cluster is safe. - 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 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
:credo1.7.13 -> 1.7.18 in the lock. 1.7.13 crashes on Elixir 1.20's sigil token format.- Dropped the
:fakertest dependency, which does not compile on 1.20 (a raw U+0085 byte is a hard syntax error there). Its three random-string helpers moved totest/support. :preferred_cli_envmoved intodef cli/0, where 1.20 expects it.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 and could refresh the value the following assertion expected to be untouched.
Full Changelog: v0.4.8...v0.5.0