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,
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
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}")
|
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 """
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
@ -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 ──────────────────────────
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user