Storefront 商品表示・一覧・検索パフォーマンス トラブルシュート¶
目的¶
Storefront の / / /products / /search?q=... / /products/$slug が遅い場合に、推測ではなく staging / production の観測結果から
「home shell」「商品 browse 本体」「認証・カート context」「Header / Navigation の background
prefetch」「Fly machine cold start」「CMS / collection chrome」のどこで待っているかを切り分けるための手順です。
この runbook は Issue #580 の対応で得た計測手順を正本化したものです。
合格基準¶
- staging の authenticated
/で、home-contract-brand-listが commit された時刻を示すwindow.__ritsubiHomeContractBrandsMetrics.durationMsが 1s 未満であること。window.__ritsubiHomePageMetrics.durationMsは shell commit の補助値として扱い、 ご契約ブランド一覧の合否には使わない。 - 公式 smoke は
tests/e2e/smoke/home-page-performance.real.spec.tsを使い、 中央値 1s 未満、最大 1.2s 以下を最低ラインとして監視する。 - staging の authenticated
/productsで、実操作できる header の初回表示 metricwindow.__ritsubiHeaderMetrics.durationMsが 1s 未満であること。CMS shell 取得待ちの skeleton ではなく、default header model で描画されたsite-headerを測る。 - 公式 smoke は
tests/e2e/smoke/header-performance.real.spec.tsを使い、 中央値 1s 未満、最大 1.2s 以下を最低ラインとして監視する。 - staging の authenticated
/productsで、ブラウザ内 first visible metricwindow.__ritsubiProductsPageMetrics.durationMsが 1s 以内であること。 - 公式 smoke は
tests/e2e/smoke/products-page-performance.real.spec.tsを使い、 中央値 1s 以下、最大 1.5s 以下を最低ラインとして監視する。 - staging の authenticated
/search?q=...で、ブラウザ内 first visible metricwindow.__ritsubiProductsPageMetrics.durationMsが 1.2s 以内であること。 - 公式 smoke は
tests/e2e/smoke/products-search-performance.real.spec.tsを使い、 中央値 1.2s 以下、最大 1.5s 以下を最低ラインとして監視する。 - staging の authenticated
/products/$slugで、ブラウザ内 first visible metricwindow.__ritsubiProductDetailPageMetrics.durationMsが 1s 未満であること。 - 公式 smoke は
tests/e2e/smoke/product-detail-performance.real.spec.tsを使い、 中央値 1s 未満、最大 1.2s 以下を最低ラインとして監視する。 - 追加の 10 サンプル計測では、外れ値を含めて first visible が 1s 程度に収まることを確認する。
- 顧客主要リスト系は、staging authenticated route で list 本体または empty state の
first visible を測る。
/account/favorites、/account/orders、/cart、/quick-orderは中央値 1.5s 未満・最大 1.8s 以下、/account/addresses、/account/giftは中央値 1s 未満・最大 1.2s 以下を最低ラインとして監視する。
最初に確認すること¶
- 対象環境の health / version / machine 状態を確認する。
just env-status staging
- Vendure staging の Fly machine が stopped を含んでいないか確認する。
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl machine list -a ritsubi-ecommerce-staging --json
- stopped machine が残っている場合は、cold start 外れ値を疑う。staging を 1 台 warm 構成にする場合は次を実行する。
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl scale count app=1 -a ritsubi-ecommerce-staging --yes
flyctl machine list -a ritsubi-ecommerce-staging --json
/products が 500 になる場合¶
/products / /search / home 周辺でブラウザ console に
POST /commerce/shop-api 500 と Vendure API request failed with status 500
が出る場合、Storefront Worker だけでなく Vendure 側の GraphQL guard を先に見る。
CSP の inline handler 警告や /monitoring の 429 が同時に出ていても、商品表示の
500 とは別症状のことがある。
- Storefront / Vendure / Dashboard の現在状態を確認する。
just env-status production
- Vendure の live log で HardenPlugin の complexity reject を探す。
fly logs -a ritsubi-ecommerce --no-tail -j \
| rg 'HardenPlugin|Query complexity|commerce/shop-api|Vendure API request failed'
現行 flyctl logs は --since を受け付けない。時刻範囲は Sentry / env-status /
deploy release 時刻で先に絞り、CLI では ring buffer を rg で絞る。
次のような行が出る場合は、Vendure は落ちておらず、GraphQL query が上限を超えて fail-closed している。
Query complexity of "anonymous" is 9250, which exceeds the maximum of 2500
production の maxQueryComplexity は 2500 の strict 運用を正本にする。
正常系 query がこの上限を超える場合は、上限緩和ではなく list fragment / take
/ query shape を軽量化して戻す。
Storefront の raw GraphQL query については deploy 前に同じ判定を preflight できる。
just storefront-graphql-complexity-preflight staging
この recipe は scripts/ops/storefront-shop-api-operation-registry.mjs で preflight: true
にした Storefront query を対象環境の Vendure Shop API へ直接投げ、Query is too complex や
Cannot query field を build / deploy 前に fail させる。just cloudflare-deploy-storefront <env>
と just deploy-storefront-staging では、この preflight が Cloudflare upload 前に自動実行される。
新しい raw Shop API query は registry 未登録だと pnpm run lint:storefront-shop-graphql-boundary
で落ちる。
- 同じ endpoint で小さい query が通るか確認する。これが 200 なら upstream availability ではなく、特定 query の shape が問題。
curl -sS -D /tmp/shop-api.headers -o /tmp/shop-api.json \
-H 'content-type: application/json' \
--data '{"query":"query HealthCollections { collections(options: { take: 1 }) { totalItems } }"}' \
https://medical.ritsubi.co.jp/commerce/shop-api
sed -n '1,30p' /tmp/shop-api.headers
cat /tmp/shop-api.json
collections(topLevelOnly)のような list multiplier が大きい query を疑う。 2026-05-27 の production incident では、Storefront の browse chrome が top-level collection tree 全体を取って complexity 9000 超になり、HardenPlugin に遮断された。修正方針は HardenPlugin を無効化することではなく、必要な root slug だけをcollection(slug: "...")で明示取得すること。
query GetCollectionBrowseRoots {
brands: collection(slug: "brands") {
id
name
slug
children {
id
name
slug
}
}
productTypes: collection(slug: "product-types") {
id
name
slug
children {
id
name
slug
}
}
campaigns: collection(slug: "campaigns") {
id
name
slug
children {
id
name
slug
}
}
}
- HardenPlugin の上限を変える場合は、先に query を削る。上限緩和は正規導線が少し
超える場合だけに限定する。正規導線の collection browser は flat / paged query
(
skip/take+parentId) で 200 になることを確認する。
curl -sS -D /tmp/collections.headers -o /tmp/collections.json \
-H 'content-type: application/json' \
--data '{"query":"query GetCollectionsForBrowser($skip: Int!, $take: Int!) { collections(options: { skip: $skip, take: $take, sort: { position: ASC } }) { items { id name slug position description parentId featuredAsset { id preview source } } totalItems } }","variables":{"skip":0,"take":100}}' \
https://medical.ritsubi.co.jp/commerce/shop-api
sed -n '1,30p' /tmp/collections.headers
cat /tmp/collections.json
古い children { children { ... } } 型の nested collection query は
packages/sdk/shop/src/operations/operation-complexity-guard.test.ts で再混入を禁止する。
- CSP warning が同時に出ている場合は、HTML shell の inline event handler を見る。
2026-05-27 の incident では Google Fonts link の
onload="this.media='all'"が CSP に遮断されていた。CSP を緩める前にapps/storefront/index.htmlの inline handler を消す。
認証 redirect / service-unavailable の切り分け¶
/ / /products / /search が商品や home shell へ到達せず、ログイン画面で
error=service-unavailable になる場合は、商品 query ではなく edge / browser の
session validation が first visible を止めている。
/api/auth-sessionの full session validation を見る。
curl -sS -w '\nstatus=%{http_code} total=%{time_total}\n' \
'https://order-staging.ritsubi-platform.com/api/auth-session'
-
cookie があるのに login へ落ちる場合は、Vendure
activeCustomerが返っているかを 確認する。/,/products,/search,/products/$slugは得意先別の表示制御・価格・ 購入条件に依存するため、cookie の存在だけでは通さない。 -
service-unavailableを消すために protected navigation を fail-open しない。 商品 route も含め、_site配下は fullactiveCustomervalidation を維持する。 -
browser route guard は
requireAuthenticatedCustomerVite()を確認する。 stale cookie を読めても/api/auth-sessionの Vendure 検証に失敗したら login redirect に倒す。intent=browse-shellのような軽量判定は使わない。
公式 smoke¶
staging の home / header / 商品カタログ / 商品検索初回表示パフォーマンスは次でまとめて測る。
just storefront-e2e-real staging \
'tests/e2e/smoke/home-page-performance.real.spec.ts tests/e2e/smoke/header-performance.real.spec.ts tests/e2e/smoke/products-page-performance.real.spec.ts tests/e2e/smoke/products-search-performance.real.spec.ts tests/e2e/smoke/product-detail-performance.real.spec.ts --reporter=line'
個別に商品カタログ初回表示パフォーマンスを測る場合は次を使う。
env \
PLAYWRIGHT_BASE_URL=https://order-staging.ritsubi-platform.com \
PLAYWRIGHT_SKIP_WEBSERVER=true \
PLAYWRIGHT_HTML_OPEN=never \
E2E_LOGIN_EMAIL=test1@ritsubi-platform.com \
E2E_LOGIN_PASSWORD='DevCustomer123!' \
pnpm -C apps/storefront exec playwright test \
tests/e2e/smoke/products-page-performance.real.spec.ts \
--config playwright.config.ts \
--project=chromium
商品検索パフォーマンスは次で測る。
env \
PLAYWRIGHT_BASE_URL=https://order-staging.ritsubi-platform.com \
PLAYWRIGHT_SKIP_WEBSERVER=true \
PLAYWRIGHT_HTML_OPEN=never \
E2E_LOGIN_EMAIL=test1@ritsubi-platform.com \
E2E_LOGIN_PASSWORD='DevCustomer123!' \
pnpm -C apps/storefront exec playwright test \
tests/e2e/smoke/products-search-performance.real.spec.ts \
--config playwright.config.ts \
--project=chromium
商品詳細パフォーマンスは次で測る。
env \
PLAYWRIGHT_BASE_URL=https://order-staging.ritsubi-platform.com \
PLAYWRIGHT_SKIP_WEBSERVER=true \
PLAYWRIGHT_HTML_OPEN=never \
E2E_LOGIN_EMAIL=test1@ritsubi-platform.com \
E2E_LOGIN_PASSWORD='DevCustomer123!' \
pnpm -C apps/storefront exec playwright test \
tests/e2e/smoke/product-detail-performance.real.spec.ts \
--config playwright.config.ts \
--project=chromium
顧客主要リスト系パフォーマンスは次で測る。/account/payments と /account/billing は
/account/orders への redirect route なので個別計測しない。CMS / 公開一覧系
(/campaigns、/announcements、/articles) はこの smoke の対象外。
just storefront-e2e-real staging \
'tests/e2e/smoke/customer-list-performance.real.spec.ts --reporter=line'
成功時はログに次のような値が出る。
[home-page-performance] contractBrandsMetricMs=768,713,698,734,724 medianMs=724 maxMs=768
[product-detail-page-performance] slug=exuviance-professional-cleanser durations=599,589,578,605,592 medianMs=592 maxMs=605
[products-page-performance] durations=974,962,916,928,925 medianMs=928 maxMs=974
[products-search-performance] term="20mL" durations=1180,1081,1207,1203,1148 medianMs=1180 maxMs=1207
[customer-list-performance] id=favorites path=/account/favorites durations=1051,1050,1071,1080,1003 medianMs=1051 maxMs=1080
[customer-list-performance] id=orders path=/account/orders durations=1030,982,977,1053,1001 medianMs=1001 maxMs=1053
[customer-list-performance] id=addresses path=/account/addresses durations=716,757,711,710,733 medianMs=716 maxMs=757
[customer-list-performance] id=gift path=/account/gift durations=986,1024,1018,995,984 medianMs=995 maxMs=1024
[customer-list-performance] id=cart path=/cart durations=986,803,864,850,793 medianMs=850 maxMs=986
[customer-list-performance] id=quick-order path=/quick-order durations=750,729,775,770,706 medianMs=750 maxMs=775
2026-05-31 時点の staging 合格ラインは上記の通り。商品詳細のユーザー要件は
first visible 1s 未満なので、product-detail-performance.real.spec.ts は中央値
1s 未満を必須、最大 1.2s 以下を外れ値ガードとして扱う。
追加 10 サンプル計測¶
公式 smoke が通っても、外れ値確認のため同じ authenticated storage state で 10 サンプルを取る。
pnpm -C apps/storefront exec node --input-type=module <<'EOF'
import { chromium, expect } from '@playwright/test';
const baseURL = 'https://order-staging.ritsubi-platform.com';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
baseURL,
storageState: 'tmp/playwright/storefront/e2e/auth/storage-state.json',
});
const page = await context.newPage();
await page.addInitScript(() => {
window.sessionStorage.setItem('password-change-skip', '1');
window.sessionStorage.setItem('auth-bypass-skip', '1');
});
const results = [];
for (let i = 0; i < 10; i += 1) {
const startedAt = Date.now();
await page.goto(`/products?perfRun=${startedAt}&sample=${i}`, {
waitUntil: 'domcontentloaded',
});
await expect(page.locator('[data-testid="products-page"]:visible').first()).toBeVisible({
timeout: 30000,
});
await expect(
page.locator('[data-testid^="products-page-item-"][data-testid$="-title"]').first(),
).toBeVisible({ timeout: 30000 });
await expect
.poll(async () => page.evaluate(() => window.__ritsubiProductsPageMetrics ?? null), {
timeout: 30000,
})
.not.toBeNull();
const metric = await page.evaluate(() => window.__ritsubiProductsPageMetrics ?? null);
const nav = await page.evaluate(() => {
const [entry] = performance.getEntriesByType('navigation');
if (!(entry instanceof PerformanceNavigationTiming)) return null;
return {
responseStartMs: Math.round(entry.responseStart),
domContentLoadedMs: Math.round(entry.domContentLoadedEventEnd),
loadEventEndMs: Math.round(entry.loadEventEnd),
transferSize: entry.transferSize,
};
});
results.push({
sample: i,
wallClockMs: Date.now() - startedAt,
durationMs: metric?.durationMs,
nav,
});
}
await browser.close();
const durations = results
.map((result) => result.durationMs)
.filter((value) => typeof value === 'number')
.sort((left, right) => left - right);
const wall = results.map((result) => result.wallClockMs).sort((left, right) => left - right);
console.log(JSON.stringify({
url: `${baseURL}/products`,
count: results.length,
durationMedianMs: durations[Math.floor(durations.length / 2)],
durationMaxMs: Math.max(...durations),
wallClockMedianMs: wall[Math.floor(wall.length / 2)],
wallClockMaxMs: Math.max(...wall),
results,
}, null, 2));
EOF
商品詳細の外れ値を追加確認する場合は、同じ storage state で /products/$slug を 10
サンプル取る。
pnpm -C apps/storefront exec node --input-type=module <<'EOF'
import { chromium, expect } from '@playwright/test';
const baseURL = 'https://order-staging.ritsubi-platform.com';
const slug = 'exuviance-professional-cleanser';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
baseURL,
storageState: 'tmp/playwright/storefront/e2e/auth/storage-state.json',
});
const page = await context.newPage();
await page.addInitScript(() => {
window.sessionStorage.setItem('password-change-skip', '1');
window.sessionStorage.setItem('auth-bypass-skip', '1');
});
const results = [];
for (let i = 0; i < 10; i += 1) {
const startedAt = Date.now();
await page.goto(`/products/${slug}?perfRun=${startedAt}&sample=${i}`, {
waitUntil: 'domcontentloaded',
});
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 30000 });
await expect(page.getByTestId('product-detail-page-detail-title')).toBeVisible({
timeout: 30000,
});
await expect
.poll(async () => page.evaluate(() => window.__ritsubiProductDetailPageMetrics ?? null), {
timeout: 30000,
})
.not.toBeNull();
const metric = await page.evaluate(() => window.__ritsubiProductDetailPageMetrics ?? null);
const nav = await page.evaluate(() => {
const [entry] = performance.getEntriesByType('navigation');
if (!(entry instanceof PerformanceNavigationTiming)) return null;
return {
responseStartMs: Math.round(entry.responseStart),
domContentLoadedMs: Math.round(entry.domContentLoadedEventEnd),
loadEventEndMs: Math.round(entry.loadEventEnd),
transferSize: entry.transferSize,
};
});
results.push({
sample: i,
wallClockMs: Date.now() - startedAt,
durationMs: metric?.durationMs,
nav,
relatedProductsCount: metric?.relatedProductsCount ?? null,
hasWpContent: metric?.hasWpContent ?? null,
});
}
await browser.close();
const durations = results
.map((result) => result.durationMs)
.filter((value) => typeof value === 'number')
.sort((left, right) => left - right);
const wall = results.map((result) => result.wallClockMs).sort((left, right) => left - right);
console.log(JSON.stringify({
url: `${baseURL}/products/${slug}`,
count: results.length,
durationMedianMs: durations[Math.floor(durations.length / 2)],
durationMaxMs: Math.max(...durations),
wallClockMedianMs: wall[Math.floor(wall.length / 2)],
wallClockMaxMs: Math.max(...wall),
results,
}, null, 2));
EOF
Cloudflare Worker tail での切り分け¶
外れ値が残る場合は、staging Worker tail を取りながら同じ計測を再実行する。
mkdir -p tmp/storefront/wrangler-logs
tmp_log="tmp/storefront/wrangler-logs/products-tail-$(date +%s).jsonl"
echo "$tmp_log"
timeout 60s ./scripts/ops/with-cloudflare-auth.sh -- \
pnpm --dir apps/storefront exec wrangler tail ec-storefront \
--env staging \
--format json \
--sampling-rate 0.99 \
> "$tmp_log" 2>&1
CLOUDFLARE_API_TOKEN はローカル troubleshooting では注入しない。Cloudflare 操作は
wrangler login のユーザー OAuth を正とし、OAuth エラーを共有 token fallback で隠さない。
tail では次を確認する。
/products?...のwallTimeとcpuTime。cpuTimeが小さくwallTimeが大きい場合は、Worker CPU ではなく upstream / background data prefetch fanout 待ちを疑う。Slow GraphQL requestのoperationName。refererが/products?...のまま、無関係な account / home などへの prefetch request が並走していないか。
よくある原因と判断¶
/productsのresponseStartMsが 1s 超: server render / upstream 待ち。 tail で slow operation を確認する。GetVisibleProductsForBrowseCardが遅い: visible browse 本体。 Vendure 側visibleProductsForBrowseと process-local cache を確認する。/productsのresponseStartMsは速いがfirst visibleだけ 1.5s 以上へ跳ねる: streaming される browse result の外れ値。visibleProductsForBrowseの batch policy / visibility cache を確認する。/products/$slugのresponseStartMsは速いがfirst visibleだけ 5s 超へ跳ねる: detail 本体ではなく CMS / recommendation 待ち。detail loader で補助データを直列化していないか確認し、 tail で upstream を切り分ける。/products/$slugが 1s を超える: 先にvisibleProductForDetail(slug)が使われているか確認する。 旧product(slug)+ 別途validateVisibilityの 2 段 query に戻すと、detail 本体の upstream 待ちだけで 1s を超えやすい。CMS / recommendation / collection chrome は first visible の正本に含めず、detail 本体と並列または後続で読む。/products/$slugの初回だけ route chunk discovery で遅い: Worker のLink: rel=modulepreloadとdist/route-preloads.jsonを確認する。apps/storefront/scripts/cloudflare-build.mjsがproduct._slug-*などの route chunk を manifest 化し、apps/storefront/worker.jsが product detail HTML へ Link header を付ける。/,/products,/search,/account/*,/cart,/quick-orderが全体的に 100ms 単位で重い: route 個別 API より先に Storefront Worker の SPA shell 経路を見る。 SPA navigation は存在しない route path を ASSETS に確認してから/へ fallback すると、 全 route で asset miss + shell 取得が二重化する。apps/storefront/worker.jsのreadSpaShellAsset()が root shell を Worker isolate 内で再利用し、既知の SPA route は 直接 shared shell から返す状態を正本にする。/products/$slugの SPA loader 開始後に Shop API を待っている: Worker の inline data preload を確認する。product detail HTML では CSP hash 付きの小さい script がvisibleProductForDetailfetch を route loader より先に開始し、window.__ritsubiProductDetailPreload.promiseを loader が再利用する。 CSP をunsafe-inlineで緩めない。GetActiveOrderContextが同時に遅い: 初回 paint に不要な active order hydration。/productsでは初回 commit 後へ遅延させる。/,/products,/searchがerror=service-unavailableの login へ落ちる: edge / browser の full session validation。cookie-only 判定へ戻さず、VendureactiveCustomer検証の遅延・失敗原因を確認する。- account / home など無関係 route の prefetch が並走: Header / Navigation の自動 prefetch fanout。
Header の常時表示 Link は
prefetch={false}にする。 - Fly machine に stopped が混在: auto-start / cold start 外れ値。 staging の app machine を warm な 1 台構成に整理する。
- CMS GraphQL 530 が混在: CMS / announcement / home prefetch。 商品 browse の KPI から切り離し、CMS 側は別 issue として扱う。
- 顧客主要リストだけが遅い: route shell ではなく list 本体または empty state の
first visible を見る。
customer-list-performance.real.spec.tsのreadySelectorが page shell だけを見ていないことを先に確認し、初期表示に不要な full session validation、active order hydration、補助 API、画像 / route chunk discovery、 client-only duplicate fetch を切り分ける。注文作成などの mutation で fixture を作らず、 データが無い場合も empty state を正規の first visible として扱う。 - 2026-05-31 の staging では protected shell marker、activeCustomer preload、account
list の activeOrder 遅延、CMS shell 遅延、gift/order/favorites の route preload に加え、
favorites を backend 側の可視性フィルタ済み正本へ寄せ、client-side
ValidateVisibility直列を撤去した。orders は初期一覧 fragment からlines -> productVariant -> bundleItems/assets/productの深い hydration を外し、 注文カード一覧は注文コード・状態・数量・金額まで、明細は注文詳細 route で取得する。 この状態で/account/favorites、/account/orders、/account/addresses、/account/gift、/cart、/quick-orderは上記の基準内に収まった。
2026-05 staging 調査メモ¶
- 2026-05-31 の staging
/products?collection=mesoceuticalでは、画像付き一覧の first visible がdurations=2871,2480,1761,2958,2619(median 2619ms) まで悪化した。根本原因は単一 query の遅さだけではなく、(1) 商品グリッドがcollectionIdsWithVisibleProductsを待つ UI 直列、(2) route loader が商品本体 fetch 前に catalog 全体の可視 collection 走査を待つ直列、(3) browse card からactiveOrderCommercialState/ rich price breakdown が商品数分発火する fanout、 (4) UI は 9 件ページなのに loader が 24 件 hydrate していた過剰取得、(5) HTTP-only cookie 認証のため browser 側visible-searchcache key が作れず同一タブ再計測で cache が効かない、の複合だった。 - 同 incident の対処後、staging Worker
18eb1b4d-78d7-4df0-96a1-8922efa727d0で公式 smoke はdurations=820,679,726,765,637 medianMs=726 maxMs=820に収まった。再発時は 「1 query を少し速くする」より先に、下記の通信形が戻っていないか確認する。 GetVisibleProductsForBrowseCardがGetCollectionIdsWithVisibleProducts完了後まで開始しない。GetVisibleProductsForBrowseCardのtakeがPRODUCTS_PAGE_SIZE(= 9) を超えている。- 商品カード表示直後に
ActiveOrderCommercialStateが商品数分並ぶ。 - sessionStorage に
ritsubi:visible-search:が作られず、同一タブの連続 browse でもGetVisibleProductsForBrowseCardが毎回 upstream へ出る。 /productsbrowse card は first visible を優先する surface であり、同時にカート投入前の pre-cart surface として扱う。単一 variant の価格表示はProductPrice/ProductPriceSummaryのdisplayMode="preCart"を使い、上代単価だけを表示する。activeOrderCommercialStateによる価格内訳、hover / focus / click の内訳 dialog、i詳細は商品一覧では出さない。初期表示で必要なのは画像、商品名、上代税込価格または 上代税込レンジ、詳細導線まで。- 一覧上のカート CTA を戻す場合も、pre-cart で価格内訳 fetch を戻してはいけない。発注可否・在庫・ active order mutation は副作用面が大きいため、商品数分 fanout を起こさない shared bulk API または post-first-visible lazy hydration を先に設計する。
collectionIdsWithVisibleProductsは sidebar / category chip の正本だが、商品本体 fetch の critical path に置かない。可視でない子孫 collection slug が混ざってもvisibleProductsForBrowseが variant policy で fail-closed に絞るため、商品取得はcollectionGroupsだけで選択 collection / 子孫 slug を解決して開始する。- browser 側
visible-searchcache は HTTP-only session cookie を直接 key に含められない。 そのため同一タブ限定のhttp-only-session-tabscope を使い、/api/auth-login//api/auth-logoutへ submit する共通入口でritsubi:visible-search:prefix を削除する。 localStorage や cross-tab cache に広げると別アカウントの可視商品を誤表示しうるため禁止。 /productsの tail latency は、visibleProductsForBrowseが sparse な page でrequestedTakeより大きく 2nd/3rd batch を膨らませると再発しやすい。page-size browse は「次の 1 page + overfetch」までに cap し、回帰 test をpackages/plugins/src/rule-engine/visibility/resolvers.spec.tsへ置く。- 2026-05-31 以降の
/products?collection=mesoceuticalは Storefront 初期表示でPRODUCTS_PAGE_SIZE(= 9) だけをvisibleProductsForBrowseに渡す。requestedTake: 24、requestedTake: 200、全件取得が production log に出た場合は古い bundle / deploy drift または route loader の過剰取得回帰を疑う。 - ブランド「すべて」は
collectionSlugsを使う 1 query が正本。親 + 子 collection を Storefront 側で N 回visibleProductsForBrowseへ分割すると、child collection 数に比例して first visible が悪化する。 - Vendure log の
visible-products-browseではrequestedTake,durationsMs,bottleneckStage,batchCountを見る。L2 hit はvisible-products-browse-shared-cache-hit。page-size browse でhydrateProductsが 全 collection 件数分に伸びている場合は、page 商品だけ hydrate する回帰 test を確認する。 visibleProductsForBrowse内の visibility 判定で同一 resolver 内の cache をforceFresh: trueで何度も破棄しない。外部 API のvalidateVisibilityは fresh validation を維持するが、browse resolver 内部のvalidateIds/filterVisibleVariantItemsは同一 request/shared context の cache を使う。/の KPI は home first viewport shell の表示で測る。hero / announcement の Promise は先に起動しても初期 shell を待たせず、ranking / brand / footer などの下段 chunk はHomePageDeferredContentとして initial commit 後へ回す。responseStartMsとdomContentLoadedMsが 100ms 前後でもwindow.__ritsubiProductsPageMetrics.durationMsだけが跳ねる場合、Worker shell ではなく streaming される browse result 側の待ち時間を疑う。- 2026-05-31 の staging では、Storefront Worker が既知の SPA route に対して
/productsなどの path を先に ASSETS へ問い合わせず、root shell を per-binding isolate cache から返すようにした。これにより/products、/search、商品詳細、 account / cart / quick-order / checkout 系で共通して発生する asset miss + fallback shell 取得の二重化を避ける。回帰はapps/storefront/src/worker-entry.test.tsのreuses the root SPA shell for authenticated browse navigationsで検知する。 /products/$slugは detail 本体の query 成功後に CMS と recommendation を待つため、 補助データ fetch は直列にしない。apps/storefront/src/lib/products-page-helpers.tsで Promise を先に起動して並列待ちにする。/products/$slugの価格は detail / browse card とも Vendure Shop API のProductVariant.priceWithTax/currencyCodeを正本にする。detail 専用 API でも browse card と同じ価格 source を返し、軽量化のために別の「軽量価格」を作らない。 内訳は同じ意味内容を維持し、必要なら hover 後に追加取得する。価格の本体表示を sessionStorage や Worker cache だけで長時間固定しない。- 商品詳細の同一タブ短時間再訪は
sessionStorageのritsubi:product-detail:${slug}を 30 秒 TTL で吸収する。これは同一 browsing session の重複 navigation を潰すための補助であり、価格・可視性の長期正本ではない。 TTL を延ばす場合は価格変更と可視性変更の stale 表示リスクを先に評価する。 - local で
just deploy-storefront-staging/build-vendure-imageが agent worktree を Nx project graph に拾って失敗する場合は、.nxignoreで.claude/worktrees/**を除外する。 - Vendure staging deploy が遅い場合、Storefront 反映や Dashboard deploy ではなく
scripts/ops/build-vendure-image.shの Docker image build/push が詰まっていることがある。just manual-deploy-vendure staging "" "" falseでもimage_refが空なら backend image build は残るため、まずpgrep -af 'docker buildx build|build-vendure-image|manual-deploy-vendure'で in-flight process を確認する。 - local の Vendure image build は registry cache を read-only に使い、local filesystem cache は
既定
BUILDX_LOCAL_FS_CACHE_MODE=min。~/.cache/ritsubi/vendure-buildcacheが肥大化している 場合、mode=maxの cache export I/O が待ち時間を支配し得るため、通常 deploy ではmode=minのままにする。中間 layer まで local に残したい調査時だけBUILDX_LOCAL_FS_CACHE_MODE=maxを明示する。 - CI の Vendure image build は
registry.fly.io/<app>:vendure-buildcacheと GitHub Actions cache (type=gha,scope=vendure-image) を併用する。R2 / GitHub artifact は BuildKit cache 本体ではなく、vendure-image-build-<env>-<sha>artifact として保存される build log /.summary.jsonの診断に使う。 - 実 deploy の image build log は local では
${TMPDIR:-/tmp}/ritsubi-vendure-image-build/<env>-<sha>-<timestamp>.log、CI ではvendure-image-build-<env>-<sha>artifact に残る。.summary.jsonのslowestStepsを見て、pnpm install、COPY、exporting cache、pushing imageのどれが支配的かを切り分ける。 - cart smoke の候補選定は UI-only fallback を全候補へ強制しない。まず API で activeOrder に積める slug を見つけ、UI fallback は API 不可時だけに使う。
- production へ promoted image を載せる前に、release phase migration が
staging 固有 schema を前提にしていないか確認する。今回の
FixCustomFieldStorageAndNullabilityDrift20260508142000はorder.customFieldsPaperconsentrequiredが無い production で落ちたため、 column existence check を入れて idempotent にした。 - Cloudflare Worker を OpenNext から Vite へ移行した環境では、過去の Durable
Object class export を消すと
wrangler versions uploadがcode: 10064で失敗する。apps/storefront/worker.jsに互換 stub export を残し、明示的な delete-class migration を設計するまでは削除しない。 wrangler login済みでもenv-status/cloudflare:deploy:*/cloudflare-verify-storefront/cloudflare-deployments-statusが/accounts/a5e9...のAuthentication error [code: 10000]/ 403 で落ちる場合、OAuth user が対象 account に所属していない可能性がある。staging / production の緊急反映ではCLOUDFLARE_ALLOW_TOKEN_FALLBACK=1 just cloudflare-deploy-storefront <env>、CLOUDFLARE_ALLOW_TOKEN_FALLBACK=1 just cloudflare-verify-storefront <env> token、CLOUDFLARE_ALLOW_TOKEN_FALLBACK=1 just cloudflare-deployments-status <env>で Secrets Manager token fallback を明示的に使う。ただし恒久対応は Cloudflare account membership / Wrangler authentication の修正であり、fallback を黙って既定化しない。- 同一 commit SHA の Vendure image を staging へ再反映する場合、registry 既存 image を
そのまま再利用すると build artifact が更新されない。schema / plugin 変更を同じ SHA で
反映する必要がある時は
VENDURE_IMAGE_BUILD_IF_EXISTS=rebuildを使い、反映後にjust env-status staging --format jsonの VendurebuildTimeが新しくなったことを確認する。 - support 導線の canonical slug は CMS の実 publish 状態を正本にする。
production では
/support/logisticsの published page は/pages/logisticsであり、/pages/direct-shipping-guideへ寄せると global error page へ落ちる。 - production
price-normalprincipal の cart smoke は、/productsの先頭に 見える visible listing がsmile-*のOUT_OF_STOCKSKU 偏重だと browser-only 購入導線を成立させられない。helper 側ではstockLevel !== OUT_OF_STOCKの variant を先に選ぶが、それでも cartable product が見つからない場合は code bug ではなく production catalog / visibility / inventory の運用問題として扱う。
2026-05-20 Sentry N+1 (#673) 対応メモ¶
- Sentry の
b2b-commerce-vendureでVisibleProductsForBrowseresolver が N+1 として 6 issue 検出。実体は単一クエリ N+1 ではなく、batch ループの 反復ごとに同形 query (search → validateIds → fallback variant find → detail variant find → productService.findByIds) が連続して走るパターンが Sentry の heuristic に拾われたもの。 - 一次対応として
visibility-shop.resolver.tsの in-process cache を 匿名ユーザーへ拡張した (commit591a03a89)。CustomerVisibilityServiceも "anonymous" 軸で context を共有しているため、channel + input + relations が同一なら decision は決定的で correctness は維持される。 - 二次対応は次の三方向で設計検討中 (本ドキュメント末尾の "#673 残り打ち手"
節を参照)。コミット単位で順に適用し、その都度 staging で
window.__ritsubiProductsPageMetricsと Sentry の batch metrics (visible_products_browse.batch_total等) を確認する。 - (a) visibility filter を search クエリ層に push down してループ自体を排除
- (b) cache TTL / LRU の調整による hit rate 改善
- (c) batch ループの round-trip 短縮 (representative variant validate と fallback fetch の sequencing 最適化)
2026-05-20 Sentry Slow DB Query (#674) / Failed to fetch (#675) 対応メモ¶
TypeORM の DISTINCT subselect が原因の Slow DB Query¶
Sentry の Slow DB Query (#674) の半数以上が以下の query 形に集中していた:
SELECT DISTINCT "distinctAlias"."<Entity>_id"
FROM (SELECT ... 多数の LEFT JOIN ...) "distinctAlias"
WHERE "<Entity>"."id" = $1
ORDER BY "<Entity>_id" ASC LIMIT 1
代表 issue:
B2B-COMMERCE-VENDURE-Q(count 949): ProductVariant の DISTINCT subselectB2B-COMMERCE-VENDURE-Z(count 273): Product の DISTINCT subselectB2B-COMMERCE-VENDURE-25(count 395): Collection の DISTINCT subselect
原因: TypeORM の find / findOne で M:N relation (ProductVariant.collections、Customer.groups 等) を含めると、行多重を deduplicate するために DISTINCT subselect を伴う query を生成する。単一 ID 検索 (WHERE id = $1 LIMIT 1) の場合、まず全 join を materialize してから DISTINCT で 1 行に絞るため極端に遅い (~300ms+)。
対処: TypeORM 0.3.x の relationLoadStrategy: "query" を指定すると、本体を取得 → 各 relation を別 query で取得という戦略になり DISTINCT subselect が発行されない。
const variant = await repo.findOne({
where: { id },
relations: ["product", "collections"], // M:N 含む
relationLoadStrategy: "query", // ← これを足す
});
適用済み: commit 4d3784dbb
packages/plugins/src/rule-engine/commercial/services/commercial-rule.service.tspackages/plugins/src/rule-engine/visibility/customer-visibility.service.tspackages/plugins/src/rule-engine/visibility/visibility-shop.resolver.ts
今後の追加検出パターン: EXPLAIN ANALYZE で query plan が Hash Right Join + HashAggregate (key: <Entity>_id) を含み、distinctAlias 名のサブクエリが見えたら同じ pattern。relationLoadStrategy: "query" を試す。
Sentry SDK 10 の Metrics は experimental flag 必須¶
Sentry.metrics.count / distribution / gauge は SDK 10 では _experiments.enableMetrics: true を init config に渡さない限り silent no-op。apps/vendure-server/src/observability/sentry-config.ts で有効化済み (commit fea099499)。
別の Vendure server を立てる場合、または @sentry/cloudflare 等別 SDK で metrics を使う場合は同じ flag が必要かどうかを確認する。
Storefront fetch retry 戦略 (#675)¶
vendureFetch (apps/storefront/src/lib/vendure-fetch.ts) は GraphQL query (副作用なし) に限り 1 回までリトライする (NETWORK_RETRY_MAX_ATTEMPTS = 2、NETWORK_RETRY_BASE_DELAY_MS = 250 * attempt)。
リトライ対象は error.name === "TypeError" (= ブラウザの fetch network failure) のみ。AbortError や JSON parse 等はリトライしない。リトライ発火は storefront.graphql_network_retry_count tag で観測。
mutation はリトライしない (副作用の二重実行を避けるため)。将来 idempotency key を導入したら mutation も拡張可能。
実装上の注意¶
- 顧客可視性に依存する
/productsは shared HTTP cache で解決しない。 - Cookie / Authorization に依存する route / fetch は
no-storeを維持する。 - 許容される cache は、同一 channel / input 内の短時間 process-local
duplicate absorption に限定する。subject 軸は
user:<activeUserId>とanonymousの二系統で、authenticated 結果が anonymous bucket に 混入しないこと、anonymous 結果が customer-specific visibility を仮定 しないことを resolver 側で保証する。 - Header / Navigation の
prefetch={false}は、クリック後の遷移を止めるものではない。初回表示中の不要な background data prefetch だけを止める。 - Storefront / Cloudflare の互換性問題を外部ライブラリ patch で回避しない。
- TypeORM
find/findOneで M:N relation を含める場合は 必ずrelationLoadStrategy: "query"を指定する。指定漏れは Sentry の Slow DB Query (#674) を量産する。M:N 判定は entity 定義で@ManyToManyを持つ relation (ProductVariant.collections、Customer.groups、Order.channelsなど) が対象。 - storefront の GraphQL
queryはvendureFetch経由で自動的にネットワーク リトライされる。リトライしてほしくない query はoptions.captureFailuresをfalseにして自前で扱うか、mutation として実装する。
DB インデックス最適化 トラブルシューティング¶
DB インデックス最適化(2026-02-12 デプロイ)後の商品検索パフォーマンス問題は、以下の手順で診断します。
1. Migration 適用状態の確認¶
# Staging proxy を起動
just proxy-postgres env=prod port=15433 &
# Migration 実行状態を確認
export PGPASSWORD=$(aws secretsmanager get-secret-value \
--secret-id b2b-ecommerce/staging/vendure-db-password \
--region ap-northeast-1 \
--query SecretString --output text)
psql -h localhost -p 15433 -U postgres -d vendure_staging -c "
SELECT name FROM migrations
WHERE name LIKE '%Index%1777%'
ORDER BY name;
"
# Expected output:
# - Add_visibility_lookup_indexes_1777874700000
# - Add_search_trigram_indexes_1777918200000
2. Index 実装状態の確認¶
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- FK lookup indexes
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'IDX_%resourceSetId%'
OR indexname LIKE 'IDX_%subjectSetId%'
ORDER BY tablename, indexname;
-- Search trigram indexes
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'IDX_search_index_item_enabled%trgm'
ORDER BY indexname;
-- Browse composite index
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'IDX_search_index_item_enabled_channel_language%'
ORDER BY indexname;
EOF
3. EXPLAIN ANALYZE で query plan を確認¶
問題: 商品検索が遅い場合
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- Browse query の execution plan を測定
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT s.id, s.productId, s.productName, s.productVariantId, s.priceWithTax
FROM search_index_item s
WHERE s.channelId = 'channel-1'
AND s.languageCode = 'en'
AND s.enabled = true
ORDER BY s.createdAt DESC
LIMIT 10;
-- 期待結果: Index Scan (Seq Scan ではない)
-- 確認項目:
-- - Plans[0].Plan.Node Type が "Index Scan" または "Index Only Scan"
-- - Plans[0].Plan."Rows Removed by Filter" が小さい(selectivity が高い)
-- - Plans[0].Execution."Buffers"."Shared Hit" が大きい(キャッシュヒット率が高い)
-- 検索キーワードでの trigram search の plan
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT s.id, s.productId, s.productName
FROM search_index_item s
WHERE s.enabled = true
AND s.productName ILIKE '%keyword%'
LIMIT 10;
-- 期待結果: Index Scan using IDX_search_index_item_enabled_productName_trgm
EOF
4. Index サイズと効率を測定¶
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- FK lookup indexes のサイズ
SELECT
schemaname, tablename, indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_indexes i
JOIN pg_class c ON c.relname = i.indexname
WHERE (i.indexname LIKE 'IDX_%resourceSetId%'
OR i.indexname LIKE 'IDX_%subjectSetId%'
OR i.indexname LIKE 'IDX_search_index_item_enabled%')
ORDER BY pg_relation_size(indexrelid) DESC;
-- 部分インデックスの効率(WHERE enabled=true の削減率)
SELECT
tablename,
COUNT(*) AS total_rows,
(SELECT COUNT(*) FROM search_index_item WHERE enabled = true) AS enabled_rows,
ROUND(100.0 * (SELECT COUNT(*) FROM search_index_item WHERE enabled = true)
/ COUNT(*), 1) AS coverage_percent
FROM search_index_item
GROUP BY tablename;
EOF
5. よくある問題と対処¶
| 問題 | 原因 | 確認方法 | 対応 |
|---|---|---|---|
| Migration が実行されていない | TypeORM 起動時エラー または migration 順序問題 | migrations テーブルで 1777* が存在するか確認 |
1. Vendure ログを確認 2. Migration lint を再実行 3. Fly restart |
| Index は存在するが遅い | Query plan が Seq Scan を使用 | EXPLAIN で Node Type を確認 | 1. Column statistics を更新: ANALYZE search_index_item 2. Planner threshold を確認 |
| Partial index なのに大きい | WHERE enabled=true フィルタが効かない | pg_indexes で indexdef に WHERE 句があるか確認 |
Index 定義を migration で確認、DROP/CREATE で再構築 |
| Trigram search が遅い | pg_trgm extension がロードされていない | SELECT extname FROM pg_extension WHERE extname='pg_trgm' |
Extension が存在しない場合は Application startup error と一緒にログに出る |
6. Diagnostics SQL スクリプト¶
完全な診断を実行する場合:
psql -h localhost -p 15433 -U postgres -d vendure_staging \
-f apps/vendure-server/scripts/products-search-diagnostics.sql
このスクリプトが提供する:
- Migration drift 検出
- FK column gap 監査
- Index 存在確認
- Partial index 効率測定
- EXPLAIN テンプレート
7. Rollback 手順¶
問題が深刻な場合の緊急対応:
# 1. 最新の migration を確認
psql -h localhost -p 15433 -U postgres -d vendure_staging -c \
"SELECT name FROM migrations ORDER BY id DESC LIMIT 5;"
# 2. Index を個別に削除(最後に追加した順に削除)
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- Search trigram indexes を削除
DROP INDEX IF EXISTS IDX_search_index_item_enabled_productName_trgm;
DROP INDEX IF EXISTS IDX_search_index_item_enabled_productVariantName_trgm;
-- ... (他の index)
-- FK lookup indexes を削除
DROP INDEX IF EXISTS IDX_resource_set_item_entity_resourceSetId;
-- ... (他の index)
EOF
# 3. Migration レコードを削除(最後に追加した 2 つ)
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
DELETE FROM migrations WHERE name LIKE 'Add_search_trigram_indexes%';
DELETE FROM migrations WHERE name LIKE 'Add_visibility_lookup_indexes%';
EOF
# 4. App を再起動(migrations テーブルから削除されたため、TypeORM は再度実行しない)
just deploy-fly staging
パフォーマンス期待値¶
DB 最適化導入後の期待値:
| メトリック | 最適化前 | 最適化後 | 期待 |
|---|---|---|---|
| FK lookup seq_scan | 大量 | 0 | 100% 削減 |
| Index utilization | 3% | 95%+ | ✅ 圧倒的改善 |
| Browse query cost | 1000+ | 50-100 | ✅ 10-20x 高速化 |
| Partial index size | full | <20% | ✅ Storage 削減 |
2026-05-28 商品カタログ Vendure API コール削減 (PR: b64b254b9)¶
superseded: 本節の 48→200 page-size 拡大は、後述「2026-05-28 per-variant pricing N+1 除去 / 単一フェッチ / L2 browse cache」の 単一フェッチ化(
take未指定で全件を 1 レスポンス) に置き換えられた。現行の正は単一フェッチ。以下は経緯として残す。
変更内容¶
GROUPED_COLLECTION_PRODUCTS_PAGE_SIZE を 48 → 200 へ拡大し、while ループの
HTTP 往復を削減した。あわせて applyProductsCatalogFetchSentryScope を追加し、
API コール数と取得商品数を Sentry タグで観測できるようにした。
- コミット:
b64b254b9 feat(storefront): increase product page size and add catalog fetch observability - 変更ファイル:
apps/storefront/src/routes/products.tsx—GROUPED_COLLECTION_PRODUCTS_PAGE_SIZE: 48 → 200、テレメトリ呼び出し追加apps/storefront/src/lib/observability/products-browse.ts—applyProductsCatalogFetchSentryScope追加
期待効果¶
| コレクション商品数 | 変更前(APIコール数) | 変更後(APIコール数) |
|---|---|---|
| 〜100商品 | 3回(最大 ~600ms) | 1回(最大 ~250ms) |
| 〜150商品 | 4回(最大 ~800ms) | 1回(最大 ~350ms) |
| 〜200商品 | 5回(最大 ~1000ms) | 1回(最大 ~400ms) |
| 200商品超 | N回 | ⌈商品数/200⌉回 |
Sentry でのコール削減確認方法¶
デプロイ後、Sentry (b2b-commerce-storefront) の Transaction 詳細で以下のタグを確認する。
storefront.products.catalog_fetch_call_count = 1 ← mesoceutical は期待値 1
storefront.products.catalog_total_products = <実際の商品数>
storefront.products.page_size = 200
catalog_fetch_call_count > 1 が引き続き観測される場合は、対象コレクションの
商品数が 200 を超えている(または Worker → Vendure のページサイズが意図通り
渡っていない)ため、以下で実際の商品数を確認する。
# staging の /products?collection=mesoceutical を叩いて totalItems を確認
curl -sS -H 'content-type: application/json' \
--data '{"query":"query { search(input: { collectionSlug: \"mesoceutical\", groupByProduct: true, take: 1 }) { totalItems } }"}' \
https://commerce-staging.ritsubi-platform.com/commerce/shop-api \
| python3 -m json.tool
totalItems が 200 超の場合は GROUPED_COLLECTION_PRODUCTS_PAGE_SIZE のさらなる
拡大を検討する(ただし 400 が Vendure 側の PRODUCTS_BROWSE_MAX = 400 の上限)。
catalog fetch 公式 smoke¶
staging の /products?collection=mesoceutical のコール削減を計測する。
cd apps/storefront
just storefront-e2e-real staging tests/e2e/smoke/products-page-performance.real.spec.ts --project=chromium
smoke の catalog_fetch_call_count は現時点ではブラウザから直接観測できないため、
Sentry Transaction で確認する。ページ表示速度は window.__ritsubiProductsPageMetrics.durationMs
で引き続き計測可能。
2026-04-30 staging の確認結果¶
- Storefront Worker version:
85d4fca8-4bab-46d7-8043-f0e477aa0b4a - Vendure staging: started
1/ total1 - 公式 smoke:
361,319,330,306,333ms、median330ms、max361ms - 追加 10 サンプル: browser first visible median
256ms、max985ms
この状態で /products の first visible は 1s 程度の範囲内に収まっている。
2026-05-28 per-collection take=1 廃止・Vendure browse キャッシュのユーザー拡張¶
背景と問題¶
2026-05-28 の staging 計測で、初回 first visible metric が 7354ms と判明した。 2026-04-30 計測(330ms)との差異はユーザー認証状態と機能追加の複合。
ボトルネック1: sidebarChromePromise ブロッキング(解消済み: a65bc53dc)¶
ProductsResults コンポーネントが use(sidebarChromePromise) で suspend し、
resolveBrowseGroupsWithVisibleProducts(12 並列 take=1 API コール)の完了まで
商品グリッドを表示できなかった。resolveProductsResultsCollectionGroups(fast
path)を新設し、ProductsResults を先行描画できるよう切り替えた。
ボトルネック2: Sidebar・Nav の per-collection take=1 チェック¶
Sidebar(5 コレクション)と SiteShell Nav(6 コレクション)が
resolveBrowseGroupsWithVisibleProducts 経由で各コレクションに
GetVisibleProductsByCollectionSlug(take=1) を並列発行していた。
Batch 2 (sidebar): +6499–6771ms(browser responseEnd)
Batch 3 (site-shell): +3807–6109ms(browser responseEnd)
これらは商品グリッドをブロックしないが、sidebar と nav の描画を大幅に遅延させた。
修正: resolveBrowseGroupsWithVisibleProducts を廃止し、
既存の GetVisibleCollectionIds の結果(1回のみ発行)を
filterCollectionBrowseGroupsByVisibleIds でそのまま使うように切り替えた。
apps/storefront/src/lib/products-page-helpers.ts— Sidebar の Batch 2 削除apps/storefront/src/lib/site-shell-helpers.ts— SiteShell Nav の Batch 3 削除apps/storefront/src/routes/products.tsx— catalog-root redirect の Batch 削除
ボトルネック3: 認証済みユーザーに対する Vendure browse キャッシュ欠落¶
visibleProductsForBrowse resolver が認証済みユーザー(subject === "user")に
対して 30秒 TTL のキャッシュを完全バイパスしていた。Anonymous ユーザーは2回目以降
が高速だが、認証済みユーザーは毎リクエストでフル DB 検索・variant 可視性チェックを実行。
Node.js headers 受信から browser response body 完了まで 2607ms のダウンロード 遅延が観測された(大きなレスポンスボディ)。
修正: resolveVisibleProductsBrowseCacheDescriptor で customer group IDs を
キャッシュキーに含め、認証済みユーザーも同一グループ構成であれば 30秒 TTL の
キャッシュを利用できるようにした。
packages/plugins/src/rule-engine/visibility/visibility-shop.resolver.tsresolveVisibleProductsBrowseCacheDescriptor: customer group IDs をキーに追加readVisibleProductsBrowseCache/writeVisibleProductsBrowseCache:subject === "user"の早期リターン削除
キャッシュキー構造:
{
"cacheSubject": "user",
"channelId": "<channelId>",
"revision": null,
"customerGroupIds": ["<groupId1>", "<groupId2>"],
"input": { "collectionSlug": "...", ... },
"relations": [...]
}
getCustomer(ctx) は WeakMap<RequestContext> per-request キャッシュ付き(追加
DB 往復なし)。グループ構成が同一の顧客間でキャッシュを共有し、
PRODUCTS_BROWSE_CACHE_MAX_ENTRIES = 128 上限で LRU 的に管理。
計測比較¶
| フェーズ | 修正前(browser responseEnd) | 修正後(予測) |
|---|---|---|
| Auth check | +517ms | +517ms(変わらず) |
| GetVisibleCollectionIds | +925ms | +925ms |
| GetVisibleProductsForBrowse | +5479ms(ボディ 2607ms含む) | ~500ms(キャッシュHit時) |
| Sidebar per-collection(Batch2) | +6499–6771ms | 削除 |
| SiteShell per-collection(Batch3) | +3807–6109ms | 削除 |
| FIRST PRODUCT VISIBLE | 7354ms | ~1500ms 予測 |
トラブルシュート: 修正後も遅い場合¶
- Vendure browse cache が効いていない
Sentry で visible_products_browse.cache.outcome = miss が連続する場合、
customer group 構成が毎リクエストで変わっている可能性がある。
# 対象ユーザーの customer group を確認
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl ssh console -a ritsubi-ecommerce-staging -C \
"node -e \"const {DataSource} = require('typeorm'); ...\"" # DB 直接確認
- per-collection take=1 が復活している
resolveBrowseGroupsWithVisibleProducts が再度 import・呼び出しされていないか確認。
rg "resolveBrowseGroupsWithVisibleProducts" apps/storefront/src/
このコマンドで visible-browse-collections.ts 本体以外に結果が出る場合は修正が崩れている。
- Vendure machine が cold start している
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl machine list -a ritsubi-ecommerce-staging --json
stopped machine がある場合は最初に確認すること」の手順で warm 化する。
2026-05-28 ブラウザ側 GetCollectionBrowseRoots / GetVisibleCollectionIds キャッシュ欠落¶
背景と問題¶
Vendure browse cache(browse products)と browser sessionStorage(visible products)が 機能した後も、measure run の FIRST PRODUCT VISIBLE が 5355–5830ms のままだった。
Fly.io ログで browse cache hit(55–77ms)が確認でき、browser sessionStorage も 機能している(measure run で Vendure に browse call が到達しない)にもかかわらず遅い原因。
ボトルネック4: GetCollectionBrowseRoots のブラウザ未キャッシュ¶
getCollectionBrowseGroupsForBrowser() は Cloudflare Worker 上で
withCmsSharedCache によりキャッシュされるが、SPA(ブラウザ)では
typeof window !== "undefined" のとき withCmsSharedCache がキャッシュをスキップし
毎ナビゲーションで Vendure に到達していた。
measure run での計測例:
navStart + 535ms(auth check完了) → GetCollectionBrowseRoots 開始
GetCollectionBrowseRoots: ~1517ms → navStart + 2052ms で完了
修正: fetchCollectionBrowseRootsForBrowser() にブラウザ向け2段キャッシュを追加。
- sessionStorage(
ritsubi:browse-roots:v1): TTL 300s、前回ナビゲーション からの持ち越し用。公開データのため認証キーなし。 - module-level singleton(
browseRootsInMemoryCache): TTL 300s、同一SPA セッション内の重複 fetch dedup(TanStack Router loader と site-shelluseEffectが同一タブで2回以上呼ぶケースに対応)。
ボトルネック5: GetVisibleCollectionIds のブラウザ未キャッシュ¶
fetchVisibleCollectionIds() はブラウザ SPA で毎ナビゲーション Vendure に到達し、
loadVisibilityContext(DB 往復含む)を実行していた。
measure run での計測例:
navStart + 535ms(auth check完了) → GetVisibleCollectionIds 開始
GetVisibleCollectionIds: ~3018ms → navStart + 3553ms で完了
use(resultsCollectionGroupsPromise) が collectionGroupsPromise と
visibleCollectionIdsPromise の両方を await するため、後者の 3018ms が
ProductsResults コンポーネントのレンダリングブロックに直結していた。
修正: fetchVisibleCollectionIds() にブラウザ向け2段キャッシュを追加。
- sessionStorage(
ritsubi:visible-coll-ids:v1:{sessionKey}): TTL 60s、 セッション cookie + vendure token をキーに含めて顧客ごとに独立したキャッシュ。 - module-level Map(
visibleCollectionIdsInMemoryCache): TTL 60s、 同一SPA セッション内の重複 fetch dedup。
ボトルネック6: PRODUCTS_SITE_SHELL_FALLBACK_DELAY_MS が短すぎる¶
root-layout.tsx の fallback delay が 2500ms のため、first product visible
イベントが発火する前にタイムアウトし、site-shell(GetCollectionBrowseRoots ×2、
GetTrackingTagSettings 等)の6件 API コールが products API コールと競合していた。
measure run での計測例:
navStart + ~1490ms(React mount) + 2500ms = ~3990ms で site-shell fallback 発火
→ site-shell の6件 shop-api コールが products 表示より先に発火
修正: PRODUCTS_SITE_SHELL_FALLBACK_DELAY_MS を 2500ms → 10000ms に延長。
browse cache hit 時は FIRST PRODUCT VISIBLE が ~1500ms 以内になるため、
fallback タイマー到達より先に ritsubi:products-first-visible イベントが発火し、
site-shell が products 表示後に非同期ロードされる。
ボトルネック7: productService.findByIds の JOIN 爆発(解消済み: fetchBrowseProductsByIds)¶
Vendure コアの productService.findByIds は relations 引数を完全無視し、
常に 5 relations(featuredAsset, assets, channels, facetValues, facetValues.facet)
を JOIN strategy でロードする実装バグを持つ:
// Vendure core の実装バグ(product.service.js)
.setFindOptions({ relations: (relations && false) || this.relations });
// (relations && false) は常に false → 渡した relations を無視して this.relations を使用
これにより 16 商品の hydration で ManyToMany JOIN(channels × facetValues)が爆発し、 staging で 13.4 秒かかっていた:
"durationsMs": {
"total": 16642,
"search": 161,
"fetchVariants": 2080,
"validateVisibility": 2080,
"hydrateProducts": 13362
}
修正: fetchBrowseProductsByIds プライベートメソッドを追加し、productService.findByIds
を完全に置き換え:
relationLoadStrategy: "query"を使用(JOIN → relation ごとに個別 SELECT)- ブラウズカードに必要な 3 relations のみロード:
featuredAsset,facetValues,facetValues.facet - 不要な
assets(OneToMany, 表示不要)とchannels(ManyToMany, 高コスト)を除外 translationsはeager: trueのため自動ロード(変更なし)- channel フィルタリングは
leftJoin("product.channels", "channel")+andWhereで維持
期待される改善後のタイムライン(warm cache)¶
navStart + 0ms : ナビゲーション開始
navStart + ~535ms : auth-session check 完了
navStart + ~540ms : GetCollectionBrowseRoots → sessionStorage HIT(<10ms)
navStart + ~540ms : GetVisibleCollectionIds → sessionStorage HIT(<10ms)
navStart + ~540ms : fetchCollectionCatalogSearchResult → sessionStorage HIT(<10ms)
navStart + ~700ms : React レンダリング + components mount
navStart + ~800ms : FIRST PRODUCT VISIBLE
navStart + ~800ms : ritsubi:products-first-visible イベント発火
navStart + ~800ms : getSiteShellModelVite() 開始(non-blocking)
計測比較¶
| フェーズ | 修正前(measure run) | 修正後(warm cache) |
|---|---|---|
| auth-session check | +535ms | +535ms(変わらず) |
| GetCollectionBrowseRoots | +535ms + 1517ms | +535ms + <10ms(HIT) |
| GetVisibleCollectionIds | +535ms + 3018ms | +535ms + <10ms(HIT) |
| fetchCollectionCatalogSearchResult | +535ms + ~77ms(HIT) | +535ms + ~77ms(HIT) |
| site-shell fallback 発火 | navStart +3990ms | products 表示後(+800ms以降) |
| FIRST PRODUCT VISIBLE | 5355–5830ms | ~800ms(目標) |
トラブルシュート: キャッシュ修正後も遅い場合¶
- sessionStorage キャッシュが機能していない(cold 初回アクセス)
初回アクセス(browser sessionStorage 空)では cold fetch になる。 warmup run → measure run の E2E 2段構成でのみ目標を達成できる。 実ユーザーは2回目以降のナビゲーションから恩恵を受ける。
- sessionStorage キャッシュキーが一致しない
GetVisibleCollectionIds のキャッシュキーは session cookie + vendure token。
ユーザーがログインし直すと TTL 内でもキーが変わり cold fetch になる。
ログイン直後の初回ロードは cold fetch を許容する設計。
- GetCollectionBrowseRoots sessionStorage キャッシュを確認
ブラウザ DevTools → Application → Session Storage で
ritsubi:browse-roots:v1 の存在と expiresAt を確認する。
- GetVisibleCollectionIds sessionStorage キャッシュを確認
ブラウザ DevTools → Application → Session Storage で
ritsubi:visible-coll-ids:v1: で始まるキーの存在と expiresAt を確認する。
- site-shell fallback が products 表示前に発火している
DevTools の Network タブで GetCollectionBrowseRoots が2回発火しているか確認。
1回目が products loader から、2回目が site-shell の fallback から発火するケースは
PRODUCTS_SITE_SHELL_FALLBACK_DELAY_MS が足りない(Vendure 障害等で products 表示が遅延)。
- auth-session check が毎回ブロッキング
/api/auth-session が毎ナビゲーションで 300–535ms かかる構造は変わっていない。
この遅延は仕様(Vendure session validity を server-side で確認する設計)。
改善候補: session cookie の存在 + TTL 30s の短期 sessionStorage キャッシュ(未実装)。
- PR #586: DB search optimization indexes と query rewrites
- Migrations:
1777874700000_add_visibility_lookup_indexes.ts(6 indexes) - Migrations:
1777918200000_add_search_trigram_indexes.ts(5 indexes) - Status: Staging に自動デプロイ、migrations auto-execute
- Expected: seq_scan 100% 削減、browse query 5-10x 高速化
#673 残り打ち手 (2026-05-20 設計)¶
visibleProductsForBrowse の Sentry N+1 検出に対する追加施策。匿名 cache
拡張 (commit 591a03a89) で同時 anonymous 再走は抑制したが、cache miss
時の resolver 内部 N+1 形パターンは残るため、以下の打ち手を順に検討する。
打ち手 (a): Visibility filter の search push-down¶
プッシュダウンの狙い¶
batch ループそのものを排除し、search 1 回 + hydrate 1 回の 2-stage 構成に する。現状 resolver は「search で広く取り、可視 variant を絞り込み、 不足したら再 search」というループで visibility filter を application 層に持っているため、低 visibility ratio で iter が膨らむ。
プッシュダウンの設計¶
- DefaultSearchPlugin の
SearchIndexItemに visibility 判定済みの bucket (customer group id / anonymous flag / policy id) を index 段階で 焼き込む。実装はapps/vendure-serverの Search reindex 経路を改修。 - Shop API の検索クエリでは
RequestContextの customer / channel から 対応する bucket を組み立ててwhereに push する。 - visibility policy が変わったときの再 index は既存 reindex worker を
再利用するが、policy 変更 hook (
AccessPolicyService.update*) に reindex enqueue を追加する。
プッシュダウンの開発コスト¶
- 高: index schema 変更、reindex 経路改修、policy 変更時の整合性 運用 (再 index 完了までは古い bucket が残る)。
- visibility policy の表現力 (時刻条件、customer custom field の 動的評価) を index 段階に落とせない条件があると、application 層の 二段絞り込みが必須になり N+1 が部分的に残る。
プッシュダウンの性能評価¶
最も効果は大きいが工数も最大。条件 (時刻 / 動的 field) の対応可否を 先に reading で確定させてから着手する。次節 (b)(c) で十分な場合は 保留可能。
打ち手 (b): Cache TTL / LRU の調整¶
キャッシュ調整の狙い¶
cache hit rate を上げて batch ループ実行回数自体を減らす。
キャッシュ調整の設計¶
- 現状: TTL
15_000ms、entries128。 - 観測: 同一 channel × 同一 (collection, term, take, skip) の組合せは /products の page 単位で数十種程度に収束する想定。anonymous bucket が 1 channel 1 input で 1 entry を消費するため、128 entries は十分。
- 提案:
- TTL を 30s に延長 (in-process なので reindex / policy 変更時の ズレ許容を 30s 以内に留める)。匿名 bucket の正当性は (a) 未導入 時は既に 15s ズレを許容しているのと同質。
- cache hit / miss / eviction を
countMetricで出して staging で hit rate を確認する。hit rate がそもそも低ければ TTL 延長は 効果薄なので (c) を優先する。 - 既存テストは TTL 値に依存しないため回帰なし。新規 metric は
visible_products_browse.cache.{hit,miss,evict}で追加。
キャッシュ調整の運用コスト¶
- 低。実装は定数調整と metric 追加のみ。
- リスクは「policy 変更後の 30s 古い結果」だが、現運用で in-process cache 15s を許容しているので問題なし。
キャッシュ調整の評価¶
最初に手を付ける。observability の追加で (a) 着手判断にも使える。
打ち手 (c): Representative variant validate と fallback fetch の並列化¶
キャッシュ調整の狙い¶
batch 1 iter あたりの round-trip 数を減らす。現状 1 iter は
fulltextSearchService.searchvisibilityService.validateIds(代表 variant ids)- (fallback あれば)
ProductVariant.find({ product.id IN fallback }) filterVisibleVariantItems(fallbackVariants)ProductVariant.find({ id IN page visible })(詳細 hydrate)productService.findByIds(page products)
の 6 直列。Sentry が見ているのはこの 6 連が iter ごとに繰り返される パターン。
キャッシュ調整の設計¶
- 2 と 3 は依存関係がない (どちらも search 結果からの派生で、
fallback 対象は productIds の subset)。
Promise.allで並列化し て 1 iter あたり 1 round-trip 削減。 - 5 と 6 も並列化可能 (両者とも paged の確定 ids に依存)。
- ただし fallback fetch は「2 の結果次第で skip できる」最適化 経路でもあるため、無差別に並列化すると不可視商品の variant fetch を投機的に行う副作用が出る。productIds 全件が visible representative で覆われた batch ではコスト増になる。
- 対策: search 結果から「representative visible で覆える ratio」が
high の場合 (>= 0.8) は従来通り順次、低い場合だけ並列化する
動的 strategy にする。観測には (b) の cache metric と
visible_products_browse.batch_totalを併用。
キャッシュ調整の運用コスト¶
- 中。ロジック変更は局所 (resolver 内 1 ブロック) だが、副作用 ある投機 fetch を入れるので contract test (代表で覆える batch は fallback fetch しない) を残す必要がある。
キャッシュ調整の評価¶
(b) で observability を整えた後、cache miss rate と batch 数の 実測を見て採否を決める。実測で 1 request あたり iter 数が常に 1 なら 不要、2 以上が常態なら採用。
進め方¶
- Step 1: (b) を実装し staging に out。
visible_products_browse.cache.*とvisible_products_browse.batch_totalを 1 週間観測。 - Step 2: hit rate と iter 分布から (c) 採否を判断。採用なら contract test 同梱で実装。
- Step 3: (b)(c) を入れても Sentry N+1 が残る、かつ business が /products の P99 短縮を要求する場合のみ (a) に着手。policy 表現の制約を先に reading で確認する。
2026-05-28 Staging Dogfooding 発見問題 (ISSUE-001〜003)¶
staging dogfoodingで発見・修正した3件の問題を記録する。
ISSUE-001 (Critical): auth ページが 10〜30 秒間空白¶
症状¶
/auth/login, /auth/reset-password, /auth/password-reset, /auth/temporary-login,
/auth/temporary-setup でページが 10〜30 秒ホワイトスクリーンになる。
根本原因¶
各 auth route の loader() が fetchStoreSettings() をブラウザ側で await するが、
fetch(endpoint, fetchOptions) にタイムアウトがなかった。
WordPress CMS が HTML エラーページを返すと最初のフェッチは即失敗するが、 フォールバックフェッチがブラウザの TCP デフォルトタイムアウト(30秒以上)までハングした。
修正内容¶
- 各 auth route loader に 2000ms Promise.race タイムアウトを追加
auth.login.tsx:withCmsTimeout()ヘルパーでfetchAnnouncements・fetchStoreSettingsを 2s でキャップauth.reset-password.tsx,auth.password-reset.tsx,auth.temporary-login.tsx,auth.temporary-setup.tsx: 同様にPromise.raceタイムアウト追加-
CMS(ロゴ・お知らせ)は装飾情報のため、タイムアウト時は
nullとして扱い ページを即座に表示する -
apps/storefront/src/lib/cms/client.tsにブラウザ側フェッチ上限追加 - ブラウザ側
fetch()にsignal: AbortSignal.timeout(8000)を設定 - ローダーの 2s race が主防御ラインだが、バックグラウンドリクエストが長時間 ハングしないよう 8s で上限を置く
確認方法¶
# stagingの /auth/login にアクセスし、CMS が遅くても 2s 以内にフォームが表示されることを確認
curl -o /dev/null -w "%{time_total}" https://order-staging.ritsubi-platform.com/auth/login
ISSUE-002 (High): パスワードリセット - 無効トークンで silent fail¶
症状¶
/auth/reset-password?token=INVALID でフォームを submit しても、エラーメッセージが
表示されないように見えた。
根本原因¶
ISSUE-001 の副作用。reset-password ページが blank 状態のままフォームが描画されず、 エラー表示が見えなかった。コード自体のエラーハンドリングは正しく実装されていた。
ResetPasswordForm は PasswordResetTokenInvalidError を受け取ると
setError("root.server", { message: "リセットトークンが無効です。..." }) を呼び、
FormErrorAlert がエラーメッセージを表示する(既存ユニットテストで検証済み)。
修正内容¶
ISSUE-001 の修正によりページ表示が 2s 以内になり、エラーハンドリングが正常に機能する。
ISSUE-003 (High): 「お問い合わせフォーム」リンクが循環リダイレクト¶
症状¶
ログインページの「お問い合わせフォーム」リンク(/support/contact)をクリックすると、
siteRoute(認証必須)にリダイレクトされ、再度ログインページに戻るループが発生した。
根本原因¶
apps/storefront/src/router.tsx で supportContactRoute が認証必須の siteRoute の
子として定義されていたため、未認証ユーザーがアクセスすると強制リダイレクトされた。
/support/contact は連絡先・Googleフォームリンクのみを表示する静的ページで、
認証不要な情報のみ含む。
修正内容¶
supportContactRoute を siteRoute(認証必須)から contentRoute(公開)へ移動。
// router.tsx
const supportContactRoute = createRoute({
getParentRoute: () => contentRoute, // siteRoute → contentRoute
path: "/support/contact",
...
});
routeTree の siteRoute.addChildren(...) からも除去し、
contentRoute.addChildren(...) へ追加。
確認方法¶
# 未認証状態で /support/contact にアクセスし、ページが表示されることを確認
curl -o /dev/null -s -w "%{http_code}" https://order-staging.ritsubi-platform.com/support/contact
# 200 が返ること(302 ではないこと)を確認
2026-05-28 Staging Re-Dogfooding 発見問題 (ISSUE-004)¶
ISSUE-004 (High): 保護ルート未認証アクセス時に Worker session validation が ~25s ハング¶
症状¶
- 未認証ブラウザまたは stale session cookie を持つブラウザが
/などの保護ルートにアクセスすると、 ログインページへのリダイレクトが 25〜26 秒かかる - 修正後は 2〜3 秒以内にリダイレクト完了
根本原因¶
apps/storefront/src/worker-api/handlers/auth.ts の validateStorefrontSession 関数が
fetchVendureGraphql で Vendure に ActiveCustomerForSession クエリを投げる際、
タイムアウトが設定されていなかった。
Vendure が応答に時間を要する場合(cold start、高負荷、ネットワーク遅延など)、 Cloudflare Worker の 30s ウォールタイムに近い時間まで待機してしまう。
影響範囲:
- session cookie を持つ未認証ユーザー(stale cookie、セッション期限切れ)
- reset-password フォーム送信後など、Vendure が session cookie を発行した後
修正内容¶
apps/storefront/src/worker-api/vendure-client.ts の fetchVendureGraphql に timeoutMs オプションを追加:
export async function fetchVendureGraphql(
request: Request,
env: WorkerEnv,
body: Record<string, unknown>,
options: {
includeRequestCookies?: boolean;
includeAuthorization?: boolean;
timeoutMs?: number; // 追加
} = {},
): Promise<Response> {
const { timeoutMs, ...fetchOptions } = options;
return await fetch(
resolveVendureShopApiUrl(env),
withRuntimeBodySupport({
method: "POST",
headers: buildVendureHeaders(request, env, fetchOptions),
body: JSON.stringify(body),
...(timeoutMs != null ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
}),
);
}
validateStorefrontSession の呼び出し側に timeoutMs: 5000 を追加:
const response = await fetchVendureGraphql(
request,
env,
{ query: ACTIVE_CUSTOMER_QUERY, operationName: "ActiveCustomerForSession" },
{ includeRequestCookies: true, includeAuthorization: true, timeoutMs: 5000 },
);
タイムアウト時は catch ブロックで { authenticated: false, unavailable: true } を返すため、
ログインページに ?error=service-unavailable 付きで即時リダイレクトされる。
ユーザーには "認証サービスに接続できませんでした" というメッセージが表示される。
確認方法¶
# stale cookie がある状態で / にアクセスし、2〜3秒以内にリダイレクトされることを確認
curl -o /dev/null -s -w "Total: %{time_total}s HTTP: %{http_code} Redirect: %{redirect_url}\n" \
-H "Cookie: vendure-session=stale-token-xxx" \
https://order-staging.ritsubi-platform.com/
# Total が 5s 未満、HTTP 302、Redirect が /auth/login?redirect=/ であること
2026-05-28 per-variant pricing N+1 除去 / 単一フェッチ / L2 browse cache¶
背景(Sentry 実測で原因確定)¶
ログイン顧客(B2B)のカタログ一覧 (/products?collection=...) の遅延を Sentry 実測で切り分けた。
- Vendure
span.op:db: 7日で count 398,819 / sum ≈ 9.4時間(avg 85ms / p95 320ms)。典型的な N+1 シグネチャ。 - Storefront
span.op:products.visible-search: p50 330ms / p95 2.9s / max 72s(prod 全体)。staging は p50 310 / p95 2,164 / max 20,259 ms。
注: カスタム Sentry.metrics.*(visible_products_browse.*)は SDK 設定により no-op の可能性があり、切り分けは span 実測を一次情報にした(#674 節の _experiments.enableMetrics も併せて確認すること)。
根本原因: 価格計算経路の per-variant N+1¶
RitsubiPriceCalculationStrategy.calculate() が variant ごとに calculateCatalogPrice を呼び、その内部で 1 ページ(最大 ~400 variant)あたり以下が積み上がっていた。
| 経路 | 状態(修正前) | 1ページあたり query 数 |
|---|---|---|
| Commercial Rules / 旧対象者条件 | request-scoped + 5s TTL でキャッシュ済 | 各 1 |
旧商品対象条件 (AccessPolicyService.listResourceSets) |
未キャッシュ(2 query 全件 load) | ~800 (2×400) |
SMILE 単価マスタ (SmilePriceMasterService.findBestPriceForVariant) |
未キャッシュ(variant ごと raw query) | ~400 |
ritsubi_smile_price_master には IDX_smile_price_master_lookup(productCode 先頭の複合 index)が既存で、seq scan ではなくクエリ「数」が問題。pricing 経路の variant relation 再取得は browse が collections/facetValues をロード済みのため ensureVariantRelations が skip しており発生しない(調査で確認)。
修正¶
- 旧商品対象条件を request-scoped + 5s shared TTL でキャッシュ化(
access-policy.service.ts、commitb8f8a93、changesetcatalog-browse-pricing-n-plus-one)。旧対象者条件の list と同型。旧商品対象条件系の create/update/delete でキャッシュ失効。→ ~800 query/page → ~2。 - SMILE 単価参照を
(productCode, customerCode, salesRateClassCode, quantity)キーで request-scoped メモ化(smile-price-master.service.ts、同 commit)。specificity 選択ロジックは不変(smile-price-master.priority.test.tsで固定)。 - storefront を単一フェッチ化(
products.tsx、commit26f8aa3、changesetproducts-catalog-single-fetch)。下記「2026-05-28 商品カタログ Vendure API コール削減」の 48→200 page-size は本変更で superseded。この時点では backendvisibleProductsForBrowseの全件 1 レスポンス化で Storefront 側の while ループ(N 往復)を廃止した。 - L2 cross-isolate browse cache(
visibility-shop.resolver.ts、PR #892、changesetvisible-products-browse-shared-cache)。これは「#673 残り打ち手 (b)」の cross-isolate 版。
2026-05-29 update: production の
/products?collection=mesoceuticalで全件 hydrate が 6s 超へ戻ったため、初期表示は再び page-size browse を正本にした。現行の正はPRODUCTS_INITIAL_PAGE_SIZE(= 24) 分だけ取得し、ブランド和集合はcollectionSlugsの 1 query にまとめること。
L2 browse cache の設計(重要)¶
per-isolate の in-memory Map(L1)は Fly machine ごと / 再起動で消えるため、cold isolate で重い search+visibility pipeline が毎回走り tail(p95/max)を悪化させていた。L2 として Vendure CacheService(RedisCacheStrategy 済み)を追加。
- 可視 product/variant の ID のみを保存。
Productentity は直列化しない(復元した plain object はapplyChannelPriceAndTaxを通せず B2B 価格が壊れる)。hit 時は ID から entity を都度ハイドレートし、価格 field resolver を正しい entity 上で動かす。 - 可視判定は cache key(channel × customerGroupIds × collectionSlug × revision × page)に紐づくため、hit 時の再 validate は不要(L1 と同じ前提)。TTL は L1 と同じ 5 分。
- additive かつ
@Optional:CacheService未設定環境 / unit test では L2 無効、従来の L1 のみ挙動と完全一致。 - ログ: L2 hit は
event: "visible-products-browse-shared-cache-hit"で出る。
staging 検証結果(Vendure bc23414 deploy 済み)¶
- 機能: Shop API 直叩きで
visibleProductsForBrowse(mesoceutical)= 26件返却・エラー0(regression なし)。 - latency: full collection + 価格を proxy 経由 end-to-end で warm ~0.52s / cold ~1.62s。
既知の落とし穴(計測時)¶
- perf smoke の flake: deploy 直後は cold start で初回 browse が 0 件になり得る。また
AUTH_LOGIN_RATE_LIMIT(10 login/60s/IP、wrangler.toml)があるため、smoke を連続実行すると login 失敗(認証サービスに接続できませんでした= ISSUE-004 経路)になる。clean な before/after は rate limit 冷却後に 1 回だけ実行する。 - ローカル drift audit / deploy で
@ritsubi/plugins/dist/index.jsnot found:git worktree操作のsync-depshook がpackages/plugins/distを消すことがある。pnpm exec nx run @ritsubi/plugins:build --skip-nx-cacheで再生成してから再実行する。 - L2 hit パスの未検証点(PR #892 マージ前): 認証済み B2B browse で L2 hit の返却商品・価格が L1 と一致することを実スタックで確認すること(pricing 隣接)。
残課題¶
- perf smoke fixture の堅牢化(clean 計測)と L2 hit パスの価格一致検証は issue #891 で追跡。
2026-05-28 ホームページ 500 エラー解消 (ISSUE-005: HardenPlugin 複雑度超過)¶
症状¶
- ホームページ (
/) が 500 エラー で返る(SWR fallback がなく真っ白) - Vendure ログに
Query complexity of "anonymous" is 6324, which exceeds the maximum of 2500とQuery complexity of "anonymous" is 4528, which exceeds the maximum of 2500が同時刻に出力される - HardenPlugin の
maxQueryComplexity: 2500に違反するクエリが複数ホームページ SSR 経路から送出されていた
根本原因¶
6324: GetProductVariantsBySkusForHome — take 未渡し¶
home-page-helpers.ts の fetchRankingVariantsBySkus が vendureQuerySsr 呼び出し時に変数 { skus } のみを渡し、
take を渡していなかった。HardenPlugin の complexity plugin はデフォルト take=1000 とみなして計算するため、
complexity = childComplexity + round(ln(childComplexity) * take) が 6324 となり上限を大幅超過した。
// 修正前 (home-page-helpers.ts)
const data = await vendureQuerySsr(
GET_PRODUCT_VARIANTS_BY_SKUS_FOR_HOME,
{ skus }, // ← take を渡さない → default 1000 → complexity 6324
...
);
// 修正後
const data = await vendureQuerySsr(
GET_PRODUCT_VARIANTS_BY_SKUS_FOR_HOME,
{ skus, take: skus.length }, // ← 実際に必要な件数だけ → complexity ~592
...
);
4528: GetHeroCampaignCollections — collections(options: { topLevelOnly: true }) に take 未指定¶
banner-utils.ts の HERO_CAMPAIGN_COLLECTIONS_QUERY がヒーローバナーのキャンペーン URL 解決のために
collections(options: { topLevelOnly: true }) を呼んでいたが take を指定していなかった。
CollectionList は PaginatedList interface を implements しているため、
HardenPlugin は take=1000 でペナルティを加算し complexity 4528 を算出した。
# 修正前
collections(options: { topLevelOnly: true }) # ← take 未指定
# 修正後
collections(options: { topLevelOnly: true, take: 100 }) # ← SHOP_LIST_SAFE_TAKE=100
HardenPlugin complexity 計算式¶
// apps/vendure-server/node_modules/@vendure/harden-plugin/.../query-complexity-plugin.js
const isPaginatedList = !!namedType.getInterfaces().find((i) => i.name === "PaginatedList");
if (isPaginatedList) {
const take = args.options?.take ?? args.take ?? 1000; // ← take 未指定は 1000
result = childComplexity + Math.round(Math.log(childComplexity) * take);
}
Math.logは自然対数 (ln)takeのデフォルト: 1000(引数未指定時)- PaginatedList チェック: TypeScript interface
PaginatedListをimplementsしているかどうか
vendureFetch の operationName 欠落問題¶
apps/storefront/src/lib/vendure-fetch.ts の vendureFetch は
body: JSON.stringify({ query: queryStr, variables }) と送り、operationName を含まない。
そのため HardenPlugin の違反ログが全て "anonymous" と表示される。どのクエリが違反しているか
ログだけでは判断できないので、staging 上で違反が出たら storefront コードで PaginatedList クエリを
take 指定漏れがないか検索すること。
確認方法¶
# staging ホームページへのリクエストが 200 を返すことを確認
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" -L "https://order.ritsubi-platform.com/"
# → 200 x.xxxs (500 ではない)
# Vendure ログで complexity 違反がないことを確認
fly logs --app ritsubi-ecommerce-staging --no-tail 2>&1 | rg "complexity"
# → 何も出力されないか、今回の修正前のタイムスタンプのみ表示される
修正コミット¶
aead09638—fix(storefront): HardenPlugin 複雑度超過を解消 (6324 + 4528)apps/storefront/src/lib/home-page-helpers.ts:take: skus.length追加apps/storefront/src/lib/cms/content/banner-utils.ts:take: ${SHOP_LIST_SAFE_TAKE}追加
予防策¶
storefront に PaginatedList を返す新クエリを追加するとき(products, collections, productVariants など)は
必ず take を変数または定数で指定すること。SHOP_LIST_SAFE_TAKE(= 100)を標準上限として利用する。
list / search / home 用の Shop API operation では、詳細用 fragment (...Product /
...ProductVariant) を spread しないこと。packages/sdk/shop/src/operations/operation-complexity-guard.test.ts
が主要 operation の full detail fragment 混入を検出する。