optimise product queries: skip image blobs, limit listing preloads, add composite index
All checks were successful
deploy / deploy (push) Successful in 1m29s

The listing preload (images: :image) was loading the full images table row
including the data BLOB column (~3MB per page). Now only loads :id and
:source_width. Listing preloads also limited to first 2 images (primary +
hover) since product cards don't use the rest. Added composite indexes on
(visible, status, inserted_at) and (visible, status, category) to eliminate
the TEMP B-TREE sort SQLite was doing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-02 17:29:15 +00:00
parent 297f3de60f
commit 2f3b7e7b21
6 changed files with 79 additions and 22 deletions

View File

@ -132,10 +132,19 @@ defmodule Berrypod.Products do
# Storefront queries # Storefront queries
# ============================================================================= # =============================================================================
# Listing pages only need images (price/stock are denormalized on product) # Image preload that skips the data blob — only loads :id and :source_width
@listing_preloads [images: :image] # from the images table. The data column holds the raw image binary (up to 5MB
# Detail page also needs variants for the variant selector # per row) which is never used in rendering.
@detail_preloads [images: :image, variants: []] defp image_preload_query do
from(i in Berrypod.Media.Image, select: struct(i, [:id, :source_width]))
end
# Listing pages only need the first 2 images (primary + hover) and don't
# need variants (price/stock are denormalized on the product row).
defp listing_preloads do
pi_query = from(pi in ProductImage, where: pi.position <= 1, order_by: pi.position)
[images: {pi_query, image: image_preload_query()}]
end
@doc """ @doc """
Gets a single visible, active product by slug with full preloads (for detail page). Gets a single visible, active product by slug with full preloads (for detail page).
@ -143,8 +152,11 @@ defmodule Berrypod.Products do
def get_visible_product(slug) do def get_visible_product(slug) do
Product Product
|> where([p], p.slug == ^slug and p.visible == true and p.status == "active") |> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
|> preload(^@detail_preloads)
|> Repo.one() |> Repo.one()
|> case do
nil -> nil
product -> Repo.preload(product, images: [image: image_preload_query()], variants: [])
end
end end
@doc """ @doc """
@ -167,8 +179,8 @@ defmodule Berrypod.Products do
|> apply_sort(opts[:sort]) |> apply_sort(opts[:sort])
|> maybe_limit(opts[:limit]) |> maybe_limit(opts[:limit])
|> maybe_exclude(opts[:exclude]) |> maybe_exclude(opts[:exclude])
|> preload(^@listing_preloads)
|> Repo.all() |> Repo.all()
|> Repo.preload(listing_preloads())
end end
@doc """ @doc """
@ -177,13 +189,15 @@ defmodule Berrypod.Products do
Accepts the same filter/sort options plus `:page` and `:per_page`. Accepts the same filter/sort options plus `:page` and `:per_page`.
""" """
def list_visible_products_paginated(opts \\ []) do def list_visible_products_paginated(opts \\ []) do
Product pagination =
|> where([p], p.visible == true and p.status == "active") Product
|> apply_visible_filters(opts) |> where([p], p.visible == true and p.status == "active")
|> apply_sort(opts[:sort]) |> apply_visible_filters(opts)
|> maybe_exclude(opts[:exclude]) |> apply_sort(opts[:sort])
|> preload(^@listing_preloads) |> maybe_exclude(opts[:exclude])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24) |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
%{pagination | items: Repo.preload(pagination.items, listing_preloads())}
end end
@doc """ @doc """
@ -357,7 +371,7 @@ defmodule Berrypod.Products do
|> apply_product_filters(opts) |> apply_product_filters(opts)
|> maybe_filter_in_stock(opts[:in_stock]) |> maybe_filter_in_stock(opts[:in_stock])
|> apply_sort(opts[:sort]) |> apply_sort(opts[:sort])
|> preload([:provider_connection, images: :image, variants: []]) |> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
|> Repo.all() |> Repo.all()
end end
@ -371,7 +385,7 @@ defmodule Berrypod.Products do
|> apply_product_filters(opts) |> apply_product_filters(opts)
|> maybe_filter_in_stock(opts[:in_stock]) |> maybe_filter_in_stock(opts[:in_stock])
|> apply_sort(opts[:sort]) |> apply_sort(opts[:sort])
|> preload([:provider_connection, images: :image, variants: []]) |> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25) |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end end
@ -397,6 +411,27 @@ defmodule Berrypod.Products do
|> Repo.get(id) |> Repo.get(id)
end end
@doc """
Gets a product by ID with admin preloads (provider, images, variants).
Excludes image blob data.
"""
def get_product_with_preloads(id) do
Product
|> Repo.get(id)
|> case do
nil ->
nil
product ->
Repo.preload(product, [
:provider_connection,
images: [image: image_preload_query()],
variants: []
])
end
end
@doc """ @doc """
Gets a single product by slug. Gets a single product by slug.
""" """
@ -817,7 +852,7 @@ defmodule Berrypod.Products do
def get_variants_with_products(variant_ids) when is_list(variant_ids) do def get_variants_with_products(variant_ids) when is_list(variant_ids) do
from(v in ProductVariant, from(v in ProductVariant,
where: v.id in ^variant_ids, where: v.id in ^variant_ids,
preload: [product: [images: :image]] preload: [product: [images: [image: ^image_preload_query()]]]
) )
|> Repo.all() |> Repo.all()
|> Map.new(&{&1.id, &1}) |> Map.new(&{&1.id, &1})

View File

@ -11,7 +11,15 @@ defmodule Berrypod.Search do
alias Berrypod.Products.Product alias Berrypod.Products.Product
alias Berrypod.Repo alias Berrypod.Repo
@listing_preloads [images: :image] # Search results only need the first 2 images (card display) and only
# source_width from the images table — no blob data.
defp listing_preloads do
alias Berrypod.Products.ProductImage
image_query = from(i in Berrypod.Media.Image, select: struct(i, [:id, :source_width]))
pi_query = from(pi in ProductImage, where: pi.position <= 1, order_by: pi.position)
[images: {pi_query, image: image_query}]
end
# BM25 column weights: title(10), category(5), variant_info(3), description(1) # BM25 column weights: title(10), category(5), variant_info(3), description(1)
@bm25_weights "10.0, 5.0, 3.0, 1.0" @bm25_weights "10.0, 5.0, 3.0, 1.0"
@ -126,8 +134,8 @@ defmodule Berrypod.Search do
Product Product
|> where([p], p.id in ^product_ids) |> where([p], p.id in ^product_ids)
|> where([p], p.visible == true and p.status == "active") |> where([p], p.visible == true and p.status == "active")
|> preload(^@listing_preloads)
|> Repo.all() |> Repo.all()
|> Repo.preload(listing_preloads())
|> Map.new(&{&1.id, &1}) |> Map.new(&{&1.id, &1})
Enum.flat_map(product_ids, fn id -> Enum.flat_map(product_ids, fn id ->
@ -148,8 +156,8 @@ defmodule Berrypod.Search do
|> where([p], like(p.title, ^pattern) or like(p.category, ^pattern)) |> where([p], like(p.title, ^pattern) or like(p.category, ^pattern))
|> order_by([p], p.title) |> order_by([p], p.title)
|> limit(20) |> limit(20)
|> preload(^@listing_preloads)
|> Repo.all() |> Repo.all()
|> Repo.preload(listing_preloads())
end end
defp sanitize_like(input) do defp sanitize_like(input) do

View File

@ -361,7 +361,7 @@ defmodule Berrypod.Theme.PreviewData do
Products.list_products( Products.list_products(
visible: true, visible: true,
status: "active", status: "active",
preload: [images: :image, variants: []] preload: [:images, :variants]
) )
end end

View File

@ -7,7 +7,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
case Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) do case Products.get_product_with_preloads(id) do
nil -> nil ->
socket = socket =
socket socket

View File

@ -64,7 +64,7 @@ defmodule BerrypodWeb.Admin.Products do
@impl true @impl true
def handle_event("toggle_visibility", %{"id" => id}, socket) do def handle_event("toggle_visibility", %{"id" => id}, socket) do
product = product =
Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) Products.get_product_with_preloads(id)
case Products.toggle_visibility(product) do case Products.toggle_visibility(product) do
{:ok, updated} -> {:ok, updated} ->

View File

@ -0,0 +1,14 @@
defmodule Berrypod.Repo.Migrations.AddProductsListingIndexes do
use Ecto.Migration
def change do
# Composite covering index for the main listing query:
# WHERE visible = 1 AND status = 'active' ORDER BY inserted_at DESC
# Replaces the individual visible + status indexes for this pattern
# and eliminates the TEMP B-TREE sort SQLite was doing.
create index(:products, [:visible, :status, :inserted_at])
# Filtered listing by category (second most common query pattern)
create index(:products, [:visible, :status, :category])
end
end