コンテンツへスキップ

メンテがエラーで終わるとプラグインバッジが消える罠 — バックエンドマーカーとフロント実数反映の設計

WordPress の保守ツールには、サイト一覧に「未更新プラグインが N 件ある」を示すバッジを表示する機能がある。通常のメンテナンスでは更新が終わるとバッジが消え、次回の横断ダッシュボード走査まで 0 件扱いになる——という動きが正しい挙動だ。

ところが、メンテナンスが途中で warning や SSH エラーになった場合にも、未更新プラグインが残っているのにバッジが消える、という症状が報告された。「更新できなかったのに、バッジが消えると残っているかどうか分からなくなる」という指摘だった。

なぜ消えていたか

原因を追うと、フロント側のバッジ更新ロジックが「メンテ完了 = 全プラグイン更新済み = 0 件」という前提で設計されていたことが分かった。

メンテ終了イベントを受け取ったフロントは、実行サイトのバッジキャッシュ(_updatesDashState)からそのサイトのエントリを削除していた。「更新が終わったのだから残数はゼロのはず」という決め打ちだ。

この前提が崩れるのは、メンテが「失敗」で終わった場合だ。

  • DB バックアップが失敗して、プラグイン更新フェーズに到達しなかった
  • プラグイン更新中に SSH が切断されて、途中終了した
  • HTTP チェックでサイト異常を検知し、ロールバック後に警告で終了した

どのケースでも「実行完了イベント」がフロントに届く(ロールバック後であっても処理フローの終端は同じ)。従来の設計はその完了イベントでキャッシュを削除していたため、未更新が残っていてもバッジが消えていた。

設計の核: バックエンドマーカー

修正の方針は「実際に残った件数をバックエンドが出力し、フロントはそれを読んでバッジを決める」というものだ。決め打ちをやめ、事実ベースに切り替える。

バックエンド(maintenance_agent.py_check_pending_updates_ssh())は、メンテ後の残余チェックが完了したタイミングで次のマーカー行をストリーミングログに出力する。

[サイト名] 未更新プラグイン: 2 件 {akismet, contact-form-7}

英語ログでは:

[SiteName] Pending plugins remaining: 2 {akismet, contact-form-7}

件数と一緒にプラグイン slug の一覧も {} で囲んで併記している。フロントがバッジキャッシュを実体化する際にプラグイン名が必要なためだ。

重要な制約: このマーカーは「残余チェックに到達した場合にだけ出力する」設計になっている。DB バックアップが失敗してプラグイン更新フェーズ自体をスキップした場合、マーカーは出力されない。フロントはこれを「不明」として扱い、既存バッジをそのまま温存する。

if plugin_count_known:
    _names_part = '{' + ', '.join(plugin_remaining_names) + '}'
    logger.info(t(
        f"[{site_name}] 未更新プラグイン: {plugin_remaining_count} 件 {_names_part}",
        f"[{site_name}] Pending plugins remaining: {plugin_remaining_count} {_names_part}"
    ))

フロント側の 3 パス処理

フロント(templates/index.html)は、ストリーミングログのチャンクごとにマーカーを検出して _postMaintPluginCountsite_id → {count, names[]})に記録する。

完了処理のタイミング(_applyPendingCountsForSites())で、各サイトのバッジを次の 3 パスで決定する:

状態 バッジの扱い
マーカーあり・N = 0 キャッシュエントリを削除(バッジ非表示 = 更新成功)
マーカーあり・N > 0 エントリを実プラグイン名で更新(バッジに件数を表示)
マーカーなし 何もしない(既存バッジをそのまま温存)

「マーカーなし」のサイトに手を付けないのが設計上の肝だ。残余チェックに到達しなかった理由はフロントには分からない。「何が起きたか不明なら、バッジは触らない」という保守的な設計が正しい。

function _applyPendingCountsForSites(siteIds) {
    for (const sid of siteIds) {
        if (Object.prototype.hasOwnProperty.call(_postMaintPluginCount, sid)) {
            _applyPostMaintPluginCountToBadge(sid, _postMaintPluginCount[sid]);
        }
        // マーカーが無いサイトは何もしない(既存バッジ温存)
    }
}

選択プラグイン更新モードの扱い

ツールには「このプラグインだけ更新する」という選択プラグイン更新モードがある。このモードでも、バッジに表示する件数は「選択したプラグインの残数」ではなく「サイト全体の未更新プラグイン総数」を使う。

選択更新後に残っている件数をバッジに出すならば、「選んだ以外のプラグインも含めた総数」が正しい。バックエンドはスコープ絞り込み前の件数を取得してからマーカーを出力するため、この点は設計上一貫している。

呼び出し経路の統一

以前の実装では、「メンテ完了後のバッジ処理」が 3 箇所に散在していた:

  1. _refreshCompletedSitesNow()(完了即時反映)
  2. 全サイト実行の完了イベント
  3. 選択プラグイン更新の完了イベント

それぞれが独立して「キャッシュを削除する」コードを持っていたため、今回の変更はすべての経路を _applyPendingCountsForSites() 経由に統一することで実現した。分散していた「完了後バッジ処理」を 1 関数に集約したことで、次回のバッジ挙動変更も 1 箇所だけ変えれば済む構造になっている。

まとめ

観点 内容
問題 メンテ完了 = 0 件という決め打ちで、エラー/warning 終了後もキャッシュを削除していた
原因 フロントがバックエンドの実結果を見ず、完了イベントだけで判断していた
対策 バックエンドがマーカー行で実数を出力し、フロントがそれを解析してバッジを決定
3 パス N=0=削除 / N>0=実名表示 / マーカーなし=既存温存
設計哲学 「不明なら触らない」— 情報が不足している場合の保守的フォールバック

バッジは「未更新がある」という情報を伝えるための UI だ。メンテがうまくいった時も失敗した時も、実際の状態を正直に反映することで、次に何をすべきかを正確に伝えられる。

バッジの初期実装は「プラグイン未更新数バッジの設計」に、キャッシュ寿命設計は「ダッシュボードキャッシュ寿命の落とし穴」にまとめている。