cache settings, categories, and media in ETS to cut per-request DB queries
All checks were successful
deploy / deploy (push) Successful in 1m26s
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:
parent
5e70c07b60
commit
297f3de60f
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 """
|
||||
|
||||
@ -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 """
|
||||
|
||||
162
lib/berrypod/settings/settings_cache.ex
Normal file
162
lib/berrypod/settings/settings_cache.ex
Normal 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
|
||||
@ -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 """
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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 ──────────────────────────
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ defmodule Berrypod.DataCase do
|
||||
|
||||
setup tags do
|
||||
Berrypod.DataCase.setup_sandbox(tags)
|
||||
Berrypod.Settings.SettingsCache.invalidate_all()
|
||||
:ok
|
||||
end
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user