形態1:状態修復。形態2:無駄なレポート。
メーター付きハートは、必要に応じてではなく、変化に応じてではなく、時計(タイマー)で鼓動します。
2つの形態、1つの根本原因:正しい設計の代わりにスケジュールされたジョブが使われていることです。
形態1:状態修復
状態遷移がアトミックに完了しません。遷移を修正する代わりに、遅延付きでバックグラウンドジョブが実行され、調整が行われます。調整ウィンドウの間、ユーザーは壊れた状態を見ることになります。
GitHubの例 (2026-04-08): プルリクエストの上流リポジトリが非公開になりました。GitHubは状態遷移を試みました:PRをクローズし、ブランチのステータスを更新し、マージステータスをクリアする。この遷移はアトミックに完了しませんでした。PRのステータスには「branch-forced-closed」と「Merge status cannot be loaded」が同時に表示されました。数分後にSidekiqのバックグラウンドジョブが実行され、調整処理が完了しました。観測者はその間、破損した状態を見ることになりました。
The Metered Heart: Sidekiqジョブはスケジュールに従って実行されました。GitHubが破損状態を検知したからではなく、タイマーが作動したために実行されたのです。PRをリアルタイムで監視していたユーザーは、次のジョブ実行まで矛盾したPRを見ることになりました。
Form 2: Wasteful Report
レポートや集計処理が固定間隔でゼロから再計算されます。キャッシュチェックなし。冪等性ガードなし。増分更新なし。毎回の実行でフルスキャンを行います。
例: 毎晩のcronジョブが全注文を最初からスキャンして各ユーザーの購入総額を再計算する。日次の分析ジョブが生のイベントログからダッシュボードを再生成する。週次のサマリーメールがアクティビティテーブルの全行をクエリする。
これらは前回の実行以降にデータが変更されたかどうかにかかわらず実行されます。過去24時間にしか新しいデータがない場合でも、常に全履歴をスキャンします。増分設計の代わりにスケジュールによる繰り返しを採用しています。
The Shared Root
Metered Heartは自身の状態について真実を語ることができません。時計のことしか知らないのです。Form 1: 状態修復ジョブはT+0時点で状態が破損しているかどうかにかかわらず、T+5分に実行されます。Form 2: レポートジョブは前日以降にデータが変更されたかどうかにかかわらず、午前2時に実行されます。
時計は「何をすべきか」という情報を持っていません。その情報はイベントが運びます。たとえば「状態遷移が失敗した」「新しい注文が到着した」などです。Metered Heart はその情報を捨て去り、スケジュールに置き換えます。
資本の流出
Metered Heart は生きた資本を浪費します。壊れた状態のインシデントに対応するためエンジニアを常時待機させます。社会的信頼を損ないます。ユーザーは不整合なデータを見て、勝手に直る欠陥を報告します。他の MOAD を増幅します。壊れた状態を探すために全レコードをスキャンする状態修復ジョブは、MOAD-0001(O(N²) スキャン)を含みやすいです。コールドデータを再計算するレポートジョブは MOAD-0005(キャッシュスタンピード)を引き起こすことがあります。MOAD-0009 は他の欠陥を複合的に悪化させます。
共有された根
Form 1 と Form 2 は表面上は異なって見えます。一方は状態を修復し、もう一方はデータを再計算します。しかし根底にある原因は共通しています。
変化時に起動し、時計では起動しない
イベント駆動設計は、何かが変化したときに起動します。状態の変化がイベントです。イベントがトリガーです。
形式1:原子的な遷移が修復ジョブに取って代わる。
状態遷移がシステムを壊れた中間状態のままにする可能性がある場合、その欠陥は遷移自体に存在し、修復ジョブが存在しないことにはありません。遷移を原子的に(またはトランザクション的に)完了するように修正してください。遷移が原子的に完了すれば、壊れた状態は決して存在しません。修復ジョブが修復するものは何もありません。
# DEFECT: non-atomic transition leaves broken state
def close_pr_on_repo_private(pr_id):
pr = PR.get(pr_id)
pr.status = 'branch-forced-closed' # ステップ1: 部分的な状態
pr.save() # ユーザーに即時表示
# ... 以降のステップで失敗する可能性がある ...
pr.merge_status = 'not_applicable'
pr.save() # ステップ2: ここで一貫性が保たれる
# Sidekiqジョブがステップ2の失敗時にリカバリする
# FIX: アトミックな遷移; 中間状態は一切見えない
def close_pr_on_repo_private(pr_id):
with db.transaction():
pr = PR.get(pr_id)
pr.status = 'branch-forced-closed'
pr.merge_status = 'not_applicable'
pr.save() # 両方のフィールドがアトミックにコミットされる。半端な書き込みは決して起こらない
形式 2: 増分更新が完全再計算に置き換わる。
一から再計算するレポートは、古いデータ + 新しいデータ = 新しい結果、という理由で実行される。しかし、古い結果 + 差分 = 同じ新しい結果であり、増分的に計算される。イベント: 新しいデータが到着した。トリガー: その新しいデータに対する集計のみを更新する。
# 欠陥: スケジュールによる完全再計算
def nightly_totals_job():
for user in all_users():
total = sum(o.amount for o in user.orders) # すべての期間をスキャン
user.total_purchases = total
user.save()
# 修正: イベント駆動の増分更新
def on_order_placed(order):
order.user.total_purchases += order.amount # 差分のみ
order.user.save()
増分更新は、2 AMではなく注文が到着したタイミングで発火します。影響を受けるユーザーのみ更新し、全注文を読み込まず、新規注文のみを読み込みます。夜間バッチは不要になります。
Form 1 が不完全な遷移を明らかにする理由
Form 1 の Metered Heart は、状態遷移が不完全なまま放置されていたことを示します。修復ジョブが存在するのは、エンジニアが壊れた状態に気づき、遷移自体を修正するのではなく調整機構を追加したためです。この修復ジョブは、壊れたアーキテクチャ上の判断に対するパッチです。
MOAD-0009 が他の MOAD を増幅する
MOAD-0009 は他の MOAD を増幅します。全レコードをスキャンして壊れた状態を探す状態修復ジョブは、MOAD-0001(ジョブ実行ごとの O(N) または O(N²) スキャン)を引き起こします。すべてをゼロから再計算するレポートジョブは、MOAD-0005(ジョブ開始時に暖かい上流へアクセスする際のキャッシュ・スタンピード)を引き起こします。MOAD-0009 は単独で害を及ぼすだけでなく、他の MOAD を定期的に引き起こします。
診断と再設計
あるチームは毎日午前2時に cron ジョブを実行しています。このジョブは全ユーザーの全注文をスキャンし、各ユーザーの購入総額をゼロから再計算します。ジョブの実行には4時間かかり、午前6時までにダッシュボードに最新の合計が表示されます。午前2時から6時の間、ダッシュボードには前日の合計が表示されます。