Разбираем скрытый баг в Python-генераторе, который приводил к потере данных. Узнайте, как избежать подобных ошибок и защитить свой код. Читайте сейчас!
Представьте: вы запускаете код, который должен разбить список сообщений на батчи, и получаете три одинаковых списка вместо трех разных. При этом часть данных бесследно исчезает. Именно такой баг нашли в импортере Microsoft Teams в Zulip. В этой статье мы разберем, почему это произошло, как исправить и как защитить свой код от подобных ловушек.
В Zulip, популярном open-source чате для команд, есть модуль импорта данных из Microsoft Teams. Внутри него — функция, которая читает сообщения и отдает их батчами (порциями) для дальнейшей обработки. Вот упрощенная версия проблемного кода:
def batched(messages, chunk_size):
batch = []
for m in messages:
if len(batch) == chunk_size:
yield batch
batch.clear() # <- вот здесь баг!
batch.append(m)
if batch:
yield batch
print(list(batched(range(12), 5)))
Что вы ожидаете? [[0,1,2,3,4], [5,6,7,8,9], [10,11]]. А что получаете на самом деле? [[10, 11], [10, 11], [10, 11]]. Все три элемента — это один и тот же список! Десять из двенадцати сообщений потеряны, и никакого исключения не возникает.
Когда генератор выполняет yield batch, он передает список потребителю (коду, который вызвал next()). Затем, при следующем вызове next(), генератор продолжает выполнение и вызывает batch.clear(), очищая тот самый список, который он только что отдал. В итоге все батчи ссылаются на один и тот же объект, который в конце содержит только последние элементы.
Это нарушает негласный контракт: потребитель владеет значением, которое получил из генератора. Если потребитель сохраняет батчи (например, через list(generator)), он получает N ссылок на один объект, и данные молча перезаписываются. В тестовых данных импортера общее количество сообщений упало с 29 до 24 без единого исключения.
Стандартная библиотека Python следует правильному контракту: itertools.batched создает новый кортеж для каждого батча. Так же делают все рецепты в документации itertools. А здесь clear() и повторное заполнение выглядели как оптимизация памяти, но на деле оптимизировали корректность.
Исправление тривиально: вместо очистки существующего списка нужно создать новый. Вот патч из PR zulip/zulip#39814:
if len(batched_messages) == chunk_size:
yield batched_messages
# Создаем новый список, а не очищаем переданный.
# Потребитель владеет списком, полученным из yield.
# Мутация здесь испортит все батчи, если генератор
# материализуется (например, через list(...)) или
# батчи сохраняются между итерациями.
batched_messages = []
Одна строка кода и шесть строк комментария. Это осознанное решение: фикс прост, но причина, по которой он должен оставаться таким, не видна из кода. Весь сбой произошел из-за того, что прошлая «оптимизация» выглядела так же безобидно.
Просто исправить баг недостаточно. Нужен тест, который предотвратит его повторное появление. Автор фикса расширил существующий тест test_get_batched_export_message_data, чтобы он материализовал генератор через list(...) и проверял два момента:
Второе утверждение особенно важно: оно ловит потерю, дублирование и перестановку одним махом. Тест защищает контракт владения, а не один конкретный симптом.
Без фикса тест падает с ошибкой AssertionError: 24 != 29, показывая, что 5 сообщений потеряны. С фиксом весь модуль проходит: ./tools/test-backend zerver.tests.test_microsoft_teams_importer — 10/10, линтер и mypy чистые.
Этот баг никогда не срабатывал в продакшене. Текущий потребитель (ленивый цикл) обрабатывал каждый батч до перехода к следующему, поэтому не замечал проблемы. Но «латентный» описывает вызывающий код, а не саму функцию. Контракт функции нарушен уже сейчас, просто текущий вызов не опирается на сломанную часть.
Любой будущий потребитель унаследует ловушку, которая приводит к тихой потере данных во время ответственной операции — миграции рабочего пространства. Это не запрос, который можно повторить. Цена исправления — одно выделение памяти на батч. Цена неисправления — отладка с сообщением «импорт прошел, но пятой части сообщений нет», что близко к худшему виду баг-репорта для миграции данных.
Вот несколько действий, которые вы можете предпринять, чтобы защитить свой код:
yield в сочетании с мутацией объекта. Убедитесь, что вы не очищаете и не изменяете объект после его передачи наружу.itertools.batched из стандартной библиотеки, если вы работаете с Python 3.12+, — он уже реализует правильное поведение.И помните: тесты, которые покрывают только текущих вызывающих, — это тесты реализации; тесты, которые покрывают контракт, — это тесты функции. Первые протухают, как только кто-то новый вызывает ваш код. Проверьте свои тестовые наборы: вы тестируете то, что функция обещает, или то, что нужно сегодняшним вызывающим?
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →