cache settings, categories, and media in ETS to cut per-request DB queries
All checks were successful
deploy / deploy (push) Successful in 1m26s

Every shop page load was triggering ~18 DB queries for data that rarely
changes (theme settings, nav items, categories, shipping countries, logo,
header image). On a shared-cpu-1x Fly machine with SQLite this was the
primary performance bottleneck.

- Add SettingsCache GenServer+ETS for all non-encrypted settings
- Cache list_categories() with single-query N+1 fix (correlated subquery)
- Cache list_available_countries_with_names() in shipping
- Cache Media.get_logo() and Media.get_header()
- Remove duplicate LoadTheme plug from :shop and :admin pipelines
- Invalidate caches on writes (put_setting, product sync, media upload)
- Clear caches between tests via DataCase/ConnCase setup

Per-page queries reduced from ~18 to ~2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-02 16:25:29 +00:00
parent 5e70c07b60
commit 297f3de60f
11 changed files with 375 additions and 65 deletions

View File

@ -36,6 +36,8 @@ defmodule Berrypod.Application do
Berrypod.Images.VariantCache,
# Start to serve requests
BerrypodWeb.Endpoint,
# Settings cache - all non-encrypted settings in ETS (must warm before CSSCache)
Berrypod.Settings.SettingsCache,
# Theme CSS cache - must start after Endpoint for static_path/1 to work
Berrypod.Theme.CSSCache,
# Page definition cache - loads page block lists into ETS

View File

@ -192,7 +192,18 @@ defmodule Berrypod.Images.Optimizer do
widths = applicable_widths(source_width)
all_tasks =
[fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, "thumb", :jpg, @thumb_size) end] ++
[
fn ->
generate_variant_to_dir(
vips_image,
output_basename,
output_dir,
"thumb",
:jpg,
@thumb_size
)
end
] ++
for w <- widths, fmt <- @pregenerated_formats do
fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, w, fmt, w) end
end
@ -202,6 +213,7 @@ defmodule Berrypod.Images.Optimizer do
timeout: :timer.seconds(120)
)
|> Stream.run()
{:ok, source_width}
end
rescue

View File

@ -34,6 +34,7 @@ defmodule Berrypod.Media do
OptimizeWorker.enqueue(image.id)
end
invalidate_media_cache(image.image_type)
{:ok, image}
error ->
@ -122,12 +123,24 @@ defmodule Berrypod.Media do
"""
def get_logo do
alias Berrypod.Settings.SettingsCache
case SettingsCache.get_cached(:logo) do
{:ok, logo} ->
logo
:miss ->
logo =
Repo.one(
from i in ImageSchema,
where: i.image_type == "logo",
order_by: [desc: i.inserted_at],
limit: 1
)
SettingsCache.put_cached(:logo, logo)
logo
end
end
@doc """
@ -140,12 +153,24 @@ defmodule Berrypod.Media do
"""
def get_header do
alias Berrypod.Settings.SettingsCache
case SettingsCache.get_cached(:header) do
{:ok, header} ->
header
:miss ->
header =
Repo.one(
from i in ImageSchema,
where: i.image_type == "header",
order_by: [desc: i.inserted_at],
limit: 1
)
SettingsCache.put_cached(:header, header)
header
end
end
@doc """
@ -158,7 +183,14 @@ defmodule Berrypod.Media do
"""
def delete_image(%ImageSchema{} = image) do
Repo.delete(image)
result = Repo.delete(image)
case result do
{:ok, _} -> invalidate_media_cache(image.image_type)
_ -> :ok
end
result
end
@doc """
@ -419,4 +451,12 @@ defmodule Berrypod.Media do
)
|> Repo.insert()
end
defp invalidate_media_cache("logo"),
do: Berrypod.Settings.SettingsCache.invalidate_cached(:logo)
defp invalidate_media_cache("header"),
do: Berrypod.Settings.SettingsCache.invalidate_cached(:header)
defp invalidate_media_cache(_), do: :ok
end

View File

@ -190,38 +190,76 @@ defmodule Berrypod.Products do
Lists distinct categories from visible, active products.
Returns a list of `%{name, slug, image_url}` where `image_url` is the
first product image for a representative product in that category.
Results are cached in ETS and invalidated when products change.
"""
def list_categories do
alias Berrypod.Settings.SettingsCache
case SettingsCache.get_cached(:categories) do
{:ok, categories} ->
categories
:miss ->
categories = do_list_categories()
SettingsCache.put_cached(:categories, categories)
categories
end
end
# Single query: categories with their first product image (by position).
# Uses a correlated subquery to pick the lowest-position image per category,
# replacing the previous N+1 pattern.
defp do_list_categories do
# Categories that have at least one product image
with_images =
from(p in Product,
join: pi in ProductImage,
on: pi.product_id == p.id,
where: p.visible == true and p.status == "active" and not is_nil(p.category),
where:
pi.id ==
fragment(
"""
(SELECT pi2.id FROM product_images pi2
JOIN products p2 ON pi2.product_id = p2.id
WHERE p2.category = ? AND p2.visible = 1 AND p2.status = 'active'
ORDER BY pi2.position ASC LIMIT 1)
""",
p.category
),
group_by: p.category,
order_by: p.category,
select: {p.category, pi.image_id, pi.src}
)
|> Repo.all()
categories_with_images = MapSet.new(with_images, &elem(&1, 0))
# Categories without any images (still need to appear in the list)
without_images =
from(p in Product,
where: p.visible == true and p.status == "active" and not is_nil(p.category),
where: p.category not in ^MapSet.to_list(categories_with_images),
select: p.category,
distinct: true,
order_by: p.category
)
|> Repo.all()
|> Enum.map(fn name ->
image_url = category_image_url(name)
%{name: name, slug: Slug.slugify(name), image_url: image_url}
end)
|> Enum.map(&{&1, nil, nil})
(with_images ++ without_images)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {name, image_id, src} ->
image_url =
cond do
not is_nil(image_id) -> "/image_cache/#{image_id}-400.webp"
is_binary(src) -> src
true -> nil
end
defp category_image_url(category_name) do
from(pi in ProductImage,
join: p in Product,
on: pi.product_id == p.id,
where:
p.visible == true and p.status == "active" and
p.category == ^category_name,
order_by: [asc: pi.position],
limit: 1,
select: {pi.image_id, pi.src}
)
|> Repo.one()
|> case do
{id, _src} when not is_nil(id) -> "/image_cache/#{id}-400.webp"
{_, src} when is_binary(src) -> src
_ -> nil
end
%{name: name, slug: Slug.slugify(name), image_url: image_url}
end)
end
@doc """
@ -400,9 +438,17 @@ defmodule Berrypod.Products do
Updates storefront-only fields (visibility and category).
"""
def update_storefront(%Product{} = product, attrs) do
result =
product
|> Product.storefront_changeset(attrs)
|> Repo.update()
case result do
{:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
_ -> :ok
end
result
end
@doc """
@ -430,7 +476,14 @@ defmodule Berrypod.Products do
source: "auto_product_deleted"
})
Repo.delete(product)
result = Repo.delete(product)
case result do
{:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
_ -> :ok
end
result
end
@doc """

View File

@ -5,7 +5,7 @@ defmodule Berrypod.Settings do
import Ecto.Query, warn: false
alias Berrypod.Repo
alias Berrypod.Settings.{Setting, ThemeSettings}
alias Berrypod.Settings.{Setting, SettingsCache, ThemeSettings}
alias Berrypod.Vault
@doc """
@ -18,11 +18,17 @@ defmodule Berrypod.Settings do
"""
def get_setting(key, default \\ nil) do
case SettingsCache.get(key) do
{:ok, value} ->
value
:miss ->
case fetch_setting(key) do
{:ok, setting} -> decode_value(setting)
:not_found -> default
end
end
end
@doc """
Sets a setting value by key.
@ -36,12 +42,24 @@ defmodule Berrypod.Settings do
def put_setting(key, value, value_type \\ "string") do
encoded_value = encode_value(value, value_type)
result =
%Setting{key: key}
|> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type})
|> Repo.insert(
on_conflict: {:replace, [:value, :value_type, :updated_at]},
conflict_target: :key
)
case result do
{:ok, _} ->
SettingsCache.invalidate()
SettingsCache.warm()
_ ->
:ok
end
result
end
@doc """
@ -154,10 +172,16 @@ defmodule Berrypod.Settings do
Deletes a setting by key.
"""
def delete_setting(key) do
result =
case fetch_setting(key) do
{:ok, setting} -> Repo.delete(setting)
:not_found -> :ok
end
SettingsCache.invalidate()
SettingsCache.warm()
result
end
@doc """

View File

@ -0,0 +1,162 @@
defmodule Berrypod.Settings.SettingsCache do
@moduledoc """
GenServer that maintains an ETS table for caching settings and computed results.
Settings are loaded once from the DB on startup and served from ETS on every
request. The cache is invalidated (and immediately rewarmed) whenever a setting
is written via `Settings.put_setting/3` or `Settings.delete_setting/1`.
Also supports caching computed results (categories, shipping countries, etc.)
under `{:cached, key}` tuple keys. These are populated lazily and invalidated
explicitly when the underlying data changes.
"""
use GenServer
@table_name :settings_cache
## Client API — settings
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Gets a cached setting by key.
Returns `{:ok, value}` if found, or `:miss` if the cache hasn't been warmed
or the key doesn't exist.
"""
def get(key) do
case :ets.lookup(@table_name, :all_settings) do
[{:all_settings, map}] ->
case Map.fetch(map, key) do
{:ok, value} -> {:ok, value}
:error -> :miss
end
[] ->
:miss
end
rescue
ArgumentError -> :miss
end
@doc """
Gets a cached setting by key, returning a default on miss.
"""
def get(key, default) do
case get(key) do
{:ok, value} -> value
:miss -> default
end
end
@doc """
Invalidates the settings map, forcing a reload on next warm.
"""
def invalidate do
:ets.delete(@table_name, :all_settings)
:ok
rescue
ArgumentError -> :ok
end
@doc """
Warms the cache by loading all non-encrypted settings from the DB.
"""
def warm do
alias Berrypod.Repo
alias Berrypod.Settings.Setting
import Ecto.Query
settings = Repo.all(from s in Setting, where: s.value_type != "encrypted")
map =
Map.new(settings, fn setting ->
{setting.key, decode_value(setting)}
end)
:ets.insert(@table_name, {:all_settings, map})
:ok
end
## Client API — computed results (categories, countries, etc.)
@doc """
Gets a cached computed result by key.
Returns `{:ok, value}` if found, or `:miss` if not yet cached.
"""
def get_cached(key) do
case :ets.lookup(@table_name, {:cached, key}) do
[{{:cached, ^key}, value}] -> {:ok, value}
[] -> :miss
end
rescue
ArgumentError -> :miss
end
@doc """
Caches a computed result under the given key.
"""
def put_cached(key, value) do
:ets.insert(@table_name, {{:cached, key}, value})
:ok
rescue
ArgumentError -> :ok
end
@doc """
Invalidates a single cached computed result.
"""
def invalidate_cached(key) do
:ets.delete(@table_name, {:cached, key})
:ok
rescue
ArgumentError -> :ok
end
@doc """
Clears all cached data (settings and computed results). For tests.
"""
def invalidate_all do
:ets.delete_all_objects(@table_name)
:ok
rescue
ArgumentError -> :ok
end
## Server Callbacks
@impl true
def init(_opts) do
:ets.new(@table_name, [
:set,
:public,
:named_table,
read_concurrency: true,
write_concurrency: false
])
{:ok, %{}, {:continue, :warm}}
end
@impl true
def handle_continue(:warm, state) do
try do
warm()
rescue
_ -> :ok
end
{:noreply, state}
end
## Private — mirrors Settings.decode_value/1 for non-encrypted types
defp decode_value(%{value: value, value_type: "json"}), do: Jason.decode!(value)
defp decode_value(%{value: value, value_type: "integer"}), do: String.to_integer(value)
defp decode_value(%{value: value, value_type: "boolean"}), do: value == "true"
defp decode_value(%{value: value, value_type: "string"}), do: value
end

View File

@ -114,6 +114,7 @@ defmodule Berrypod.Shipping do
)
Logger.info("Upserted #{count} shipping rates for connection #{provider_connection_id}")
Berrypod.Settings.SettingsCache.invalidate_cached(:countries_with_names)
{:ok, count}
end
@ -343,9 +344,21 @@ defmodule Berrypod.Shipping do
sorted by name.
"""
def list_available_countries_with_names do
alias Berrypod.Settings.SettingsCache
case SettingsCache.get_cached(:countries_with_names) do
{:ok, countries} ->
countries
:miss ->
countries =
list_available_countries()
|> Enum.map(fn code -> {code, country_name(code)} end)
|> Enum.sort_by(&elem(&1, 1))
SettingsCache.put_cached(:countries_with_names, countries)
countries
end
end
@doc """

View File

@ -129,6 +129,10 @@ defmodule Berrypod.Sync.ProductSyncWorker do
# Rebuild search index after successful sync
Berrypod.Search.rebuild_index()
# Invalidate cached categories and countries (products/rates may have changed)
Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
Berrypod.Settings.SettingsCache.invalidate_cached(:countries_with_names)
:ok
else
{:error, reason} = error ->

View File

@ -42,13 +42,11 @@ defmodule BerrypodWeb.Router do
pipeline :shop do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
plug BerrypodWeb.Plugs.LoadTheme
plug BerrypodWeb.Plugs.Analytics
end
pipeline :admin do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :admin_root}
plug BerrypodWeb.Plugs.LoadTheme
end
# ── Routes without the :browser pipeline ──────────────────────────

View File

@ -33,6 +33,7 @@ defmodule BerrypodWeb.ConnCase do
setup tags do
Berrypod.DataCase.setup_sandbox(tags)
Berrypod.Settings.SettingsCache.invalidate_all()
{:ok, conn: Phoenix.ConnTest.build_conn()}
end

View File

@ -29,6 +29,7 @@ defmodule Berrypod.DataCase do
setup tags do
Berrypod.DataCase.setup_sandbox(tags)
Berrypod.Settings.SettingsCache.invalidate_all()
:ok
end