コンテンツへスキップ

ダッシュボードキャッシュの「寿命」を巡る 3 つの設計やり直し — 起動時復元 / TTL / 部分消去

横断ダッシュボードに キャッシュ即表示の仕組み を入れて、その後 サイト一覧のバッジ もそのキャッシュを流用するように接続した ── ここまでは順調でした。

ところが、運用に入った途端、キャッシュの「寿命」周りで 3 つの異なる落とし穴を立て続けに踏みました。「リフレッシュで全部消える」「TTL 7 日が短すぎる」「1 サイトのメンテで全バッジが消える」。どれも単独で見ると小さな仕様判断ですが、利用者から見ると全部「バッジが期待通りに残っていない」という同じ症状に集約されます。今回はこの 3 つの修正を、それぞれ何をミスしていたかを含めてまとめておきます。

落とし穴 1 — リフレッシュで全バッジが消える

最初の指摘は端的でした。「プラグイン未更新のバッジが、リフレッシュで全部消えてしまうのは仕様?

調べると仕様ではなくバグでした。_updatesDashState は localStorage バックの永続キャッシュとして設計されていたのに、復元関数の呼び出しが「ダッシュボードを開いた瞬間」にしか配置されていなかった

// 旧: ダッシュボードを開いた時にだけ復元される
function openUpdatesDashboard() {
  _loadUpdatesDashStateFromLocalStorage();   // ← ここでしか呼ばれない
  // ... 描画 ...
}

この経路だと:

  • アプリ起動時: メモリの _updatesDashState は初期値(空配列)
  • サイト一覧描画時: _getPendingPluginCountForSite が空配列を見て null を返す
  • 結果: バッジが全部非表示

ユーザーが明示的にダッシュボードを開いて初めて localStorage から復元される、という挙動でした。設計者の頭の中では「キャッシュ = ダッシュボードのもの」だったのに、後から接続したサイト一覧バッジが先に描画される動線を考慮できていなかった訳です。

修正は単純で、起動時の DOMContentLoaded 内に復元呼び出しを 1 行足しただけ:

document.addEventListener('DOMContentLoaded', () => {
  _completedSiteTimestamps = _loadCompletedSites();
  try {
    _loadUpdatesDashStateFromLocalStorage();  // ← 起動時に必ず復元
  } catch (e) {
    // localStorage 無効環境でも起動を妨げない
  }
  // ... 以降の初期化
});

try/catch で例外保護を入れているのは、ブラウザのプライベートモード等で localStorage 自体が使えない環境でも起動が転ばないように。復元処理は「あれば嬉しい」ものなので、失敗しても落とさないのが原則です。

学び 1 — キャッシュは「使う動線」より「アプリのライフサイクル」に紐付ける

復元のタイミングを「キャッシュを最初に使う画面(ダッシュボード)」に置いたのが間違いでした。他の画面(サイト一覧バッジ)が先に同じキャッシュを使う可能性があるなら、復元はアプリ起動時に一度だけやる方が安全です。「データを読む側」を増やすたびに、復元呼び出しを足す必要のない設計になります。

落とし穴 2 — TTL 7 日は短すぎた

次の指摘は「プラグイン未更新数の一覧表示は 7 日経過したら消えちゃうの?」。

7 日は初期実装時の感覚値でした。「ダッシュボードを定期的に開く運用」を前提にしていたのですが、実際の使い方は「たまにダッシュボードでざっと見て、その後はサイト一覧のバッジを参考にしながら個別に対応」というスタイルが多く、7 日では切れる方が早い。

クライアントの要望は明快で「次のチェックまで表示されていてほしい」。それを素直に受けて、TTL を 30 日に延長:

const _DASH_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;  // 7→30 日

ただし、30 日は長いので「いま見えている数字がいつのものか」を可視化しないと、古い情報を最新と誤認するリスクがあります。tooltip に経過日数を併記する形で対応しました:

function _formatPendingPluginCountAgeSuffix() {
  const loaded = _updatesDashState.loadedAt;
  if (!loaded) return '';
  const days = _daysSinceTimestamp(loaded);
  return _formatDaysAgoText(days, '(最終取得: ${days})');
  // → "(最終取得: 5日前)" のような suffix
}

_formatDaysAgoText経過日数バッジ で作ったヘルパーをそのまま流用。「N 日前」の言い回しを 1 箇所に集約しておくと、こういう小さな再利用が効きます。

学び 2 — 長い TTL は「鮮度の可視化」とセットにする

TTL を伸ばすと「古い情報のまま見続けるリスク」が増えます。これは TTL を短くして対処するより、鮮度をユーザーに見せて判断を委ねる方が運用に馴染みます。30 日 + 「5 日前のデータです」の tooltip の組合せは、7 日でばっさり切るより情報量が多い設計です。

落とし穴 3 — メンテナンス実行で「全バッジ」が消える

3 つ目は運用に入ってから気づいた指摘でした。「1 サイトのメンテを実行しただけで、他のサイトのバッジも全部消える。これでよかったっけ?

調べると、旧実装は「メンテナンス実行成功時にダッシュボードキャッシュを全消去」していました:

// 旧: 全消去
function _invalidateUpdatesDashCache() {
  _updatesDashState = { sites: [], total_pending_count: 0, loadedAt: 0 };
  _saveUpdatesDashStateToLocalStorage();
}

これは V43 で踏んだのと同じ罠 でした ── 状態クリアの範囲を実行スコープに限定しないと、無関係な状態まで巻き込む。1 サイトしかメンテしていないのに、他 9 サイトのバッジも消えてしまえば、運用としては逆に困ります。

修正は「実行サイトの ID 一覧」を渡して部分消去に変えました:

function _invalidatePendingPluginCacheForSiteIds(siteIds) {
  if (!Array.isArray(siteIds) || siteIds.length === 0) return;
  const idSet = new Set(siteIds);
  _updatesDashState.sites = _updatesDashState.sites.filter(
    s => !idSet.has(s.site_id)
  );
  _updatesDashState.total_pending_count =
    _updatesDashState.sites.reduce((sum, s) => sum + s.plugins.length, 0);
  _saveUpdatesDashStateToLocalStorage();
  // loadedAt は変えない(部分更新なので「いつ取得したか」は維持)
}

呼び出し側では、payload.sites から実行対象の _id を抽出して渡します。payload.sites === null(全サイト実行)の場合は従来通り全消去にフォールバック ── つまり「全消去」を消したのではなく、「明示的に全部を消す経路」と「指定したサイトだけ消す経路」を分けた形です。

_invalidateUpdatesDashCache() 関数は撤去せず温存しています。他から呼ぶ場面が将来あり得る(手動「キャッシュクリア」ボタン等)ので、明示的な「全消去」の手段を残しておく方が安全です。

学び 3 — 状態クリアは「実行スコープ」に必ず限定する

ログ逆走の罠 と全く同じ構造でした。「メンテナンス開始 → キャッシュをリセット」のような処理を書く時、リセットの範囲が暗黙に「全部」になっている経路は要注意です。実行対象が変動するなら、そのスコープを引数で受け取って明示的に限定する。手間は小さく、効果は大きい。

まとめ — キャッシュの寿命設計は「読む側のライフサイクル」と「クリアの範囲」が肝

3 つの落とし穴は別々の指摘から見つかりましたが、底に共通する原則は次の 2 つでした。

  1. キャッシュは「アプリ起動時に復元」が原則 — 復元タイミングを「最初の使い手」に置くと、後で別の使い手が追加された時に黙って壊れる。DOMContentLoaded で一度だけ復元 + try/catch で例外保護がデフォルトでよい
  2. TTL の伸ばし方は「鮮度の可視化」とセット — 30 日 + 「N 日前のデータ」tooltip は、7 日で切るより情報量が多い。ユーザーに古さを伝えて判断を委ねる方が、機械的な切り捨てより運用に合う
  3. 状態クリアは実行スコープに限定する — 「全部消す」をデフォルトにしない。実行対象の ID 一覧を引数で受け、その範囲だけクリアする。明示的に「全消去」したい場面は別関数として温存

キャッシュは「速さ」のためのものですが、運用に出ると寿命の設計が UX を決める比率の方が大きくなります。次にキャッシュを足す時は、最初から起動時復元・鮮度の可視化・部分消去の 3 点をテンプレートとして組み込むつもりです。