Событие вместо поля: как журнал медиации вернул историю, которую система стирала

—

от автора

В феврале я написал три текста о том, что статусы не показывают процесс и что legaltech-платформа без событийной модели не масштабируется. В августе показал первый кейс: булево поле в схеме исполнительного производства, которое полгода лгало. Оба раза речь шла о дальнем конце конвейера, о приставах.

Теперь про центр. Досудебная медиация: между отправкой претензии и деньгами на счёте. Именно здесь живёт большинство дел, здесь работают люди отдела медиации, здесь крутится автоматика писем и автоматическое соглашение. И именно здесь у нас не было истории. Была витрина.

Ниже: как выглядела слепота, какие три варианта рассматривали, что построили за две недели, какие три бага поймали до и после выкладки и что журнал показал в первые же часы.

Семь следов и шесть дыр

Первое, что я сделал, когда сел за задачу: не проектировать, а провести перепись. Что система уже пишет о контактах с нарушителем и где это лежит.

Оказалось, следов семь. Журнал отправленных писем с отметкой открытия. Массив звонков внутри документа дела. Массив визитов личного кабинета нарушителя, который пишет не CRM, а сайт. Отдельная сайтовая коллекция аналитики кабинета. Массив тегов дела, человекочитаемая история этапов. Очередь исходящих писем, из которой документ удаляется после отправки. Dead-letter недоставленных писем.

Семь форматов, четыре коллекции, два репозитория, одна общая база. Виджет хронологии на карточке дела собирал из этого хаоса ровно три источника: теги, шесть плоских дат соглашения и визиты. Письма, звонки и переписка жили в других вкладках.

И шесть дыр, где данные гибли в момент записи.

Главная: история торга уничтожалась. Автоматическое соглашение позволяет нарушителю запросить скидку, сотруднику сделать встречное предложение, нарушителю принять или отказаться. Каждый раунд записывался в два поля: статус торга и встречная сумма. Следующий раунд их перезаписывал. Видно было только последний исход. Хуже: pre-save хук модели при смене статуса дела сбрасывал весь блок соглашения в исходное состояние. Дело ушло в суд, и вся история переговоров исчезла. Не заархивировалась. Исчезла.

Вторая дыра: СМС. Одна перезаписываемая отметка на дело: отправлено, дата, кем. Ни текста, ни истории повторных отправок, ни статуса доставки.

Остальные четыре скучнее: результаты судебных инстанций из внешней системы попадали только в консольный лог, реплики досудебной переписки размазаны по трём механизмам разных лет, общего аудит-лога нет, входящие письма привязаны к делу, но не к хронологии.

Я специально перечисляю. Когда в феврале я писал «нужна событийная модель», в голове была картинка чистого листа. В реальности события уже были. Просто в семи формах, с шестью течами, и никто не мог задать вопрос «что произошло с этим делом за месяц» одним запросом.

Три варианта

Вариант A: единый append-only журнал. Новая коллекция, один хелпер записи, точки эмита во всех местах, где что-то происходит, разовый перенос прошлого из семи существующих источников. Старые поля не трогаем, пишем в две стороны.

Вариант B: только чтение. Ничего не писать. Собирать ленту на лету из семи источников при открытии дела, воронку считать ночным кроном. Быстро, дёшево, ни одной новой точки записи.

Вариант C: гибрид. Новая коллекция только для того, что теряется (торг, СМС, внешние системы). Лента при чтении сшивает новую коллекцию со старыми источниками.

B отвергли сразу, и это важная развилка. Вариант B выглядит как «сначала посмотрим, потом решим». Но у него есть свойство, которое перевешивает всё: дыры остаются навсегда. Торг и СМС уничтожаются в момент записи. Если не начать их сохранять сегодня, завтра их не будет. Чтение ничего не спасает.

C оставили запасным. Его минус: каждая метрика становится вечной сшивкой шести форматов, и любой новый источник добавляет седьмой join в каждый отчёт.

Выбрали A. С двумя условиями от бизнеса: существующий виджет хронологии не убирать, прошлое подтянуть.

Семь источников событий, три дыры и один журнал

Семь источников событий, три дыры и один журнал

Модель

Коллекция из девяти полей. Дело, тип события, время, канал, источник, сотрудник, полезная нагрузка, ссылка на первоисточник, ключ дедупликации.

project   ссылка на делоtype      'email.sent' | 'negotiation.round' | 'settlement.reset' | ... (21 тип)at        время события, не время записиchannel   email | call | sms | lk | systemsource    crm | mediator | site | backfilluser      сотрудник, если применимоpayload   типоспецифичные данныеref       коллекция и id первоисточникаdedupKey  уникальный разреженный индекс

Актор здесь разложен на два поля: сотрудник и источник. Одно и то же действие могут совершить человек в CRM, внешний сервис медиации, сайт или бэкфилл, и для аналитики это разные вещи. Версии у события нет: в append-only журнале её заменяет следующее событие. Абстрактных типов ожидания в словаре тоже нет. Ожидание оказалось не событием, а расстоянием между двумя событиями. Его считают, а не пишут.

Пять решений в этой схеме, которые потом окупились.

Время события, а не время записи. Поле at отдельно от createdAt. Бэкфилл пишет прошлое с настоящими датами, догон записывает открытие письма с лагом, и лента всё равно сортируется правильно.

Источник как часть события. Одно и то же действие «скачал соглашение» может сделать нарушитель в кабинете, а может автомат. В сентябре это стало отдельным типом: автопилот соглашений пишет settlement.agreementGenerated, а не lk.agreementDownloaded. Иначе аналитика цепочек считала бы действие робота реакцией нарушителя.

Append-only без исключений. Документы не обновляются и не удаляются. Ошибочное событие гасится компенсирующим. За полтора месяца компенсирующее не понадобилось ни разу, но правило стоит.

Fail-open. Событие это телеметрия. Хелпер записи оборачивает всё в try/catch, дубль по ключу дедупликации молча пропускается, любая другая ошибка уходит в лог, но не валит бизнес-операцию. Потерять одно событие дешевле, чем не отправить письмо.

Хуки вместо охоты за вызовами. Звонки и СМС к тому моменту уже писались в отдельную append-only коллекцию попыток контакта. Вместо того чтобы искать все места её создания, повесили post-save хук на модель: любая попытка контакта, откуда бы она ни пришла, порождает событие. Для тегов дела сделали наоборот: push в массив тегов был разбросан по коду, ввели одну функцию-обёртку, механически перевели все места на неё и записали в правила проекта: прямой push запрещён. Сегодня таких точек полсотни в восемнадцати файлах.

Самая ценная точка эмита оказалась самой неприятной: pre-хук сброса соглашения. Тот самый, что уничтожал историю. Теперь перед обнулением он снимает полный снапшот стираемого блока и пишет settlement.reset с этим снапшотом в payload. Уничтожение стало событием.

Перенос прошлого

Бэкфилл гнали одним скриптом на прод-машине, ночью. Идемпотентность через детерминированный ключ: имя коллекции-источника, id документа, при необходимости имя поля. Повторный запуск ничего не дублирует, это проверили трижды, потому что запускать пришлось трижды.

Первый прогон: 429 962 попытки вставки, 428 349 записей. Разницу в 1,6 тысячи отсёк уникальный индекс, это дубли внутри самих источников.

Разбивка по типам сказала о процессе больше, чем любая презентация. Отправленных писем 192 тысячи. Смен статуса 177 тысяч. Оплат 16 тысяч. Визитов кабинета 16 тысяч. Входящих звонков 10 тысяч. Торга: ноль. Не потому что торга не было. Потому что его негде было взять.

Одна деталь про границу. Живые эмиты соглашения и оплат не имели ключа дедупликации, они пишутся один раз при действии. Бэкфилл для этих типов ограничили меткой времени деплоя: всё, что случилось после выкладки, уже записал живой код. Без этой границы окно между деплоем и ночным прогоном дало бы дубли. К этому окну мы ещё вернёмся.

Что пошло не так

Индекс, который ослепил бы слой до первой записи

Ключ дедупликации нужен не всем событиям. Живой эмит раунда торга пишется один раз, ему ключ не нужен. Поэтому в схеме стояло dedupKey: { type: String, default: null } и уникальный разреженный индекс.

Разреженный индекс в MongoDB пропускает документы, у которых поля нет. Но default: null создаёт поле. Со значением null. Которое индексируется. Первое событие без ключа записалось бы, второе упало бы с ошибкой дубликата, а хелпер, помня о fail-open, молча проглотил бы её как «дубль, норма». Слой записал бы ровно одно событие каждого бесключевого типа и дальше ослеп, не сообщив об этом никому.

Поймали на ревью до выкладки. Исправление: убрать default, поле не создаётся, пока ключ не передан явно. Урок стоит записать отдельно: fail-open плюс тихая обработка дубликатов равно система, которая не умеет сказать, что ей плохо. Оба свойства оставили, они нужны. Но теперь я знаю, где в этой паре спрятан нож.

Сверка, которая прошла и ничего не проверила

Перед переключением старого виджета хронологии на новую коллекцию сравнили сто случайных дел: старый источник против новой ленты с фильтром по типам. Сто из ста сошлись один в один. Переключили.

Первый же смоук после переключения на деле с торгом показал дыру: дата завершения торга не бэкфилилась. Раунды и исход отсутствовали в ленте, хотя в старом виджете дата была.

Случайная выборка не могла этого поймать. Дел с торгом около двух процентов. В ста случайных делах их ожидаемо два, а с учётом того, что торг сосредоточен в свежих делах, могло не быть ни одного. Сверка была честной и бесполезной одновременно.

Исправление вышло в тот же день: тип исхода по последнему статусу торга, повторный идемпотентный прогон, плюс 857 событий. Урок: случайный сэмпл проверяет типичное. Редкие ветки нужно проверять направленным сэмплом, и список редких веток надо составить до сверки, а не после.

Несимметричные ключи

Для писем, звонков и визитов живой хук и маппер бэкфилла строят ключ одинаково, от id первоисточника. Повторный прогон по ним безопасен по построению. Но у событий соглашения и оплат живой эмит бесключевой: они пишутся один раз при действии, первоисточника в виде отдельного документа нет. Бэкфилл для них ключ строил от дела и имени поля. Два пути, одно событие, разные ключи. Окно между деплоем и ночным прогоном дублировалось ровно по этим типам.

Это дефект, который не виден при просмотре каждой задачи по отдельности. Живой эмит написан правильно. Маппер бэкфилла написан правильно. Вместе они несимметричны. Поймали на финальном ревью всей ветки перед PR, отсюда та самая граница по метке деплоя. С тех пор правило: у типа события либо ключ, одинаковый для живого кода и бэкфилла, либо явная временная граница, за которую бэкфилл этот тип не заходит.

43 процента реакций до претензии

Через четыре дня после запуска слоя запустили аналитику: когортная воронка и цепочки касаний. Претензия, открытие письма, визит кабинета, контакт, торг, соглашение, оплата. Плюс гистограмма «дней до первой реакции нарушителя».

Гистограмма показала, что 43 процента первых реакций происходят до даты претензии.

Первая мысль: баг в датах. Вторая, после проверки дат: баг в бэкфилле. Третья, после разговора с процессом: это не баг. Бумажной претензии предшествует нулевое письмо, триггером которого служит загрузка протокола фиксации нарушения. Оно уходит раньше претензии на дни и недели, и нарушители на него отвечают. Автоматика писала его годами, аналитика о нём не знала, потому что точкой отсчёта воронки была дата претензии.

Нулевое письмо вынесли в отдельную колонку хронокарты. В когорте августа в этой колонке 421 отправленное письмо, 127 ответов, 25 визитов кабинета и 8 входящих звонков. До запуска журнала этот этап процесса для аналитики не существовал.

Результат

Полтора месяца спустя коллекция выглядит так.

Всего событий: 539 703 по 42 412 делам. Из них бэкфилл 457 773, живых записей CRM 44 429, догонов с сайта 35 967, событий от внешнего сервиса медиации 1 534.

Живые события в день, сентябрь 2026

Живые события в день, сентябрь 2026

В будний день слой пишет от 800 до 2,3 тысячи живых событий. В выходные от 13 до 64. Сама эта разница уже диагностика: процесс живёт в рабочие дни, и любой сигнал в субботу это либо нарушитель в кабинете, либо сбой.

Одно уточнение про единицу счёта. Журнал медиации считает контакты: письмо, его открытие, ответ, визит кабинета, звонок, СМС, каждый раунд торга, соглашение, оплату. Задачи сотрудников, документы, судебные стадии и исполнительное производство в него не входят, у них свои журналы, и о некоторых из них я уже писал. Поэтому «событие» здесь всегда означает событие контакта, и все цифры выше про этот срез.

Что это дало сверх хронологии:

  • Старый виджет на карточке дела переключён на журнал, и история соглашения переживает сброс. Раньше дело, ушедшее в суд, теряло всю досудебную переписку по торгу.

  • Когортная воронка живёт как страница. Когорта мая: 999 претензий, 449 открытий, 496 визитов кабинета, 702 контакта, 169 торгов, 493 соглашения, 495 оплат. Раньше такая таблица была разовым скриптом на прод-машине.

  • Цепочки касаний: сколько писем, звонков и СМС до первой реакции, какой канал сработал. Показатель реакции по каналам считается только для диапазонов целиком после запуска, чтобы не смешивать бэкфилл с живыми данными.

  • Автопилот соглашений, запущенный в сентябре, пишет своё действие отдельным типом события, и аналитика цепочек не путает робота с реакцией нарушителя.

Что осталось

Список честный, как в прошлый раз.

  • История торга до 7 августа потеряна навсегда. Бэкфилл вернул письма, звонки и визиты, но перезаписанные раунды не вернуть ничем.

  • Отметка открытия письма попадает в журнал один раз, при первом догоне. Повторные открытия того же письма в него не попадают.

  • Догон визитов и открытий работает раз в час с окном в три часа. Если планировщик лежал дольше, события за этот интервал теряются до повторного бэкфилла.

  • Сайт по-прежнему пишет визиты и открытия в свои поля, а слой их догоняет. Нативные эмиты с сайта не сделаны, догон это заплатка.

  • Компенсирующих событий нет ни одного, механизм не проверен в бою.

  • Пагинация ленты без вторичного ключа сортировки: два события в одну миллисекунду могут поменяться местами между страницами.

Что забрать с собой

Перепись перед проектированием. Событий в системе почти всегда больше, чем кажется, и меньше, чем нужно. Семь следов и шесть дыр стали спекой сами по себе.

Вариант «только чтение» не бывает бесплатным, если данные гибнут в момент записи. Отложенное решение здесь равно решению не сохранять.

Уничтожение это тоже событие. Самая полезная точка эмита оказалась в хуке, который стирал данные.

Сверка случайной выборкой проверяет типичное. Редкие ветки ищут направленно, и список редких веток пишут до сверки.

Следующая статья про детектор зависших дел, обещанный ещё в августе. Там свой сюжет: как первая версия порога пометила почти все дела разом и почему в юридическом процессе отказ выглядит не как ошибка сервера, а как тишина.

ссылка на оригинал статьи https://habr.com/ru/articles/1086838/