diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex index 6cfa387..acd86e2 100644 --- a/lib/berrypod/application.ex +++ b/lib/berrypod/application.ex @@ -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 diff --git a/lib/berrypod/images/optimizer.ex b/lib/berrypod/images/optimizer.ex index ef91372..394e997 100644 --- a/lib/berrypod/images/optimizer.ex +++ b/lib/berrypod/images/optimizer.ex @@ -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 diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index a6fe5de..f46177f 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -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 - Repo.one( - from i in ImageSchema, - where: i.image_type == "logo", - order_by: [desc: i.inserted_at], - limit: 1 - ) + 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 - Repo.one( - from i in ImageSchema, - where: i.image_type == "header", - order_by: [desc: i.inserted_at], - limit: 1 - ) + 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 diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index daf1fe2..30666f4 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -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 - from(p in Product, - where: p.visible == true and p.status == "active" and not is_nil(p.category), - 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) + 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 - 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 + # 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(&{&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 @doc """ @@ -400,9 +438,17 @@ defmodule Berrypod.Products do Updates storefront-only fields (visibility and category). """ def update_storefront(%Product{} = product, attrs) do - product - |> Product.storefront_changeset(attrs) - |> Repo.update() + 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 """ diff --git a/lib/berrypod/settings.ex b/lib/berrypod/settings.ex index 88c56da..7a3b151 100644 --- a/lib/berrypod/settings.ex +++ b/lib/berrypod/settings.ex @@ -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,9 +18,15 @@ defmodule Berrypod.Settings do """ def get_setting(key, default \\ nil) do - case fetch_setting(key) do - {:ok, setting} -> decode_value(setting) - :not_found -> default + 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 @@ -36,12 +42,24 @@ defmodule Berrypod.Settings do def put_setting(key, value, value_type \\ "string") do encoded_value = encode_value(value, value_type) - %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 - ) + 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 - case fetch_setting(key) do - {:ok, setting} -> Repo.delete(setting) - :not_found -> :ok - end + result = + case fetch_setting(key) do + {:ok, setting} -> Repo.delete(setting) + :not_found -> :ok + end + + SettingsCache.invalidate() + SettingsCache.warm() + + result end @doc """ diff --git a/lib/berrypod/settings/settings_cache.ex b/lib/berrypod/settings/settings_cache.ex new file mode 100644 index 0000000..21c9cb6 --- /dev/null +++ b/lib/berrypod/settings/settings_cache.ex @@ -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 diff --git a/lib/berrypod/shipping.ex b/lib/berrypod/shipping.ex index 0477568..b5de249 100644 --- a/lib/berrypod/shipping.ex +++ b/lib/berrypod/shipping.ex @@ -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 - list_available_countries() - |> Enum.map(fn code -> {code, country_name(code)} end) - |> Enum.sort_by(&elem(&1, 1)) + 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 """ diff --git a/lib/berrypod/sync/product_sync_worker.ex b/lib/berrypod/sync/product_sync_worker.ex index 9f47d9e..bda8bc6 100644 --- a/lib/berrypod/sync/product_sync_worker.ex +++ b/lib/berrypod/sync/product_sync_worker.ex @@ -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 -> diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 5af14d8..bd9ec57 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -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 ────────────────────────── diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index be4c458..5ad4d2c 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -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 diff --git a/test/support/data_case.ex b/test/support/data_case.ex index fa2c7c0..b6f24bd 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -29,6 +29,7 @@ defmodule Berrypod.DataCase do setup tags do Berrypod.DataCase.setup_sandbox(tags) + Berrypod.Settings.SettingsCache.invalidate_all() :ok end