ブログ一覧

EmDash で記事にタグを付けても本番に反映されない原因

更新: Web開発者向け

EmDash で記事を書き、公開し、あとからタグを付け直しました。本番のタグ一覧を開くと、その記事が出てきません。管理画面では正しく付いています。

しばらく待っても変わりませんでした。

コンテンツを保存すると消えるのに、タグを付けても消えない

EmDash は Cloudflare の Workers Caching に HTML を載せます(ゾーンの CDN キャッシュとは別の層で、Cloudflare の Purge Everything で Workers Cache が消えない理由)。コンテンツを更新すると該当ページのキャッシュを自動で失効させるので、編集は普通すぐ反映されます。実際、記事本文を書き換えたときは即座に新しい内容が出ました。

タグの付け替えだけが反映されません。cf-cache-status を見ると、ページはキャッシュから返ったままでした。

$ curl -s -D- -o /dev/null https://example.com/posts/tag/ai | grep -i cf-cache-status
cf-cache-status: HIT

同じ記事に対して、本文を変えずに content update を 1 回走らせると落ちます。つまりキャッシュの失効そのものは動いていて、タグ付けの経路だけが呼んでいないということになります。

失効を呼んでいるルートと、呼んでいないルート

EmDash の配布物を読むと、失効の呼び出しがどこにあるか分かります。

$ grep -rl "cache.*invalidate(" dist/astro/routes/ | sed 's|.*/api/||'
content/_collection_/_id_.mjs
content/_collection_/_id_/publish.mjs
content/_collection_/_id_/unpublish.mjs
content/_collection_/_id_/restore.mjs
content/_collection_/_id_/schedule.mjs
content/_collection_/_id_/duplicate.mjs
content/_collection_/_id_/discard-draft.mjs
content/_collection_/_id_/permanent.mjs
content/_collection_/index.mjs

呼んでいるのはコンテンツの CRUD だけでした。公開、更新、削除、復元、予約公開、複製。どれも cache.invalidate({ tags: [...] }) を実行しています。

一方、タグの割り当てを担当しているのは別のルートで、こちらは呼んでいません。

// dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs
await repo.setTermsForEntry(collection, canonicalId, taxonomy, termIds);
invalidateTermCache();

invalidateTermCache() という名前が紛らわしいのですが、これは Worker のメモリ上に持っているタクソノミーのキャッシュを捨てる処理で、エッジキャッシュには届きません。同じことが taxonomies、settings、menus、widget-areas のルートにも当てはまります。いずれも失効の呼び出しは 0 件でした。

管理画面が叩いている URL を配布物から拾うと、対象はこうなります。

  • /_emdash/api/content/{collection}/{id}/terms/{taxonomy} — 記事へのタグ・カテゴリの割り当て
  • /_emdash/api/taxonomies/_emdash/api/taxonomies/{taxonomy}/terms — タクソノミー自体の作成・更新・削除
  • /_emdash/api/settings/_emdash/api/settings/email — サイト設定
  • /_emdash/api/menus/_emdash/api/menus/{menu}/_emdash/api/menus/{menu}/translations — メニュー
  • /_emdash/api/widget-areas/_emdash/api/widget-areas/{area} — ウィジェットエリア

このうち最初のタグ割り当てが、実運用でいちばん頻繁に通ります。記事を書くたびに触るからです。

保持時間を延ばすまで気づかなかった

この穴は前から空いていましたが、実害が出ていませんでした。エッジキャッシュの保持時間を 5 分にしていたので、反映されなくても数分で自然に消えていたからです。

Cloudflare D1 の読み取りが 1 日 2,100 万行になった原因 作業で保持時間を 30 日へ延ばしたところ、同じ穴が「最大 30 日反映されない」に変わりました。長く持たせるほど、自動失効の守備範囲の外にあるものが表に出ます。

CMS 本体に手を入れずに塞ぐ

最初は EmDash のパッケージに patch を当てるつもりでした。実際、別件では pnpm patch を使っています。ただ今回はアプリ側だけで完結しました。

EmDash が失効に使っているのは EmDash 独自の API ではなく、Astro 7 の API コンテキストが持っている `cache` でした。ルートの引数を見ると分かります。

const POST = async ({ params, request, locals, url, cache }) => {
  // ...
  if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });

cacheAPIContext のプロパティなので、ミドルウェアからも同じものが使えます。型定義にも invalidate があります。

export interface CacheLike {
  readonly enabled: boolean;
  set(input: CacheOptions | CacheHint | LiveDataEntry | false): void;
  readonly tags: string[];
  readonly options: Readonly<CacheOptions>;
  invalidate(input: InvalidateOptions | LiveDataEntry): Promise<void>;
}

なので src/middleware.ts に、対象の API への書き込みが成功したら失効させる処理を足せば済みます。EmDash のミドルウェアはすべて order: "pre" で登録されているため、アプリ側のミドルウェアは必ず後に走ります。next() の戻り値で書き込みの成否を判定できるのはそのおかげです。

const PURGE_ON_WRITE =
  /^\/_emdash\/api\/(?:taxonomies|settings|menus|widget-areas)(?:\/|$)|^\/_emdash\/api\/content\/[^/]+\/[^/]+\/terms(?:\/|$)/;

const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

export const onRequest = async (context, next) => {
  const response = await next();
  if (!WRITE_METHODS.has(context.request.method)) return response;
  if (!response.ok) return response;
  if (!PURGE_ON_WRITE.test(context.url.pathname)) return response;
  if (!context.cache.enabled) return response;
  // ここで失効させる
  return response;
};

失効の粒度はタグ指定ではなくサイト全体にしました。メニューやサイト設定は全ページのヘッダーやサイドバーに出るうえ、個別ページのキャッシュタグはエントリの ID なので、タグ指定では消しきれません。消えるのはエッジキャッシュだけでコンテンツは無傷なので、全部落として構わないと判断しました。

サイト全体の失効は Astro の invalidate には無いので、そこだけ cloudflare:workerscache を直接使います。ローカルの開発サーバーでは解決できないため動的 import にし、cache.enabledfalse のときは何もしないようにしました。

本番で確かめた

管理画面からサイト設定を開き、値を変えずに保存しました。直前まですべて HIT だったページが MISS に変わりました。

/          cf-cache-status: MISS
/posts     cf-cache-status: MISS
/works     cf-cache-status: MISS

タクソノミーの編集でも同じでした。タグを 1 つ更新すると、タグ別の一覧ページを含めて落ちます。

/                  cf-cache-status: MISS
/posts             cf-cache-status: MISS
/posts/tag/ai      cf-cache-status: MISS

/posts/tag/ai が落ちているのが要点で、ここはタグ指定の失効では消えないページでした。

他のサイトのキャッシュは巻き込まれていません。同じアカウントで動かしている別サイトは age が伸びたまま HIT を返し続けていました。Workers Caching は Worker ごとに閉じているので、失効も Worker の単位で止まります。

パッチではなくアプリ側にした理由

EmDash 本体に patch を当てても同じことはできます。それでもアプリ側を選んだのは 2 つの理由からです。

バージョンを上げても壊れません。 patch は当てた版に固定されるので、EmDash を上げるたびに当て直しが要ります。手元では別件の patch がその状態にあります。

穴が 4 系統あってもミドルウェア 1 か所で塞げます。 patch だと対象のルートそれぞれに手を入れることになります。

この構成で気をつけること

  • CMS が「編集すると自動で反映される」と言っていても、その範囲はコンテンツの CRUD に限られていることがある。タグ付け・サイト設定・メニューは別経路になりやすい
  • 反映されない経路があるかどうかは、保持時間を短くしていると気づけない。延ばしてから顕在化する
  • 失効の呼び出しがどこにあるかは、配布物を grep すれば分かる。cache.*invalidate( で当たる
  • Astro 7 の cacheAPIContext のプロパティなので、ミドルウェアからも触れる。CMS 本体を触らずに補える
  • EmDash のミドルウェアは order: "pre" で登録されるため、アプリ側のミドルウェアは後に走る。レスポンスの成否を見てから処理できる

タグを付け替えただけのときに古いページが残るのは、キャッシュが壊れているからでも設定を間違えているからでもありませんでした。自動失効の範囲が想定より狭かっただけで、範囲さえ分かれば残りは自分で埋められます。

関連

この記事をシェア