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, Berrypod.Images.VariantCache,
# Start to serve requests # Start to serve requests
BerrypodWeb.Endpoint, 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 # Theme CSS cache - must start after Endpoint for static_path/1 to work
Berrypod.Theme.CSSCache, Berrypod.Theme.CSSCache,
# Page definition cache - loads page block lists into ETS # 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) widths = applicable_widths(source_width)
all_tasks = 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 for w <- widths, fmt <- @pregenerated_formats do
fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, w, fmt, w) end fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, w, fmt, w) end
end end
@ -202,6 +213,7 @@ defmodule Berrypod.Images.Optimizer do
timeout: :timer.seconds(120) timeout: :timer.seconds(120)
) )
|> Stream.run() |> Stream.run()
{:ok, source_width} {:ok, source_width}
end end
rescue rescue

View File

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

View File

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

View File

@ -5,7 +5,7 @@ defmodule Berrypod.Settings do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Berrypod.Repo alias Berrypod.Repo
alias Berrypod.Settings.{Setting, ThemeSettings} alias Berrypod.Settings.{Setting, SettingsCache, ThemeSettings}
alias Berrypod.Vault alias Berrypod.Vault
@doc """ @doc """
@ -18,9 +18,15 @@ defmodule Berrypod.Settings do
""" """
def get_setting(key, default \\ nil) do def get_setting(key, default \\ nil) do
case fetch_setting(key) do case SettingsCache.get(key) do
{:ok, setting} -> decode_value(setting) {:ok, value} ->
:not_found -> default value
:miss ->
case fetch_setting(key) do
{:ok, setting} -> decode_value(setting)
:not_found -> default
end
end end
end end
@ -36,12 +42,24 @@ defmodule Berrypod.Settings do
def put_setting(key, value, value_type \\ "string") do def put_setting(key, value, value_type \\ "string") do
encoded_value = encode_value(value, value_type) encoded_value = encode_value(value, value_type)
%Setting{key: key} result =
|> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type}) %Setting{key: key}
|> Repo.insert( |> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type})
on_conflict: {:replace, [:value, :value_type, :updated_at]}, |> Repo.insert(
conflict_target: :key on_conflict: {:replace, [:value, :value_type, :updated_at]},
) conflict_target: :key
)
case result do
{:ok, _} ->
SettingsCache.invalidate()
SettingsCache.warm()
_ ->
:ok
end
result
end end
@doc """ @doc """
@ -154,10 +172,16 @@ defmodule Berrypod.Settings do
Deletes a setting by key. Deletes a setting by key.
""" """
def delete_setting(key) do def delete_setting(key) do
case fetch_setting(key) do result =
{:ok, setting} -> Repo.delete(setting) case fetch_setting(key) do
:not_found -> :ok {:ok, setting} -> Repo.delete(setting)
end :not_found -> :ok
end
SettingsCache.invalidate()
SettingsCache.warm()
result
end end
@doc """ @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}") Logger.info("Upserted #{count} shipping rates for connection #{provider_connection_id}")
Berrypod.Settings.SettingsCache.invalidate_cached(:countries_with_names)
{:ok, count} {:ok, count}
end end
@ -343,9 +344,21 @@ defmodule Berrypod.Shipping do
sorted by name. sorted by name.
""" """
def list_available_countries_with_names do def list_available_countries_with_names do
list_available_countries() alias Berrypod.Settings.SettingsCache
|> Enum.map(fn code -> {code, country_name(code)} end)
|> Enum.sort_by(&elem(&1, 1)) 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 end
@doc """ @doc """

View File

@ -129,6 +129,10 @@ defmodule Berrypod.Sync.ProductSyncWorker do
# Rebuild search index after successful sync # Rebuild search index after successful sync
Berrypod.Search.rebuild_index() 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 :ok
else else
{:error, reason} = error -> {:error, reason} = error ->

View File

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

View File

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

View File

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