Пять лет до одной двери
Пятеро разработчиков ERPNext по очереди чинили одну трещину, и никто из них не ошибался. Пока один не спросил: а почему это вообще работает? Оказалось — по случайному свойству чужой базы данных.
miki/pyat-let-do-odnoy-dveri · v1
Пятеро разработчиков ERPNext по очереди чинили одну трещину, и никто из них не ошибался. Пока один не спросил: а почему это вообще работает? Оказалось — по случайному свойству чужой базы данных.
ERPNext, 2021 — 2026.
ERPNext — открытая система управления предприятием, её делает индийская компания Frappe, и стоит она у тысяч установок. Складская часть держится на двух вещах: журнале движений Stock Ledger Entry и числе Bin — «сколько сейчас лежит».
Это история про пятерых человек и про то, как они по очереди подходили к одной трещине с разных сторон, пока не поняли, где она на самом деле.
20 декабря 2021 — Анкуш и прошлое.
Ankush Menat из Frappe присылает fix: correct bin qty on backdated transactions.
Движение, проведённое задним числом, встаёт в середину журнала. Журнал это переживает — он про события. А число посчитано по тому, что было известно раньше.
4 июля 2022 — Марика и будущее.
Полтора года спустя Marica чинит то же самое с другой стороны: fix: LCV updates wrong future qty/Bin qty.
Не прошлое портит число, а пересчёт стоимости портит будущие остатки.
Между этими двумя кадрами полтора года. Между вторым и следующим — четыре.
5 июля 2026 — Михир замечает, что дело в базе.
Mihir Kandoi, тоже из Frappe, приносит PR #57202. Его описание переворачивает всю историю:
On postgres, concurrent stock writes for the same (item, warehouse) are currently kept correct only by REPEATABLE READ serialization-failure retries: postgres locking reads never see rows a concurrent transaction inserts (MariaDB's gap locks block the insert and its locking reads then return the fresh row), so the losing writer recomputes from a stale previous SLE and overwrites Bin with a wrong absolute qty.
На Postgres одновременные записи по одной паре «позиция + склад» держатся правильными только за счёт повторов при ошибке сериализации: блокирующее чтение в Postgres никогда не видит строк, которые вставляет параллельная транзакция (в MariaDB gap-блокировки не дают эту вставку сделать) — поэтому проигравший пересчитывает от устаревшей записи журнала и переписывает Bin неверным числом.
Прочитайте это ещё раз.
Один и тот же код был верен на MariaDB и неверен на Postgres. Не «плохо работал» — именно неверен, тихо, при живой нагрузке.
ERPNext исторически жил на MariaDB. Поддержку Postgres добавили позже — и вместе с ней унаследовали чуть другую гарантию, которую в коде никто не выражал: правильность держалась на побочном свойстве блокировок, а не на решении разработчика.
Что делает Михир:
Makes correctness lock-based instead of retry-based
Правильность становится основанной на блокировке, а не на повторах.
И отдельная строка в конце, за которую хочется пожать руку:
MariaDB code paths are byte-identical.
Пути кода для MariaDB побайтово те же.
Починил чужую базу — не тронул свою.
11 августа 2026 — Набин решает проверить все двери.
Через двадцать шесть дней приходит Nabin Hait, один из основных разработчиков ERPNext, с PR #57980. Первая фраза описания:
First of a series routing all Stock Ledger Entry / Bin writes through single chokepoints. This one is groundwork: one bug fix and two behavior-neutral cleanups found while auditing every SLE/Bin write path.
Первый из серии, сводящей все записи в журнал и в Bin к единым узким местам. Этот — подготовка: одна починка бага и две правки без изменения поведения, найденные при обходе каждого пути записи.
Он не чинит очередной случай. Он садится и обходит все места, где к числу вообще прикасаются.
Первая находка обхода читается как готовый анекдот:
Строка с нулевым количеством записи в журнал не порождает — но остальная часть цикла продолжает работать с sle_doc предыдущей итерации. Остаток пересчитывался дважды для соседней строки. А если нулевая строка попадалась первой — всё падало.
И вторая правка, ради которой этот разбор вообще стоило писать:
refactor: remove dead
update_entries_after.update_bin_dataNo callers anywhere in the codebase; it duplicates
update_bin()with subtly different semantics and would only invite accidental resurrection as a second Bin write path.Нигде в коде не вызывается; дублирует
update_bin()с чуть иной семантикой и только напрашивалась бы на случайное воскрешение в качестве второго пути записи в Bin.
Функция мёртвая. Её никто не вызывает. Набин удаляет её не потому, что она мешает, — а потому, что она вторая дверь к числу.
Там же bin.update_qty переименовывается в update_qty_from_sle — имя теперь само говорит, откуда берётся число.
25 августа 2026 — Судхарсанан и пустой журнал.
Через две недели Sudharsanan Ashok из Aerele Technologies — уже не из Frappe, а из компании-пользователя — закрывает последний известный случай: reset bin when no stock ledger entries remain.
Удалили все движения — а число осталось. Даже опустевший журнал должен доходить до кэша.
Что видно, когда смотришь на людей, а не на коммиты.
Пятеро за пять лет, и никто из них не ошибался.
Анкуш починил прошлое. Марика — будущее. Оба сделали ровно то, что было видно с их места. Четыре года между ними — не халатность, а отсутствие поводов: пока никто не жалуется, случай считается закрытым.
Михир увидел то, чего не видно из одного случая: правильность держалась на свойстве чужой базы данных. Это находится не чтением кода, а вопросом «а почему это вообще работает?».
Набин, проработавший в этом коде дольше всех, после этого пошёл обходить все пути записи. Не потому что появился новый баг, а потому что стало понятно: баги здесь будут появляться, пока дверей больше одной.
Что это значит для нас
Взять место, где код опирается на поведение базы, очереди или библиотеки, и назвать вслух гарантию, на которой всё держится.
- Гарантия названа словами, а не подразумевается
- Понятно, что сломается при смене базы, версии или уровня изоляции
Если у числа есть источник правды, оно обязано вычисляться из него и обновляться той же транзакцией, что пишет событие.
- Событие и число пишутся одной транзакцией
- Число вычисляется из события, а не приходит отдельным аргументом
Выписать все места, где кэшируемое число меняется. Невызываемые пути — удалить, а не оставлять на всякий случай.
- Список мест записи составлен грепом, а не по памяти
- Каждое место либо идёт через общую точку, либо удалено
- Имя функции говорит, откуда берётся число
Запрос, сравнивающий кэш с суммой событий. Только читает, ненулевой код возврата при расхождении.
- Сверка запускается регулярно, а не один раз
- Проверено запуском: подложенное расхождение действительно ловится
- Сверка ничего не чинит сама — какое число верное, решает человек
Между первым кадром и последним — пять лет, пятеро людей и одна трещина, которую каждый видел со своей стороны.
Такие места не чинят. Их сводят к одной двери и потом сторожат.
Related lists
Семнадцать дней копий не было, и это выглядело точно так же, как если бы они были. Разбор чужой истории: флаг, который умел только портиться, мусор, забивший диск, и починка, приехавшая боком.
graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет
Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода
Люди и проекты из разборов — по поступкам, датам и ссылкам. Как определитель птиц: не оценивает, а помогает узнать, кого встретил
Карточка вида: чем живёт, как принимает чужаков, кто в нём работает. Наблюдения по коду, переписке и тому, что проект пишет о себе сам
comlink-python: проверки на отказ стучались в адрес, который сервер не защищает, — и пять месяцев скрывали настоящую ошибку подписи

