В статье про юридический техдолг я писал, что он копится незаметно и вылезает через искажённые данные. Теперь покажу, как это выглядит вблизи: одно булево поле в схеме, 142 записи, и система, которая полгода уверенно сообщала неправду.
Мы делаем платформу защиты авторских прав. Дальний конец процесса — взыскание денег по выигранному суду через приставов. Это конвейер: запросили исполнительный лист, получили, отправили, вручили, возбудили производство, дальше либо деньги, либо обжалование бездействия. У каждого шага — свой документ, свой срок и своя вероятность заглохнуть.
Летом мы переписали модель данных этого конвейера. Ниже — что было сломано, какие развилки прошли и во что обошлись ошибки.
Флаг вместо истории
Три проблемы в исходной схеме, все — один и тот же класс.
Разбирательство было единственным. Блок обжалования бездействия пристава лежал плоскими полями в корне документа взыскания: суд, судья, даты, результат. Второе разбирательство по тому же взысканию затирало первое.
Это не редкий случай. Обжаловать приходится многократно: подали жалобу, получили формальный ответ, подали снова. Схема допускала ровно одну попытку, и вся предыдущая история просто исчезала при вводе новой.
Постановления хранились где придётся. Возбуждение, отказ, окончание производства — это отдельные документы со сканами. Скан лежал в облачном хранилище, дата — в легаси-поле, истории версий не было. Перезалили исправленный скан — предыдущий пропал.
Отказ в возбуждении был булевым флагом. Вот здесь начинается интересное. Поле refusal , 142 записи со значением true . Мы посмотрели на эти 142 записи и увидели, что у 104 из них уже есть номер возбуждённого производства.
То есть в 104 случаях из 142 отказ давно преодолён: специалист устранил замечание, подал повторно, производство возбуждено, работа идёт. А флаг стоит и продолжает сигналить. Дайджест каждое утро аккуратно докладывал о проблемах, которых нет.
Статусная машина, которую никто не выключал
Отдельный сюжет — статусы. Конвейер задумывался как честный pipeline с переходами. По факту легаси-обработчики писали булевы флаги мимо поля статуса.
Что нашли при инвентаризации:
-
Четыре с лишним тысячи взысканий в статусе «закрыто» с невычищенным флагом «в работе». Два источника правды, оба используются, оба врут по-разному.
-
Три значения
enum, под которые не подходил ни один документ. Мёртвые ветки, которые исправно обрабатывались в коде и покрывались условиями в шаблонах. -
Один и тот же исход, записанный двумя строками: «Отказ» и «Отказано». 75 и 422 записи соответственно. Любая выборка по исходу молча теряла часть данных.
Последний пункт особенно нагляден. Никто не вводил это руками — просто в разное время разные обработчики писали свою строку. Разнобой рос сам по себе: между инвентаризацией и выкаткой миграции добавились ещё две записи «Отказ».
Как чинили
Три слоя, снизу вверх.
Слой первый: разбирательство как массив
Плоские поля заменили массивом субдокументов. Тип разбирательства, суд, судья, даты отправки, подачи и регистрации, определения, результат. API — создание кейса плюс точечные ручки на каждое поле, все через атомарное присваивание по позиционному оператору.
Бэкфилл идемпотентный, с обязательным сухим прогоном. Старые плоские поля превратились в «кейс номер один», сами поля остались на месте нетронутыми.
Это важная развилка. Двадцать шесть генераторов юридических документов читают плоские поля. Переписывать их в том же релизе — значит смешать миграцию данных с миграцией потребителей и потерять возможность откатиться. Мы оставили двойную запись и подключили генераторы позже, отдельным диспетчером: данные разбирательства накладываются на документ в памяти, сами генераторы не правились ни строкой.
Массив субдокументов, а не отдельная коллекция — потому что разбирательств на взыскание единицы, читаются они всегда вместе с родителем, и позиционный доступ снимает вопрос транзакций.
Слой второй: постановления как append-only журнал
Массив постановлений на взыскании. Каждое — логический документ с типом (возбуждение, отказ, окончание, иное) и вложенным массивом версий. Файлы в объектном хранилище, актуальная версия — последняя, запись только через атомарное добавление. Удаления нет.
Отсутствие удаления — осознанное решение. Постановление пристава это внешний юридический факт. Оно может быть отменено, оспорено, заменено — но не может исчезнуть из истории, потому что на него ссылаются наши собственные документы и сроки.
Оспариваемое постановление привязывается к разбирательству ссылкой. Дата зеркалится в легаси-поле — снова ради тех же генераторов.
Фундамент сознательно сделали тонким: сроки в него не зашивали. Проверка сработала — следующий релиз добавил расчёт срока повторного предъявления исполнительного листа, не тронув фундамент ни строкой. Срок зависит от основания окончания производства, два месяца или шесть, с прижатием к последнему дню месяца. До этого его считали в голове.
Слой третий: отказ становится событием
Здесь была настоящая развилка. Три варианта, что делать с флагом отказа.
А. Сделать отказ терминальным статусом. Красиво ложится в pipeline, но требует отдельной ветки возврата.
Б. Оставить булев флаг, дописать логику вокруг. Дёшево.
В. Отказ — это постановление в журнале, а признак «отказ актуален» вычисляется. Дороже, схема сложнее.
Спор решили данные. 104 из 142 отказов уже преодолены. Значит отказ по своей природе не конечное состояние, а событие в жизни взыскания — как любое другое постановление. Терминальный статус описывал бы редкий случай как основной.
Выбрали вариант В. Признак актуальности отказа перестал храниться и стал вычисляться: для новых записей — по журналу постановлений, для 142 легаси-записей — по правилу «флаг стоит, номера производства нет, дело не закрыто».
Отдельно отмечу: 142 записи мы не мигрировали. Вычисляемое правило дешевле и честнее миграции, которая может ошибиться на краевых случаях и не откатывается.
Заодно появился журнал смены статусов и единый хелпер записи во всех точках. В нём идиома, которую стоит украсть: keepIf — обновить статус, только если новое значение не откатывает прогресс назад. Легаси-обработчики теперь пишут флаг и статус одновременно, так что рассинхрон перестал расти.
Что пошло не так
Регрессия через двадцать часов
Первый релиз с массивом разбирательств прошёл ревью и уехал в прод. Через двадцать часов модальное окно добавления производства начало отдавать 500.
Причина: бэкфилл записал в поле типа кейса значение null — а null не входил в enum. Больше двух тысяч записей стали невалидными при попытке обновления.
Урок в одну строку: если у поля enum стоит default: null , то null обязан быть в списке допустимых значений явно. Схема молчала, потому что запись шла через операцию, которая обходит валидаторы. Хотфикс в тот же день.
Три мины Mongoose
Все три нашли на ревью, до прода. Выписываю, потому что каждая стоит нескольких часов.
Глобальная опция отключения таймстемпов в bulkWrite молча игнорируется. Её надо ставить в каждой операции отдельно. Мы этого не знали, и updatedAt у двух с лишним тысяч записей перезаписался датой миграции. Проверили — поле никто не читает, обошлось. Но это ровно тот случай, когда «безвредно» узнаёшь постфактум.
Pipeline-update не приводит типы и трактует строки со знаком доллара как пути к полям. Если хотите записать литеральную строку, оборачивайте в $literal . Иначе получите значение поля вместо строки, тихо и без ошибки.
updateOne с фильтром {_id: undefined} приводится к пустому фильтру и обновляет первый документ коллекции. Не ноль документов. Не ошибку. Первый попавшийся документ. Это худшая из трёх, потому что в тестах на пустой базе она не воспроизводится.
Дыру в спеке нашёл смоук
После выкатки слоя со статусами прогнали браузерный смоук. Оказалось: канонический финальный статус в модели появился, а опции для него в фильтре списка нет. Пользователь не может отфильтровать завершённые дела по новому статусу.
Спека этого не предусмотрела, ревью не заметило, тесты проверяли модель, а не интерфейс. Нашлось за пять минут ручного клика. Follow-up в тот же вечер.
Отложенное эхо через одиннадцать дней
Самое поучительное. Через одиннадцать дней после релиза выяснилось, что главная таблица списка продолжает читать легаси-плоские поля. У дел с новыми блоками разбирательств пять судебных колонок стояли пустыми.
Это прямая цена двойной записи. Она даёт безопасную миграцию — но каждый читатель мигрирует отдельно, и пока не мигрировал, показывает старую картину. Читателей у популярной модели больше, чем кажется на инвентаризации.
Если бы я делал это заново, то к плану миграции прикладывал бы явный список всех читателей поля, найденный грепом, с галочкой напротив каждого. Не «мигрируем модель», а «мигрируем модель и вот эти одиннадцать мест».
Результат
Бэкфилл разбирательств: 2 144 кейса создано, из них 286 — по обжалованию решений, ноль сбоев, счётчики сверены с базой.
Формула «даты последнего действия» научилась читать новый массив. До этого она занижала дату у 621 дела из 1 003 в стадии обжалования, у 466 — больше чем на месяц. После — ноль занижений.

Прикладной эффект от этого одного исправления: операционные ложные срабатывания детектора зависших дел упали с 18 в день до нуля, а раздел «Бездействие» в утреннем дайджесте сократился примерно с 950 строк до 601.
Вторая цифра важнее первой. Дайджест на 950 строк никто не читает. Дайджест на 601 строку тоже, честно говоря, великоват — но это уже вопрос порогов, а не вранья в данных.
Разнобой «Отказ» и «Отказано» устранён миграцией, обе строки схлопнуты в одну. Рост рассинхрона между статусом и булевыми флагами остановлен со-записью: новых противоречий по контрольному чеклисту ноль.
142 зомби-флага отказа заменены вычисляемым признаком, 104 из них корректно перестали сигналить.
Что осталось
Честный список, потому что статья про техдолг без раздела «наш собственный техдолг» была бы лицемерием.
Скан отказа из старого хранилища не привязывается к постановлению в журнале — в канон пишется только дата. Повторный клик по отметке отказа плодит дубли.
Зеркало даты не обновляется, если переоформить уже привязанное постановление: нужно переоткрыть селект. Файл больше пятидесяти мегабайт отдаёт 500 вместо внятной ошибки.
Формула расчёта долга живёт в трёх местах и правится синхронно. Комментарий об этом стоит во всех трёх — что, конечно, не решение, а расписка.
Маппинг основания окончания производства на срок повторного предъявления ждёт юридической сверки. В открытых источниках он подтверждается слабо. Правка — одна строка константы, но менять её без заключения юриста нельзя.
Что забрать с собой
Булев флаг — это потерянная история. Если сущность может быть отменена, преодолена или повторена, она событие, а не состояние. Событие пишется в журнал, состояние вычисляется из журнала.
Данные решают споры о моделировании лучше, чем интуиция. Мы могли неделю спорить, терминален ли отказ. Один запрос к проду закрыл вопрос за минуту: 104 из 142.
Двойная запись — правильный инструмент миграции и одновременно источник отложенных багов. Пользуйтесь, но составляйте полный список читателей заранее.
Не мигрируйте данные там, где хватит вычисляемого правила. Миграция необратима, правило правится в одну строку.
Следующая статья — про детектор зависших дел, который в этом тексте появился как потребитель формулы. Там свой сюжет: как первая версия порога пометила почти все дела разом и почему в юридическом процессе отказ выглядит не как ошибка сервера, а как тишина.
ссылка на оригинал статьи https://habr.com/ru/articles/1066510/