Packages
Caches Ash reads, generic actions and calculations, and clears the cache when the data changes through Ash.
Current section
Files
Jump to
Current section
Files
usage-rules.md
# Rules for working with AshCache
AshCache adds declarative read-through caching to Ash resources. Read these
rules before writing code that uses it. Prefer the defaults; add options only
when a requirement demands them.
## Setup
- Add `{:ash_cache, "~> 0.1"}` and `{:cachex, "~> 3.6"}` to deps. Cachex is
the default store and is optional: skip it only if you set another adapter.
- Add `{AshCache.Supervisor, otp_app: :my_app}` to the application's
supervision tree exactly once, after the repo. It starts one Cachex store
per distinct `store` name declared across all domains in
`config :my_app, ash_domains`.
- No application config is required. For isolated `async: true` tests, set in
`config/test.exs` `config :ash_cache, adapter: AshCache.Store.Adapters.ProcessLocal`.
- Ash needs a SAT solver (`:picosat_elixir` or `:simple_sat`) to evaluate
policies. That is the application's dependency, not AshCache's.
## Caching a resource
- Add `extensions: [AshCache.Resource]` to `use Ash.Resource` and a `cache`
block with one `read` per read action to cache:
```elixir
cache do
read :get_by_sku, ttl: :timer.hours(1)
read :featured, ttl: :timer.minutes(1), cache_empty?: true
end
```
- `ttl` (milliseconds or `:infinity`) is optional on each `read`. Without it
the read uses the block's `ttl`, which defaults to 5 minutes
(`cache do ttl :timer.hours(1) ... end` changes it for the resource).
`:infinity` is never the default: old entries stay in memory until their
`ttl` runs out, so only use it for data that is written rarely. There is
no way to cache all reads at once. Unlisted reads are never cached. Every name must be a read action on the resource, listed once, or
compilation fails.
- Options on `read`: `cache_empty?: true` caches `[]` and `nil` results (off
by default); `store_options: [...]` is passed verbatim to the adapter's
`put/4`.
- `action :name, ttl: ...` caches a generic action the same way, keyed by
its arguments, actor and tenant; on a hit its `run` is not called. It
takes the same options as `read`, and the same `ttl` default. Errors and
`nil` are never cached. The action must have a return type and its `run`
must not be a Reactor, or compilation fails. Policies are still checked
on every call.
- `calculation :name, ttl: ...` caches a module calculation
(`Ash.Resource.Calculation`) per record, keyed by its arguments, actor,
tenant and every attribute and `load/3` field of the record. Writes do
not clear it; a changed record gets a new key. Use it only for a
calculation that depends on the record alone: one that reads an API or
the clock serves old answers until `ttl`. Expression calculations fail
compilation; they need no entry. Events:
`[:ash_cache, :calculation, :hit | :miss]`.
- Options on the block: `store :name` gives the resource its own store
(default `:ash_cache`); `ttl` sets the lifetime for reads without their own.
With the Nebulex adapter the store is the Nebulex cache module:
`store MyApp.Cache`.
- `refresh :read_action, arguments: [arg: :attribute], actor: {Mod, :fun, []}`
re-runs a cached read after each create/update with arguments taken from
the written record. The entry is cached for that actor only (no actor by
default) and for the written record's tenant, so `refresh` only helps reads
normally called the same way. Do not use it for per-user reads. Each
`refresh` costs the writer one extra read.
- Every successful create/update/destroy clears all cached reads and
generic actions of the resource, after commit.
- `clear_when_changed [MyApp.Blog.Comment]` on the block also clears the
resource's cached reads and generic actions when a listed resource is
created, updated or destroyed through Ash (single or bulk), after commit.
Each listed resource must use `extensions: [AshCache.Resource]`; it needs
no `cache` block. Listing a module that is not such a resource fails
compilation. It is not passed along: if Post lists Comment and Comment
lists Author, an Author write does not clear Post. List Author on Post too.
- Identical misses running at the same time on one server share one data
layer read or one generic action `run` (keyed on the full cache key, so
never across actors or tenants). Do not add your own locking around cached
reads or actions. Waiters give up after
`config :ash_cache, rebuild_timeout: ms` (default 5000) and run for
themselves.
- Cache only reads that are hot and tolerate staleness up to their `ttl`.
Choose `ttl` per read: long for content that rarely changes, short for
results that change without a write (time-based filters, data written
outside Ash).
## Rules
- The cache key is built per actor and tenant, however they are passed
(code interface, `Ash.Query.for_read/4` or `Ash.read/2`).
- Related records a cached read `load`s are read fresh on every request;
they need nothing. 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 in `clear_when_changed`, including a `many_to_many` join resource.
Otherwise it always goes to the data layer. Do not shorten `ttl` for this.
- Do not cache values that must be exact at the moment of use: stock counts
at checkout, balances, permissions checked for a payment.
- The default store is in memory per node. On several nodes a write clears
the cache only on the node that made it; others serve old entries until
`ttl`. Sharing one cache is experimental (not tested on real servers);
only set it up if the user asks. To share one cache, set
`config :ash_cache, adapter: AshCache.Store.Adapters.Nebulex`, start your
own Nebulex cache (Redis, partitioned or replicated) instead of
`AshCache.Supervisor`, and set `store MyApp.Cache` on every cached
resource. Do not use a coherent or multilevel-with-local cache: other
servers can serve old entries. With Redis, give AshCache its own database:
`AshCache.clear_store/1` runs FLUSHDB.
- Do not cache a write-heavy resource without measuring: every write also
writes a new version value to the store.
- Do not write cache keys or invalidation by hand. Both are automatic.
- To skip the cache for one query, set `context: %{ash_cache: :bypass}`.
Pass it to `Ash.Query.for_read/4` or `Ash.read/2`, or call
`Ash.Query.set_context/2` before you run the query. For a generic action,
pass it to `Ash.ActionInput.for_action/4` or call
`Ash.ActionInput.set_context/2`.
- To invalidate a resource at runtime (e.g. after a raw SQL import):
`AshCache.invalidate(Resource)`.
- To wipe a whole store (all resources in it): `AshCache.clear_store(:name)`.
- To disable caching in one process (tests, scripts):
`Process.put(:ash_cache_adapter, AshCache.Store.Adapters.Noop)`.
- A custom store implements `AshCache.Store.Adapter` (`get/2`, `put/4`,
`delete/2`, `clear/1`; a miss is `{:ok, nil}`) and is selected with
`config :ash_cache, adapter: MyAdapter`.
## Telemetry and tests
- Events: `[:ash_cache, :read, :hit]`, `[:ash_cache, :read, :miss]`
(metadata `:resource`, `:action`, `:store`; a miss also has `:error`) and
`[:ash_cache, :invalidate]` (`:resource`, `:store`). Generic actions send
`[:ash_cache, :action, :hit]` and `[:ash_cache, :action, :miss]` with the
same metadata. Bypassed and uncached
reads emit nothing.
- In tests, call `AshCache.Testing.capture_events()` in `setup`, then
`assert_received {:ash_cache, :hit, %{action: :get_by_sku}}` (kinds:
`:hit`, `:miss`, `:invalidate`). Safe with `async: true`. It only delivers
events from the test process and Tasks it starts.
- With `ProcessLocal`, each process has its own cache: a Task or GenServer
does not share the test's cache, and the test's writes do not clear it.