Самые коварные баги -- не те, которые роняют систему. Система упала -- все побежали чинить. Самые коварные -- те, которые работают. Стабильно, предсказуемо, без ошибок в журнале регистрации. Просто неправильно. И никто не замечает, потому что результат выглядит правдоподобно.
Недавно я разбирал обработку, которая создавала документы на основании данных из внешней системы. Обработка работала в production несколько месяцев. Ни одной ошибки. Документы создавались, проводились, попадали в отчёты. Все довольны.
А потом бухгалтер заметила расхождение в остатках. Небольшое -- пара позиций. Я полез разбираться и обнаружил три бага, которые идеально компенсировали друг друга. Каждый по отдельности вызвал бы немедленную ошибку. Вместе -- стабильную, но неправильную работу.
Один баг -- проблема. Три бага -- стабильность
Парадокс звучит абсурдно, но в программировании он встречается чаще, чем хотелось бы. Один баг обычно проявляется быстро: что-то падает, что-то не записывается, пользователь видит ошибку. Система защищается -- валидации, проверки, исключения. Баг натыкается на защиту и кричит о себе.
Но когда багов несколько, они могут образовать устойчивую систему. Первый баг создаёт некорректное состояние. Второй баг -- в другом месте кода -- реагирует на это некорректное состояние и компенсирует его, создавая новое некорректное состояние. Третий баг компенсирует второе. Результат на выходе выглядит нормально. Все проверки проходят. Тесты зелёные. Пользователи не жалуются.
До тех пор, пока кто-то не исправит один из трёх багов. Или пока не появятся данные, которые нарушают хрупкий баланс. Тогда система рассыпается, и отладка превращается в кошмар: ты чинишь один баг, а ломаются два других, которые раньше «работали».
Именно это произошло в моём случае. Расхождение в остатках появилось не потому, что что-то сломалось. А потому что появился новый тип номенклатуры, который нарушил баланс между тремя багами.
Баг первый: переменная-невидимка
Обработка заполняла табличную часть документа. Для каждой строки входных данных создавалась новая строка в табличной части. Код выглядел так (упрощённо):
Для Каждого СтрокаДанных Из ВходныеДанные Цикл
НоваяСтрокаТЧ = Документ.Товары.Добавить();
// ... 40 строк заполнения реквизитов ...
НоваяСтрока.Номенклатура = СтрокаДанных.Номенклатура;
НоваяСтрока.Количество = СтрокаДанных.Количество;
НоваяСтрока.Цена = СтрокаДанных.Цена;
НоваяСтрока.Сумма = СтрокаДанных.Количество * СтрокаДанных.Цена;
КонецЦикла;
Заметили? Строка добавляется в переменную НоваяСтрокаТЧ. А заполняется переменная НоваяСтрока. Без суффикса «ТЧ».
В 1С переменные не нужно объявлять. Если написал НоваяСтрока.Номенклатура = ..., а переменной НоваяСтрока не существует -- 1С не выдаст ошибку компиляции. Она создаст переменную на лету. Но эта переменная -- не строка табличной части. Это просто переменная. Присвоение НоваяСтрока.Номенклатура ничего не запишет в табличную часть документа.
Ошибка -- банальная опечатка. Разработчик создал строку как НоваяСтрокаТЧ, а при заполнении написал НоваяСтрока (видимо, скопировал из другого места). Между объявлением и заполнением -- 40 строк кода, через которые глаз перескакивает. Классическая невнимательность.
Что должно было произойти: строки в табличной части добавляются, но остаются пустыми. Номенклатура, количество, цена -- всё пусто. Документ при проведении должен упасть с ошибкой «не заполнена номенклатура» или «нулевое количество».
Но документ не падал. Потому что вступил в игру второй баг.
Баг второй: пустая ссылка как защитный механизм
Обработка создавала документы на основании другого документа -- документа-основания. Ссылка на основание записывалась в реквизит шапки. Проблема: ссылка формировалась через поиск по номеру документа во внешней системе. И этот поиск содержал ошибку -- использовал неправильное поле для сопоставления. В результате ссылка на документ-основание всегда была пустой.
// Поиск документа-основания
ДокументОснование = Документы.ПоступлениеТоваров.НайтиПоНомеру(
СтрокаДанных.НомерВоВнешнейСистеме); // Нужно было НомерДокумента
// НомерВоВнешнейСистеме -- это внутренний ID внешней системы,
// а не номер документа 1С. Поиск всегда возвращал пустую ссылку.
Пустая ссылка на основание -- сам по себе баг. Документ без основания -- это сирота, его сложно отследить, сложно связать с исходной операцией. Но в данном случае пустая ссылка сыграла роль защитного механизма.
Дело в том, что при проведении документа 1С проверяла: если указано основание -- сверить табличную часть с основанием. Количество, номенклатура, суммы должны совпадать. Если основание пустое -- проверка пропускается. Документ проводится «как есть».
Если бы основание находилось правильно, проведение сравнило бы пустые строки табличной части (баг 1) с заполненными строками основания. Ошибка. Система бы остановилась. Но основание пустое (баг 2), проверка не срабатывает, пустые строки проходят.
Два бага взаимно компенсируются: опечатка в переменной (данные не попадают в табличную часть) + пустое основание (проверка не выполняется) = документ создаётся и проводится без ошибок. С пустыми строками.
Но подождите. Если строки пустые -- как бухгалтер видела данные в документах? Как они попадали в отчёты? Вступает третий баг.
Баг третий: НайтиПоРеквизиту врёт красиво
После создания документа обработка выполняла «постобработку»: заполняла дополнительные реквизиты на основании уже записанных данных. В том числе -- подтягивала номенклатуру и цены из регистра сведений, используя НайтиПоРеквизиту.
Для Каждого СтрокаТЧ Из Документ.Товары Цикл
Если НЕ ЗначениеЗаполнено(СтрокаТЧ.Номенклатура) Тогда
// Пытаемся найти номенклатуру по штрихкоду из внешней системы
НайденнаяНоменклатура = Справочники.Номенклатура.НайтиПоРеквизиту(
"ШтрихкодВнешнейСистемы", СтрокаТЧ.ШтрихкодВнешнейСистемы);
СтрокаТЧ.Номенклатура = НайденнаяНоменклатура;
КонецЕсли;
КонецЦикла;
Логика: если номенклатура в строке не заполнена -- попробовать найти её по штрихкоду. Звучит как разумный fallback. Но есть нюанс.
СтрокаТЧ.ШтрихкодВнешнейСистемы -- тоже пуст. Он не заполнился из-за бага 1 (опечатка переменной). Мы ищем номенклатуру по пустому штрихкоду.
Что делает НайтиПоРеквизиту с пустым значением? Возвращает первый элемент справочника, у которого реквизит ШтрихкодВнешнейСистемы не заполнен. А таких элементов -- почти все. У 95% номенклатуры этот реквизит пустой, потому что заполнялся только для товаров, интегрированных с внешней системой.
НайтиПоРеквизиту честно возвращает первый попавшийся элемент с пустым штрихкодом. Это реальная номенклатура -- с названием, с единицей измерения, с ценой в регистре. Строка табличной части заполняется этой номенклатурой. Документ выглядит нормально.
Только это не та номенклатура. Это случайный элемент справочника, который подошёл по формальному критерию «пустой штрихкод». Иногда -- по чистому совпадению -- это оказывался правильный элемент (если нужная номенклатура шла первой в порядке выборки). Чаще -- нет.
Вот так три бага образовали устойчивую систему:
- Опечатка в переменной -- данные не попадают в табличную часть, строки пустые.
- Пустое основание -- проверка при проведении пропускается, пустые строки не вызывают ошибку.
- НайтиПоРеквизиту с пустым значением -- находит случайную номенклатуру, заполняет строку. Документ выглядит нормально.
Убери любой один баг -- система рухнет. Исправь опечатку -- данные заполнятся правильно, но НайтиПоРеквизиту перезапишет их случайной номенклатурой. Исправь поиск основания -- проверка при проведении обнаружит несоответствие и остановит документ. Исправь НайтиПоРеквизиту -- строки останутся пустыми (потому что опечатка), и документ будет создаваться с нулевыми суммами.
Чинить нужно все три одновременно. Иначе «починка» одного бага активирует два других.
Как нашёл: prod-данные, а не отладчик
Обычный путь -- отладчик: поставил точку останова, прошёл по шагам, увидел проблему. Но отладчик здесь бесполезен. Обработка запускается регламентным заданием ночью, обрабатывает сотни строк. Отладить пошагово -- нереально. Да и что отлаживать? Код не падает. Код работает. Неправильно, но работает.
Я пошёл от данных. Бухгалтер заметила расхождение по двум позициям. Я выгрузил все документы, созданные обработкой за последний месяц, и сравнил с исходными данными из внешней системы.
// Запрос для анализа расхождений
ВЫБРАТЬ
Док.Номер,
Док.Дата,
ТЧ.Номенклатура КАК Номенклатура1С,
ВД.Номенклатура КАК НоменклатураВнешняя,
ВЫБОР КОГДА ТЧ.Номенклатура = ВД.Номенклатура
ТОГДА "Совпадает"
ИНАЧЕ "РАСХОЖДЕНИЕ"
КОНЕЦ КАК Статус
ИЗ
Документ.РеализацияТоваров КАК Док
ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.РеализацияТоваров.Товары КАК ТЧ
ПО Док.Ссылка = ТЧ.Ссылка
ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ДанныеВнешнейСистемы КАК ВД
ПО Док.Ссылка = ВД.Документ
И ТЧ.НомерСтроки = ВД.НомерСтроки
ГДЕ
Док.Дата МЕЖДУ &НачалоПериода И &КонецПериода
И Док.Автор = &РегламентноеЗадание
Результат шокировал. Из 1200 строк расхождения были в 847. Семьдесят процентов. Не две позиции -- семьдесят процентов всех строк содержали неправильную номенклатуру. Бухгалтер заметила только два случая, потому что там расхождение повлияло на итоги по группе товаров, которую она проверяла вручную. Остальные 845 прошли незамеченными.
Кстати, про LEFT JOIN в 1С -- в том запросе выше я осознанно использовал LEFT JOIN для сравнения. И помнил про подводные камни: если во внешней системе строка есть, а в 1С нет -- LEFT JOIN покажет NULL, и условие сравнения не сработает как ожидается. Пришлось добавить отдельную проверку на отсутствующие строки.
Дальше я стал раскручивать цепочку. Почему номенклатура неправильная? Потому что подставилась через НайтиПоРеквизиту. Почему сработал fallback? Потому что в строке табличной части номенклатура пустая. Почему пустая? Потому что записывалась в другую переменную. Каждый шаг назад открывал следующий баг.
Ключевой момент: я не искал баги. Я искал причину расхождения данных. Баги нашлись как следствие. Это принципиально другой подход: не «где в коде ошибка?», а «почему данные не такие, как должны быть?».
Отладчик показывает, как работает код. Анализ данных показывает, как код повлиял на реальность. Когда баги компенсируют друг друга, код работает «правильно» -- отладчик не покажет ничего подозрительного. А данные -- покажут.
Как починил. Все три бага -- одним коммитом. Не последовательно, а одновременно. Потому что, как я уже говорил, починка одного активирует два других.
- Переименовал
НоваяСтрокаТЧвНоваяСтрокапо всему коду (или наоборот -- привёл к единообразию). Данные стали заполняться в правильную строку табличной части. - Исправил поиск документа-основания: заменил
НомерВоВнешнейСистеменаНомерДокумента. Ссылка на основание стала находиться корректно. Проверка при проведении заработала. - Добавил проверку в
НайтиПоРеквизиту: если значение поиска пустое -- не искать, сразу возвращать пустую ссылку. И добавил логирование: если fallback сработал -- записывать в журнал регистрации, какая номенклатура была подставлена и почему.
После исправления прогнал обработку на копии базы. Все 1200 строк -- корректная номенклатура. Ноль расхождений.
Отдельная задача -- исправление уже созданных документов. 847 строк с неправильной номенклатурой в production. Написал отдельную обработку для корректировки, прогнал с бухгалтером. Два дня ручной проверки, прежде чем остатки сошлись. Это та цена, которую платишь за баги, которые «не мешают работать».
Несколько выводов, которые я вынес из этой истории.
Стабильность -- не значит корректность. Код может работать без ошибок и при этом делать неправильные вещи. Отсутствие падений -- не гарантия качества. Нужна сверка с реальностью: сравнивайте то, что система записала, с тем, что она должна была записать.
НайтиПоРеквизиту с пустым значением -- мина. Этот метод возвращает первый найденный элемент. Если искомое значение пустое -- он найдёт первый элемент с пустым реквизитом. Это почти никогда не то, что вы хотели. Всегда проверяйте входное значение перед вызовом.
Неявное создание переменных в 1С -- источник опечаток. В языках с обязательным объявлением переменных такая ошибка невозможна: компилятор скажет «переменная не объявлена». В 1С -- молча создаст новую. Используйте BSL линтер с правилом UsingDeclaredVariable -- он найдёт переменные, которые объявляются неявно.
Чините связанные баги одновременно. Если нашли два бага, которые компенсируют друг друга -- не чините по одному. Починка первого «активирует» второй, и система может повести себя хуже, чем до починки. Собрали все связанные проблемы, поняли полную картину, исправили одним изменением. Это особенно важно при работе с транзакциями в 1С -- если баги затрагивают запись данных, частичное исправление может привести к нарушению атомарности.
Анализ данных важнее отладки. Отладчик показывает один конкретный путь выполнения с одними конкретными данными. Анализ prod-данных показывает все пути и все данные за весь период. Расхождение в 70% строк невозможно обнаружить отладчиком -- для этого пришлось бы пройти каждую из 1200 строк вручную. Один SQL-запрос показал всё за три секунды.
Три бага, каждый из которых по отдельности был бы пойман за пять минут. Вместе они прожили в production несколько месяцев, тихо портя данные. 847 строк с неправильной номенклатурой. И всё потому, что система не падала. Не было повода смотреть в код. Не было ошибок, которые бы привлекли внимание.
Иногда лучший способ найти баг -- не смотреть в код вообще. Смотреть в данные.


