Form 1: State-Repair. Form 2: Wasteful Report.
A Metered Heartは、必要に応じて、変化に応じて、時計に従ってビートします。
二つの形態、共通の根本原因:正しい設計に代わってスケジュールされたジョブが置き換わっています。
Form 1: State-Repair
状態遷移が原子的に完了しません。代わりに、バックグラウンドジョブが遅延後に整理されます。ユーザーは、整理期間中に破損した状態を見ることができます。
GitHub例(2026-04-08): プルリクエストのアップストリームリポジトリがプライベートになりました。GitHubは、状態遷移を試みました:PRをクローズし、ブランチステータスを更新し、マージステータスをクリアします。遷移は原子的に完了しません。PRステータスは、'branch-forced-closed'と'マージステータスが読み込めません'と同時に表示されました。Sidekiqのバックグラウンドジョブが数分後に実行され、整理が完了しました。観察者は、整理期間中に破損した状態を見ることがでした。
メーターされた心臓:Sidekiqジョブは、GitHubが破損した状態を検出するかどうかにかかわらず実行されませんでした。タイマーが発火したため実行されました。リアルタイムでPRを監視しているユーザーは、次のジョブ実行まで、PRが矛盾するものとして表示されました。
Form 2: Wasteful Report
レポートや集計は、定期的な間隔でスクラッチから再計算されます。キャッシュチェックなし。重複排除のガードなし。インクリメンタルアップデートなし。各実行:フルスキャン。
例:毎晩のcronジョブが、すべてのオーダーの開始から現在までの全ての購入総額を再計算します。毎日の分析ジョブが、rawイベントログからダッシュボードを再生成します。毎週の概要メールが、活動テーブルのすべての行をクエリします。
それぞれが、最後の実行以来データが変更されたかどうかにかかわらず、実行されます。それぞれが、過去24時間に新しいデータがある場合でも、全歴史をスキャンします。それぞれが、インクリメンタルデザインに代わってスケジュールされた繰り返しを使用しています。
共通の根本
メーターされた心臓は、自分の状態について真実を語ることができません。時計だけを知っています。Form 1: 状態修復ジョブは、状態がT+0で破損しているかどうかにかかわらず、T+5分後に実行されます。Form 2: レポートジョブは、昨日のデータが変更されたかどうかにかかわらず、毎朝2時に実行されます。
時計は、どのようなことが必要かについての情報を持ちません。イベントは、その情報を持ちます:'最近の状態遷移が失敗しました'、'新しいオーダーが到着しました'。メーターされた心臓は、その情報を捨てて、スケジュールに置き換えます。
資本流失
A Metered Heart は、生存資本を抽出します:エンジニアは破損状態のインシデントに応答してお手伝いを受けます。社会信頼を侵食します:ユーザーは不一致のデータを見ることができ、自動的に解決される欠陥を報告します。他の MOAD を拡大します:状態修復ジョブがすべてのレコードを検索して破損状態を探すことが多い場合は、MOAD-0001(O(N²) スキャン)が含まれます。レポート ジョブが冷たい再計算をトリガーする場合、MOAD-0005(キャッシュスタンプ)が発生します。MOAD-0009 は他の欠陥を複数化します。
共通の根源
Form 1 と Form 2 は表面上異なります:一つは状態を修復し、もう一つはデータを再計算します。根源はそれらを繋いでいます。
変更に応答して時計に応答せず
イベント駆動デザインは、変更が発生したときに実行されます。状態変更がイベントです。イベントがトリガーです。
Form 1:原子遷移が修復ジョブに置き換わる。
状態遷移がシステムを破損した中間状態に残す場合、その欠陥は遷移にあるではなく、修復ジョブの欠如にある。遷移を完全に原子的に(またはトランザクション的に)修正し、遷移が原子的に完了するようにします。遷移が原子的に完了する場合、破損した状態は存在しない。修復ジョブは何も修正することがない。
# 欠陥:非原子的な遷移が破損した状態を残す
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() # step 2: now consistent
# Sidekiq job reconciles if step 2 fails
# FIX: atomic transition; no intermediate state visible
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() # both fields commit atomically; never half-written
Form 2: the incremental update replaces the full recompute.
A report that recomputes from scratch fires because old data + new data = new result. But the old result + delta = same new result, computed incrementally. The event: new data arrived. The trigger: update the aggregate for the new data only.
# DEFECT: full recompute on schedule
def nightly_totals_job():
for user in all_users():
total = sum(o.amount for o in user.orders) # scan all time
user.total_purchases = total
user.save()
# FIX: event-driven incremental update
def on_order_placed(order):
order.user.total_purchases += order.amount # delta only
order.user.save()
The incremental update fires when an order arrives, not at 2 AM. It updates only the affected user. It reads only the new order, not all orders from all time. The nightly job disappears.
Why Form 1 Reveals a Broken Transition
A Form 1 Metered Heart reveals that a state transition was left incomplete. The repair job exists because an engineer noticed broken state & added a reconciliation mechanism rather than fixing the transition. The repair job: a patch over a broken architectural decision.
MOAD-0009 as Amplifier
MOAD-0009 amplifies other MOADs. A state-repair job that scans all records to find broken state: MOAD-0001 (O(N) or O(N²) scan per job run). A report job that recomputes everything cold: MOAD-0005 (cache stampede when the job starts & hits a warm upstream). MOAD-0009 does not just harm by itself; it delivers other MOADs on a schedule.
診断&リデザイン
チームは毎晩2時にcronジョブを実行します。ジョブはすべてのユーザーのすべての注文をスキャンし、ユーザーの総購入額をゼロから再計算します。ジョブは4時間かかります。6時までにダッシュボードに最新の合計が表示されます。2時間から6時間の間、ダッシュボードは昨日の合計を表示します。