DarkRiDDeR15 мин

Разбор расхождения данных: как собрать evidence до компенсации между сервисами

ОтладкаДанныеАрхитектура

Симптом в поле обычно приходит коротким сообщением: «заказ отменён здесь, но всё ещё висит там». Цена ручного исправления тоже короткая: оператор меняет статус во второй системе, повторяет consumer, а потом не может объяснить, какой event был пропущен и успела ли компенсация сработать. Если рядом находится действие вроде отгрузки, доступа или возврата, такой разбор может создать второй эффект. Начинать нужно не с кнопки replay и не с общего вопроса «почему данные не синхронны», а с пакета evidence для одного object id.

Возьмём учебный order-417. Owner order-service уже записал cancelled v3. Projection fulfillment пока хранит awaiting-reservation v2. Такое окно возможно, если v3 была получена раньше v2 и consumer корректно отложил её, либо если нужная доставка ещё не применена. В этой статье нет production incident и настоящих заказов: значения созданы fixture в памяти. Но порядок доказательств переносится в любой стек, потому что он не зависит от названия очереди или базы.

Evidence packet должен уместиться в одну проверку

Для первого разбора достаточно пяти связанных фактов. orderId связывает все записи. Owner state и version отвечают, какое решение уже принято. Projection state и version показывают, какую часть истории видел consumer. Event id и source позволяют проверить конкретную доставку. Compensation key говорит, было ли уже принято повторяемое решение по отказу. Если в пакете есть только два статуса, его нельзя отличить от разных объектов, старого snapshot или повторного message. Если в пакете нет версии, невозможно понять, что именно consumer должен догнать.

const evidencePacket = {
  orderId: 'order-417',
  owner: { state: 'cancelled', version: 3 },
  projection: { state: 'awaiting-reservation', version: 2 },
  delayedEvent: 'evt-order-417-cancelled-v3',
  compensationKey: 'compensation:order-417:reservation:v2',
};

// Такой пакет позволяет проверять gap, а не угадывать по одному статусу.
Минимальная диагностика расхождения одного заказа
НаблюдениеФакт, который ищемБезопасное действиеЧто не делать
owner v3, projection v2есть ли event v3 и причина его задержкисохранить gap и найти v3 delivery или replay по контрактупереписать projection строкой cancelled
v3 пришла раньше v2ожидалась ли v2 и сохранён ли deferred eventприменить v2, затем replay v3применить v3 поверх v1 без проверки
один event id виден дваждыесть ли он в consumer ledgerподавить повтор и проверить отсутствие нового state transitionсчитать duplicate новой компенсацией
другой event имеет уже занятую versionсовпадают ли source, id и rule переходаотклонить конфликт, сохранить evidence и передать в owner routeприменить второй event по времени получения
compensation key уже естьсовпадают ли order id, version и reasonпоказать существующее решение owner-асоздать вторую отмену по тому же входу
owner cancelled, projection readyToShipравны ли version и evidence резервасразу блокировать следующий шаг и собрать packetдоверять одному локальному флагу готовности

Последняя строка — важная защита от «согласовали позже». Если owner уже отменил заказ, а projection готова к отгрузке, сначала блокируется рискованное действие. Затем собираются версии и event ids. Ручной update projection может быть частью утверждённого восстановления, но только после того, как сохранено доказательство, почему нормальный consumer не принёс этот state. Иначе следующий разбор начнётся с ещё более бедных данных: прежний gap уже будет затёрт.

Сначала отличаем gap от другой причины

Расхождение статусов не всегда означает задержку события. Projection может читать другой object id, отфильтровать событие по schema, записать version, но не записать локальный effect, или получить event из другого source. Поэтому diagnosis начинается с version sequence. Если owner version больше projection version, есть недостающий переход или отложенный вход. Если версии равны, но states противоречат, ищем правило трансформации и effect evidence; второй event с занятой version не применяем по времени получения. Если event id уже отмечен применённым, но projection не менялась, это отдельный дефект consumer-а: возможно, ledger записан раньше effect. Называть все три случая «eventual consistency» означает потерять следующий точный вопрос.

Вертикальная схема диагностики: от разных статусов одного order id через сбор owner версии, projection версии, event id и compensation key; version gap ведёт к поиску или контролируемому replay, конфликт одинаковой версии — к проверке трансформации, а рискованный следующий шаг блокируется до evidence
Диагностика держит порядок: сначала блокируем риск, затем сохраняем evidence, только после этого выбираем replay, correction или ручное решение.

Для v3 раньше v2 безопасная реакция не «подождать немного». Consumer сохраняет v3 как deferred вместе с expected version 2. Это уже evidence: вход не исчез и не был принят как конечное состояние. После v2 можно replay-ить v3 и проверить, что version монотонно выросла. Если v2 не приходит по правилам вашего transport, нужен отдельный контракт fetch или manual route. Учебный модуль не выбирает между ними, потому что не запускает broker. Он показывает только, что пропуск нельзя замаскировать новым status update.

Компенсация требует причины и версии

В нашем примере cancelled v3 возникает не от любого сбоя. Owner получает training-reservation-rejected, проверяет orderId и исходную version 2, затем записывает одну compensation запись. Это новое доменное решение, а не удаление paid v2 из истории. Если внешний вызов вернул timeout, такого evidence недостаточно: резерв мог успеть состояться. Тогда автоматическая отмена будет предположением, которое может противоречить внешнему состоянию. В field-разборе это правило легко проверить: у причины есть тип, у входа есть версия, у решения есть ключ.

function compensatePaidOrder(order, rejection, ledger) {
  const key = 'compensation:' + order.id + ':reservation:v' + order.version;
  if (ledger.has(key)) return { state: 'already-recorded', key };
  if (order.status !== 'paid' || rejection.reason !== 'training-reservation-rejected') {
    return { state: 'manual-review' };
  }
  const next = { ...order, status: 'cancelled', version: order.version + 1 };
  ledger.set(key, next);
  return { state: 'compensation-recorded', order: next, key };
}

Повтор compensation key сам по себе не доказывает, что внешний эффект отменён. Он доказывает только, что owner второй раз не создал такое же решение в своей boundary. Дальше смотрим, кто владеет внешним действием, как он принимает отмену и какое evidence возвращает. В учебной fixture этого механизма нет. Поэтому карточка разборщика не должна закрываться текстом «компенсация запущена». Ей нужен следующий факт: новое owner state, событие версии 3, состояние consumer-а и отдельный результат внешнего шага, если он вообще был частью задачи.

Duplicate и stale — не повод чистить ledger

Повтор evt-order-417-cancelled-v3 после его применения должен дать duplicate-event-suppressed. Повтор v2 после v3 тоже не должен возвращать projection назад. Другой event с уже занятой version должен дать same-version-event-rejected, а не переписать state. В fixture эти случаи проверяются отдельно, потому что duplicate относится к event id, stale — к меньшей version, а совпавшая version с другим event — к конфликту контракта. В реальном consumer хранение ledger и самой projection должно иметь свой транзакционный или иной проверяемый контракт. Если сначала пометить event applied, а потом потерять update проекции, появится сложный случай «ledger говорит да, состояние говорит нет». Его не исправляет удаление ledger без разбора: можно снова выполнить уже совершённый effect.

function chooseTrainingAction(packet) {
  if (packet.owner.version > packet.projection.version) {
    return { action: 'find-or-replay-missing-version', automaticMutation: false };
  }
  if (packet.owner.state === 'cancelled' && packet.projection.readyToShip) {
    return { action: 'block-shipping-and-check-owner-evidence' };
  }
  return { action: 'compare-event-id-and-compensation-key' };
}

Функция не выполняет replay и не меняет данные. Она выбирает следующую проверку. Это полезнее автоматической команды в момент нехватки evidence. Когда owner version больше projection version, сначала ищем или контролируемо воспроизводим недостающий переход. Когда owner cancelled, а projection хочет продолжать, сначала блокируем действие. Когда версии равны, сравниваем event id, transform rule и compensation key. Так у каждого исхода появляется конкретный владелец следующего шага, а не один общий канал с надписью «синхронизация».

Проверяем разрешение на следующее действие

Инвариант поля должен быть виден не только в диаграмме. Перед отгрузкой, доступом или другим эффектом projection сравнивает owner state, свою version и локальное evidence. Равенство двух слов paid ничего не гарантирует: версия могла измениться между чтениями, резерв мог быть отменён, а projection могла быть построена из другого source. В учебном контракте разрешение появляется только при paid, равной версии, reserved и явном evidence. После cancelled v3 функция возвращает false даже если старый UI-флаг ещё не очищен.

function mayShip(owner, projection) {
  return owner.state === 'paid'
    && projection.state === 'reserved'
    && owner.version === projection.version
    && projection.reservationEvidence === true;
}

// Равенство статусов без версии и evidence не является разрешением на действие.

Это не совет внедрять одну универсальную проверку для всех сервисов. В одних доменах действие обратимо и его можно задержать. В других нужен manual decision, компенсация или другой business rule. Но формулировка «какой следующий шаг запрещён без evidence» почти всегда точнее, чем требование мгновенной одинаковости всех копий. Она позволяет объяснить пользователю временный status и не позволяет фоновому consumer-у совершить дорогой шаг только потому, что он увидел старую проекцию.

Маршрут полевого разбора

  1. Остановить рискованное следующее действие для одного object id: отгрузку, доступ, списание или другой effect. Не чистить state и ledger до сохранения facts.
  2. Собрать owner state и version, projection state и version, source + event id, compensation key и reason. Зафиксировать, откуда получен каждый факт.
  3. Сравнить версии. При owner > projection искать missing или deferred event; при одинаковых версиях проверять transform rule и local effect evidence, а второй event с той же version не применять автоматически.
  4. Проверить consumer ledger отдельно от projection. Повтор event id и stale version имеют разные причины и не должны иметь одну кнопку удаления.
  5. Проверить компенсацию: известен ли reason, совпадает ли исходная version, есть ли только один key и какое новое owner state создано.
  6. Выбрать действие по контракту: controlled replay после evidence, fetch missing transition, correction с журналом или manual decision. Не повторять unknown external failure вслепую.
  7. После восстановления добавить fixture или интеграционный test именно для найденной границы: gap, duplicate, stale event, ledger/effect split или external uncertainty.

Что проверено источниками, а что остаётся границей

CloudEvents 1.0.1 помогает именовать контекст конкретного события, PostgreSQL 13 документирует локальную изоляцию и уникальные conflict paths, Kafka 2.7 ограничивает область producer idempotence. Из этих фактов не следует единая «правильная» реализация compensation. Паттерны data consistency существуют как инженерские приёмы, а не как один стандарт с владельцем. Поэтому статья не обещает, что version + Map гарантируют доставку, и не приписывает апрелю 2021 года зрелость готовой distributed platform.

Здесь не выполнялись real database, broker, external reserve, HTTP, browser, CI, production build, deployment или screen reader. Fixture не содержит персональных данных, не измеряет lag и не имитирует реальную очередь. Следующий шаг — взять один тестовый object id и пройти маршрут с фактическими storage и transport: от owner update до ledger consumer-а и запрета следующего effect. Если нужного evidence нет, безопаснее оставить запись в разборе, чем синхронизировать статусы на глаз.

Проверяемые источники

  • CloudEvents Specification v1.0.1: release record — официальная карточка выпуска, опубликованного в декабре 2020 года; поля id, source и type служат словарём учебного конверта, но fixture не является реализацией CloudEvents
  • PostgreSQL 13: Transaction Isolation — версионная документация PostgreSQL 13, доступная к апрелю 2021 года; описывает границы локальной транзакции и необходимость retry при serialization failure
  • PostgreSQL 13: INSERT и ON CONFLICT — официальный синтаксис и семантика локального уникального ограничения; пример ниже не утверждает атомарность между несколькими сервисами
  • Apache Kafka 2.7.0: KafkaProducer API — versioned API линии 2.7, существовавшей в апреле 2021 года; ограничивает идемпотентность producer одной session и не устраняет повторную отправку прикладным кодом