Форма 1: Восстановление состояния. Форма 2: Расточительный отчёт.
Сердце по расписанию бьётся по часам. Не по необходимости. Не по изменению. По таймеру.
Две формы, одна корневая причина: запланированная задача подменяет собой правильный дизайн.
Форма 1: Восстановление состояния
Переход состояния не завершается атомарно. Вместо исправления перехода фоновая задача запускается с задержкой и выполняет согласование. Пользователи видят повреждённое состояние в течение окна согласования.
Пример GitHub (2026-04-08): Приватный апстрим репозитория pull request. 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+5 минут независимо от того, сломано ли состояние в T+0. Form 2: задача отчёта запускается в 2:00 ночи независимо от того, изменились ли данные со вчерашнего дня.
Часы не несут информации о том, что нужно делать. Эту информацию несёт событие: «переход состояния только что провалился», «поступили новые заказы». Metered Heart отбрасывает эту информацию и заменяет её расписанием.
Утечка капитала
Metered Heart приводит к утечке живого капитала: инженеры дежурят по инцидентам с повреждённым состоянием. Разрушает социальное доверие: пользователи видят несогласованные данные и сообщают о дефектах, которые сами собой исчезают. Усиливает другие MOAD: задача восстановления состояния, сканирующая все записи для поиска повреждённого состояния, часто содержит MOAD-0001 (сканирование O(N²)). Задача отчёта, пересчитывающая холодные данные, может вызвать MOAD-0005 (cache stampede). MOAD-0009 усиливает другие дефекты.
Общий корень
Form 1 и Form 2 внешне выглядят по-разному: одна чинит состояние, другая пересчитывает данные. Их связывает общая корневая причина.
Срабатывание по изменению, а не по часам
Событийно-ориентированный дизайн срабатывает при изменении состояния. Изменение состояния и есть событие. Событие — это триггер.
Форма 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() # шаг 2: теперь консистентно
# Sidekiq-задача восстанавливает согласованность, если шаг 2 не выполнен
# ИСПРАВЛЕНИЕ: атомарный переход; промежуточное состояние не видно
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 часа ночи. Оно обновляет только затронутого пользователя. Оно читает только новый заказ, а не все заказы за всё время. Ночной джоб исчезает.
Почему Форма 1 указывает на сломанную транзакцию
Счётчик «Сердце по запросу» (Form 1) показывает, что переход состояния остался незавершённым. Ремонтный джоб существует потому, что инженер заметил сломанное состояние и добавил механизм согласования вместо исправления самого перехода. Ремонтный джоб — это заплатка поверх ошибочного архитектурного решения.
MOAD-0009 как усилитель
MOAD-0009 усиливает другие MOAD. Джоб восстановления состояния, который сканирует все записи в поисках сломанного состояния: MOAD-0001 (сканирование O(N) или O(N²) при каждом запуске). Джоб отчёта, который пересчитывает всё с нуля: MOAD-0005 (cache stampede при старте джоба и обращении к тёплому upstream). MOAD-0009 вредит не только сам по себе — он регулярно доставляет и другие MOAD.
Диагностика и редизайн
Команда запускает ночной cron-джоб в 2 часа ночи. Джоб сканирует все заказы всех пользователей и пересчитывает общую сумму покупок каждого пользователя с нуля. Джоб работает 4 часа. К 6 утра дашборд показывает актуальные суммы. Между 2 и 6 часами ночи дашборд показывает вчерашние суммы.