Skip to content

v0.5.0

Latest

Choose a tag to compare

@MikaAK MikaAK released this 16 Aug 19:26
· 5 commits to main since this release
3c6c060

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/1 for 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/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). A small map goes from 2,105 ns to 302 ns (7.0x).
  • Cache.PersistentTerm 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 RefreshAhead over Cache.ETS stops encoding too. Wrapping a byte-storing adapter is unchanged.
  • Cache.SandboxRegistry.register_caches/2 post-register sleep is configurable via Cache.Config.sandbox_sleep_ms/0 (config :elixir_cache, :sandbox_sleep_ms, 50). The default is unchanged; suites that don't need it can set 0 and save ~50 ms per cache per test.

Features

  • Cache.MultiLayer cross-node 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 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 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 :pg members, no acks), so backfill_ttl and 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 Cache macro emits a single adapter_options!/1 clause matching the configured opts shape instead of a clause per shape plus a catch-all, and the generated get/1 drops the {:error, _} branches 1.20's type checker proves unreachable.

Bug Fixes

  • :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 reaching 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 opts: [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/2 stored a brace-wrapped binary unencoded, so decode/1 had to guess what it was looking at — put(:k, ~s({"a": 1})) then get(:k) returned %{"a" => 1}, a String in and a Map out. Binaries are always encoded now, and decode/1 keys off the external term format version byte rather than the shape of the payload.
  • 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 neither digits nor brace-wrapped, an ArgumentError on any value written into the store by something else.
  • A brace-wrapped binary that is not valid JSON (eg "{oops}") no longer raises Jason.DecodeError on read.
  • 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.
  • Raw Cache.ETS operations (match_object/1, select/1, tab2list/0, foldl/2) see the terms that were put rather 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/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.
  • Nothing that outlives the process that wrote it changed format. Cache.DETS and Cache.ETS with :rehydration_path still encode, so existing files and table dumps stay readable. Cache.HashRing, and Cache.MultiLayer under broadcast_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

  • :credo 1.7.13 -> 1.7.18 in the lock. 1.7.13 crashes on Elixir 1.20's sigil token format.
  • Dropped the :faker test 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 to test/support.
  • :preferred_cli_env moved into def 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/2 on the next line and could refresh the value the following assertion expected to be untouched.

Full Changelog: v0.4.8...v0.5.0