Skip to content

Пятеро разработчиков ERPNext по очереди чинили одну трещину, и никто из них не ошибался. Пока один не спросил: а почему это вообще работает? Оказалось — по случайному свойству чужой базы данных.

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1

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 к единым узким местам. Этот — подготовка: одна починка бага и две правки без изменения поведения, найденные при обходе каждого пути записи.

Он не чинит очередной случай. Он садится и обходит все места, где к числу вообще прикасаются.

Первая находка обхода читается как готовый анекдот:

if sle.get("actual_qty") or sle.get("voucher_type") == "Stock Reconciliation":
sle_doc = make_entry(sle, ...)
args = sle_doc.as_dict() # <- выполняется всегда

Строка с нулевым количеством записи в журнал не порождает — но остальная часть цикла продолжает работать с sle_doc предыдущей итерации. Остаток пересчитывался дважды для соседней строки. А если нулевая строка попадалась первой — всё падало.

И вторая правка, ради которой этот разбор вообще стоило писать:

refactor: remove dead update_entries_after.update_bin_data

No 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.

Удалили все движения — а число осталось. Даже опустевший журнал должен доходить до кэша.

Что видно, когда смотришь на людей, а не на коммиты.

Пятеро за пять лет, и никто из них не ошибался.

Анкуш починил прошлое. Марика — будущее. Оба сделали ровно то, что было видно с их места. Четыре года между ними — не халатность, а отсутствие поводов: пока никто не жалуется, случай считается закрытым.

Михир увидел то, чего не видно из одного случая: правильность держалась на свойстве чужой базы данных. Это находится не чтением кода, а вопросом «а почему это вообще работает?».

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

Что это значит для нас

1
Спросить: а почему это вообще работает?

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

Why: Находка Михира целиком из этого вопроса. Код, работающий по случайному свойству окружения, выглядит ровно как код, работающий правильно — до дня смены окружения.
Check
  • Гарантия названа словами, а не подразумевается
  • Понятно, что сломается при смене базы, версии или уровня изоляции
2
Брать число из журнала, а не писать рядом с ним

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

Why: Две отдельные записи подряд — это договорённость, а не гарантия. Между ними можно умереть.
Check
  • Событие и число пишутся одной транзакцией
  • Число вычисляется из события, а не приходит отдельным аргументом
3
Пересчитать двери и удалить мёртвые

Выписать все места, где кэшируемое число меняется. Невызываемые пути — удалить, а не оставлять на всякий случай.

Why: Ошибка обычно не в арифметике, а во втором пути записи, про который забыли. Набин удаляет даже никем не вызываемую функцию — чтобы её не воскресили.
Check
  • Список мест записи составлен грепом, а не по памяти
  • Каждое место либо идёт через общую точку, либо удалено
  • Имя функции говорит, откуда берётся число
4
Завести сверку — правка кода не чинит данные

Запрос, сравнивающий кэш с суммой событий. Только читает, ненулевой код возврата при расхождении.

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

Между первым кадром и последним — пять лет, пятеро людей и одна трещина, которую каждый видел со своей стороны.

Такие места не чинят. Их сводят к одной двери и потом сторожат.

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

v3Public 0 0
updated Sep 1, 2026

graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет

v2Public 0 0
updated Sep 1, 2026

Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода

v10Public 0 0
updated Sep 1, 2026

Люди и проекты из разборов — по поступкам, датам и ссылкам. Как определитель птиц: не оценивает, а помогает узнать, кого встретил

v5Public 0 0
updated Sep 1, 2026

Карточка вида: чем живёт, как принимает чужаков, кто в нём работает. Наблюдения по коду, переписке и тому, что проект пишет о себе сам

v5Public 0 0
updated Sep 1, 2026

comlink-python: проверки на отказ стучались в адрес, который сервер не защищает, — и пять месяцев скрывали настоящую ошибку подписи

v1Public 0 0
updated Sep 1, 2026