0.1.0 — 2026-11-04

First packaged release. Extracted from the application it was written in, where it caches two production resources.

  • AshCache.Resource extension. Each read to cache is listed on its own with an optional lifetime: read :get_by_sku, ttl: :timer.hours(1). Reads without one use the block's ttl, which is 5 minutes unless set. :infinity is allowed, but only when written out. cache_empty? and store_options are set per read too. Reads that are not listed are never cached. Keys are built from the action, the whole query, the actor and the tenant.
  • action :name, ttl: ... caches a generic action, such as a call to another service. On a hit its run is not called. Policies are still checked. Keys are built from the action, its arguments, the actor and the tenant; writes and AshCache.invalidate/1 clear these entries too. An action with no return type, or whose run is a Reactor, fails to compile. Telemetry: [:ash_cache, :action, :hit] and [:ash_cache, :action, :miss].
  • refresh re-runs a cached read after each create or update, so the new value is cached before anyone asks for it.
  • clear_when_changed [MyApp.Blog.Comment] throws away a resource's cached reads and generic actions when a listed resource is created, updated or deleted through Ash. The listed resource needs the AshCache.Resource extension, but no cache block: a resource may now use the extension without caching anything.
  • Version-based invalidation: a successful write bumps the resource's counter in after_transaction, so a rolled-back write cannot leave stale entries behind and old entries become unreachable in O(1).
  • Telemetry events [:ash_cache, :read, :hit], [:ash_cache, :read, :miss] and [:ash_cache, :invalidate].
  • AshCache.Testing.capture_events/0 sends a test the cache events its own code caused, so cache behaviour can be checked in async: true tests.
  • calculation :name, ttl: ... caches a module calculation's result per record, keyed by its arguments, the actor, the tenant and every value it can read from the record. Writes don't clear these entries: a changed record gives a new key. Expression calculations are rejected at compile time. Sends [:ash_cache, :calculation, :hit | :miss].
  • Cachex is an optional dependency. Add it to your deps to use the default store. Without it, AshCache fails at startup with a message saying so, unless you set another adapter.
  • Store adapters: Cachex (default), ProcessLocal (per-process isolation for async tests) and Noop, behind AshCache.Store.Adapter.
  • Experimental: AshCache.Store.Adapters.Nebulex stores entries in the app's own Nebulex cache, so several servers can share one cache. Not yet tested on real servers. Nebulex is an optional dependency.
  • A version value the store loses (eviction, restart, a server leaving a distributed cache) is replaced by a new random one, so it can never bring back entries cached before a write. A write whose new version value the store refuses deletes the old one instead, and logs a warning if the store refuses that too.
  • Requests that miss the same cached read at the same time on one server share a single database read instead of each running it. If that read fails, another waiting request runs it; after rebuild_timeout (5 seconds by default) the others stop waiting and read for themselves. Cached generic actions do the same with their run call. With ProcessLocal, each process has its own cache, so it only shares reads with itself.
  • A read whose rows are computed from another resource (an aggregate, a calculation over one, or a filter, sort or policy that reaches another resource) is cached only when every resource it reaches is the resource itself or in clear_when_changed; otherwise it goes to the data layer every time. Related records loaded with load are read fresh on every read and need nothing. A per-owner policy (relates_to_actor_via over a belongs_to) compares only the row's own foreign key and needs nothing either. A cache hit no longer hangs when the read loads an aggregate.
  • AshCache.Supervisor starts one store per declared cache with no manual wiring beyond a single child spec.

Known limits, both deliberate in this version:

  • No per-key eviction. With auto-derived keys a write cannot know every query shape that cached its resource, so invalidation is per-resource.
  • Caching a write-heavy resource costs write throughput, because every write writes a new version value to the store. One write-heavy test suite in the host app roughly doubled in runtime when its resource was cached.