ブログ一覧

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

更新: Web開発者向け

EmDash で公開した記事にあとからタグを付け直したところ、管理画面では正しく付いているのに、本番のタグ一覧を開くとその記事が出てきませんでした。しばらく待っても変わりませんでした。

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

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

タグを付け替えたときだけは反映されず、cf-cache-status を見るとページはキャッシュから返ったままでした(キャッシュの確かめ方で誤診しやすい点は Cloudflare で purge しても age が伸び続けるのは壊れていない)。

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

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

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

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 本体を変えずにアプリのミドルウェアで失効させる

最初は別件と同じく pnpm patch で EmDash のパッケージに 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] });

cache は APIContext のプロパティなので、ミドルウェアからも同じものが使えます。型定義にも 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:workers の cache を直接使います。ローカルの開発サーバーでは解決できないため動的 import にし、cache.enabled が false のときは何もしないようにしました。

本番で確かめた

管理画面からサイト設定を開き、値を変えずに保存しました。直前まですべて 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 はタグ指定の失効では消えないページで、ここが MISS になったことが確かめたかった点です。

他のサイトのキャッシュは消えていません。同じアカウントで動かしている別サイトは age が伸びたまま HIT を返し続けていました。Workers Caching は Worker ごとに閉じているので、失効の範囲も Worker の中に限られます。

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

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

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

失効を呼ばないルートが 4 系統あっても、ミドルウェア 1 か所で補えます。 patch だと対象のルートをそれぞれ書き換えることになります。

CMS の自動失効はコンテンツの CRUD に限られることがある

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

タグを付け替えただけのときに古いページが残った原因は自動失効の範囲が想定より狭かったことで、範囲が分かれば足りない分はアプリ側で失効させられます。

この記事をシェア