Each section below is a feature you might have in your app, with the
cache block for it and why it is set up that way. Every option is listed
in the DSL reference.
Each example sets ttl on purpose. A read without one keeps its results
for 5 minutes, or for the cache block's ttl if you set one.
Two things apply to every example:
- A cached result is thrown away when the resource is changed through Ash, and in a few other cases listed in When the cache clears.
- The default store keeps each server's cache in its own memory. If your
app runs on several servers, a write clears the cache only on the server
that made it, and the others serve the old result until
ttlruns out. Sharing one cache between servers is experimental; see Running on several servers.
Blog posts and help pages
Pages that editors change a few times a week and visitors read all day.
defmodule MyApp.Content.Post do
use Ash.Resource,
domain: MyApp.Content,
extensions: [AshCache.Resource]
cache do
read :get_by_slug, ttl: :timer.hours(24)
read :published, ttl: :timer.hours(24)
end
endEditors change posts through Ash, and each change clears the cache, so a
long ttl costs nothing in freshness. Results are cached per actor: all
signed-out visitors share one entry, but each signed-in user gets their own.
Don't cache the editor's draft view. Editors expect to see what they just typed, and the draft list is read far less than the public pages.
"Related posts" and other sidebars
A "related posts" or "customers also bought" box shown next to every post or product. Stack Overflow caches its related-questions sidebar this way.
cache do
read :related, ttl: :timer.minutes(30)
endEach post's sidebar is its own entry, because the post id is an argument of the read. The list doesn't need to be up to date to the minute, and it is read on every page view.
RSS feeds and sitemaps
Feeds and sitemaps that bots and feed readers fetch over and over.
cache do
read :recent_for_feed, ttl: :timer.minutes(15)
endBots fetch these far more often than you publish. Publishing a post is a
write, so the feed updates right away; the ttl only limits how long an
unchanged feed is kept.
Phoenix LiveView pages
A LiveView page loads its data twice when it opens: once for the first HTML and once more when the browser connects.
cache do
read :list_for_page, ttl: :timer.seconds(30)
endEven a short ttl makes the second load a cache hit, which halves the
database work for every page visit. The two loads run in different
processes on the same server, and the cache is shared by all processes
on a server, so they find each other's entry (as long as they read as the
same actor).
Countries, currencies and pricing plans
Short lists that fill dropdowns and pricing pages and almost never change.
cache do
read :read, ttl: :timer.hours(24)
endThese lists are read on many pages and changed maybe once a year, which is the best case for a cache.
Don't cache the read that decides what a customer is charged at checkout.
If a price changes on one server, another server could still charge the old
price until ttl runs out.
The signed-in user, loaded on every request
Most apps load the current user (and often their roles) at the start of every request.
cache do
read :get_by_id, ttl: :timer.minutes(5)
endEach user gets their own entry, because the lookup filters by their id. When any user changes their profile, the cached user reads of all users are thrown away.
Keep the ttl short if you run several servers. Banning or locking a user
is a write, and it throws away the cache only on the server that made it.
The other servers keep letting the user in until the ttl runs out.
Product catalog: lookup by SKU and a "best sellers" list
A shop that looks products up by SKU on every product page, and shows a list of best sellers ranked by this week's orders.
defmodule MyApp.Shop.Product do
use Ash.Resource,
domain: MyApp.Shop,
extensions: [AshCache.Resource]
cache do
read :get_by_sku, ttl: :timer.hours(1)
read :best_sellers, ttl: :timer.minutes(5), cache_empty?: true
end
endThe SKU lookup only changes when the product changes, and that write clears
the cache, so it can live for an hour. The best-seller ranking comes from
orders, which are a different resource: new orders do not clear the
product cache, so the ttl of 5 minutes is how old the ranking can get.
cache_empty?: true keeps an empty list too, so a quiet week does not send
every page view to the database.
Don't cache stock counts or "only 2 left" at checkout. They change with every order, and a stale count sells items you no longer have. Read them with the cache skipped (see "A report that must be exact" below), or keep them on a resource you don't cache.
Popular posts from the last 24 hours
A blog or forum that shows the posts with the most upvotes in the last day.
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
extensions: [AshCache.Resource]
cache do
read :get_by_slug, ttl: :timer.hours(1)
read :top_today, ttl: :timer.minutes(1)
end
endEveryone sees the same cached list, rebuilt at most once a minute. Big
sites work the same way: the top list is rebuilt every so often, not on
every vote. The ttl is how often the list refreshes.
Store votes in their own resource (for example MyApp.Blog.Vote) and count
them with an aggregate. Votes then don't touch the post cache. If you keep
a vote_count field on Post instead, every vote is a write to Post and
throws away all cached post reads, so with lots of voting the cache is
almost always empty.
Settings shared by the whole app
Feature flags or site settings that every request reads, and that look the same for every user.
cache do
read :get_by_key, ttl: :timer.hours(1)
refresh :get_by_key, arguments: [key: :key], actor: {MyApp.SystemActor, :new, []}
endWithout refresh, the first request after a change goes to the database.
With it, the update reads the setting again and caches it before it
returns. arguments says which field of the saved record fills each argument of
the read. refresh caches the result only for the actor it reads as (no actor
unless you set actor), so it only helps if your app always reads settings
as that same actor.
Don't rely on this to switch off something urgent, like a kill switch for a
broken payment provider, when you run several servers. The other servers
keep the old value for up to ttl. Use a short ttl for those flags, or
skip the cache for them.
Each user's own settings or feature flags
Preferences or per-user flags read on every request for the signed-in user.
cache do
read :for_current_user, ttl: :timer.minutes(30)
endThe cache key includes the actor, so each user gets their own entry, and
one user's settings can never be served to another. Pass the user as the
actor when you read, for example through a code interface or
Ash.Query.for_read(query, :for_current_user, %{}, actor: user).
There is no refresh here. refresh reads as one fixed actor, so it would cache
an entry that no user's request ever looks up.
Any user saving their settings clears the cached settings of all users, because clearing works per resource. If many users change settings all the time, measure whether the cache still helps.
A multi-tenant app
Each customer company (tenant) has its own projects, and every page lists the current tenant's projects.
cache do
read :read, ttl: :timer.minutes(15)
endThe cache key includes the tenant, so one company never sees another's
cached results. Pass the tenant as you already do for Ash
(Ash.read(query, tenant: org_id) or the tenant: option on a code
interface).
A write in any tenant clears the cache for all tenants of that resource.
In an app with many busy tenants, that can mean most reads miss. refresh
works on a multi-tenant resource: it refreshes the cache for the tenant of the record that
was written. Other tenants' entries are cleared and fill on their next read.
Dashboard numbers and leaderboards
A team dashboard that shows totals counted from other resources, or a leaderboard of players by points won in matches.
cache do
read :dashboard, ttl: :timer.minutes(1)
endNumbers like these are usually counts or sums of other records, loaded
with Ash aggregates. AshCache caches them only when those other records are
listed in clear_when_changed (see "Reads that include another
resource" below), and a write to them then throws the numbers away. If the
other records are written all the time, that clears the cache all the
time: leave such a dashboard uncached.
Don't cache numbers that someone acts on right away, such as an account balance before a transfer or a usage limit that blocks requests.
Data from another service
An exchange rate, a weather forecast or a shipping price that your app gets from another company's API. In Ash such a call is usually a generic action.
defmodule MyApp.Money.ExchangeRate do
use Ash.Resource,
domain: MyApp.Money,
extensions: [AshCache.Resource]
cache do
action :get_rate, ttl: :timer.minutes(10)
end
actions do
action :get_rate, :decimal do
argument :from, :string, allow_nil?: false
argument :to, :string, allow_nil?: false
run fn input, _context ->
MyApp.RatesApi.fetch(input.arguments.from, input.arguments.to)
end
end
end
endThe first call for a pair of currencies calls the API. For the next 10
minutes, calls with the same arguments get the saved rate, and the run
function is not called. This is often the biggest win, because outside APIs
are slow and may charge per call or limit how often you can call them.
Each set of arguments, each actor and each tenant gets its own entry.
AshCache cannot see when the other service changes its data, so ttl is
how old a rate can get.
Errors are not saved, so a failed call is tried again next time. nil is
never saved, and [] is saved only with cache_empty?: true. Ash still
checks the resource's policies on every call, also when the answer comes
from the cache.
The action must have a return type. An action whose run is a Reactor
cannot be cached; call the Reactor from a run function instead. If the
call is a read action with manual (a read you write yourself instead of a
database query), cache it with read instead of action.
Don't cache the rate a customer is charged with. Skip the cache for that call:
MyApp.Money.ExchangeRate
|> Ash.ActionInput.for_action(:get_rate, %{from: "EUR", to: "USD"},
context: %{ash_cache: :bypass}
)
|> Ash.run_action!()A slow calculation
A shipping quote that takes a second to work out, shown on every order page. The calculation is an Elixir module that only looks at the shipment:
defmodule MyApp.Shipping.Quote do
use Ash.Resource.Calculation
@impl true
def calculate(shipments, _opts, _context) do
{:ok, Enum.map(shipments, &MyApp.Shipping.Rates.quote(&1.weight_grams, &1.destination))}
end
end
defmodule MyApp.Shipping.Shipment do
use Ash.Resource,
domain: MyApp.Shipping,
extensions: [AshCache.Resource]
cache do
calculation :shipping_quote, ttl: :timer.hours(1)
end
attributes do
uuid_primary_key :id
attribute :weight_grams, :integer
attribute :destination, :string
end
calculations do
calculate :shipping_quote, :decimal, MyApp.Shipping.Quote
end
endEach shipment has its own entry. The first Ash.load!(shipment, :shipping_quote)
runs the calculation. Loads in the next hour find the saved quote. When you
load the quote for a list of shipments, only the ones not yet saved are
calculated.
The key holds the arguments, the actor, the tenant, every attribute of the
shipment and every field the calculation's load/3 asks for. Edit the
weight and the next load is a new key, so you never see a quote for the old
weight. The old entry is not deleted; it expires after an hour. Writes don't
clear calculation entries.
Use this only for a calculation that depends on the record alone. If it
reads something else, such as a carrier's API or the current time, the
cache can't see that change and serves the old answer until the ttl runs
out. Changing any attribute, even one the calculation doesn't use, makes it
run again. Identical misses at the same moment each run the calculation.
Only module calculations can be cached. An expression calculation already
runs in the database, and it is saved inside the cached read that loads it.
Naming one in the cache block is a compile error.
To skip the cache for one load:
Ash.load!(shipment, :shipping_quote, context: %{ash_cache: :bypass})Reads that include another resource
Related records loaded with load are read fresh on every request, cache
hit or not: a post's comments, a product's prices, an order's items, a
profile's avatar. They need nothing.
Values computed from another resource are part of the cached rows: counts
and sums (aggregates), calculations that use them or reach another
resource, and filters, sorts or policies that reach another resource.
AshCache caches such a read only when every resource it reaches is in
clear_when_changed. Otherwise the read goes to the database every time,
so it is never stale. List each resource on the path, including the join
resource of a many_to_many.
Each listed resource needs the extension, even if it caches nothing. The rules are in When the cache clears.
A blog post with its comments
A post page that shows the post and its comments.
cache do
read :get_by_slug, ttl: :timer.hours(1)
endMyApp.Blog.Post
|> Ash.Query.for_read(:get_by_slug, %{slug: slug})
|> Ash.Query.load(:comments)
|> Ash.read_one!()The post comes from the cache and its comments from the database, so a new comment shows up on the next page view without listing anything.
Team dashboard and its members
A dashboard that shows a team with its member count and member list.
cache do
read :dashboard, ttl: :timer.hours(1)
clear_when_changed [MyApp.Teams.Membership]
endThe member count is an aggregate over memberships, so Membership is
listed: each join or leave throws away the cached dashboard. People join
and leave teams a few times a day, so a long ttl is fine. Without the
listing, the dashboard is not cached at all.
Data that also changes outside Ash
A table that a nightly CSV import, a script with raw SQL or another service also writes to.
cache do
read :read, ttl: :timer.minutes(10)
endAshCache only sees writes made through Ash. Other writes show up when the
entry expires, so set ttl to the longest delay you can accept. If you
control the import, clear the cache when it finishes:
MyApp.Import.run!()
AshCache.invalidate(MyApp.Shop.Product)AshCache.invalidate/1 does exactly what a write through Ash does. Like a
write, it clears only the server it runs on.
A report that must be exact
A finance export or an admin page that must show the database as it is right now, on a resource that is otherwise cached.
MyApp.Shop.Product
|> Ash.Query.for_read(:read, %{}, context: %{ash_cache: :bypass})
|> Ash.read!()The query goes to the database and caches nothing. You can also add the
option with Ash.Query.set_context/2, any time before the query runs.
A personal feed that changes all the time
Each user's home feed, order history or inbox, on a resource that many users write to every second.
Don't cache it with AshCache. Any write clears the cached reads of every user, so with constant writes almost every read is a miss, and each write gets slower. Shopify hit the same problem with the home feed of its Shop app and built a cache that is cleared per user instead.
Things not worth caching
- Search results and filtered lists. Each search term and filter is its own entry, so the same entry is rarely read twice.
- Data written about as often as it is read, such as a "last seen" timestamp. Every write clears the cache, so most reads miss and every write gets slower.
- Anything that must be exact when it is used: stock at checkout, balances, the price being charged.
- Counters that change on every request, such as rate limits or view counts. They are written as often as they are read.