EmDash で記事にタグを付けても本番に反映されない原因
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"で登録されるため、アプリ側のミドルウェアは後に走り、レスポンスの成否を見てから失効させられる
タグを付け替えただけのときに古いページが残った原因は自動失効の範囲が想定より狭かったことで、範囲が分かれば足りない分はアプリ側で失効させられます。