ГлавнаяБлогКак баг в Python-генераторе тихо терял сообщения: разбор фикса
Python

Как баг в Python-генераторе тихо терял сообщения: разбор фикса

Разбираем скрытый баг в Python-генераторе, который приводил к потере данных. Узнайте, как избежать подобных ошибок и защитить свой код. Читайте сейчас!

Al
Редакция Algolitalgolit.ru
7 мин чтения5 августа 2026 г.

Представьте: вы запускаете код, который должен разбить список сообщений на батчи, и получаете три одинаковых списка вместо трех разных. При этом часть данных бесследно исчезает. Именно такой баг нашли в импортере 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(...) и проверял два момента:

  1. Общее количество сообщений во всех батчах.
  2. Точную последовательность ID сообщений, совпадающую с отсортированными исходными файлами.

Второе утверждение особенно важно: оно ловит потерю, дублирование и перестановку одним махом. Тест защищает контракт владения, а не один конкретный симптом.

Без фикса тест падает с ошибкой AssertionError: 24 != 29, показывая, что 5 сообщений потеряны. С фиксом весь модуль проходит: ./tools/test-backend zerver.tests.test_microsoft_teams_importer — 10/10, линтер и mypy чистые.

Почему стоит исправлять «латентные» баги

Этот баг никогда не срабатывал в продакшене. Текущий потребитель (ленивый цикл) обрабатывал каждый батч до перехода к следующему, поэтому не замечал проблемы. Но «латентный» описывает вызывающий код, а не саму функцию. Контракт функции нарушен уже сейчас, просто текущий вызов не опирается на сломанную часть.

Любой будущий потребитель унаследует ловушку, которая приводит к тихой потере данных во время ответственной операции — миграции рабочего пространства. Это не запрос, который можно повторить. Цена исправления — одно выделение памяти на батч. Цена неисправления — отладка с сообщением «импорт прошел, но пятой части сообщений нет», что близко к худшему виду баг-репорта для миграции данных.

Практический вывод: что делать прямо сейчас

Вот несколько действий, которые вы можете предпринять, чтобы защитить свой код:

  • Проверьте свои генераторы, которые используют yield в сочетании с мутацией объекта. Убедитесь, что вы не очищаете и не изменяете объект после его передачи наружу.
  • Пишите тесты на контракт, а не только на текущего потребителя. Если функция обещает возвращать независимые батчи, тест должен материализовать генератор и проверять целостность данных.
  • Используйте itertools.batched из стандартной библиотеки, если вы работаете с Python 3.12+, — он уже реализует правильное поведение.
  • Документируйте контракт в docstring: «Каждое значение, полученное из генератора, принадлежит потребителю и не должно изменяться генератором после yield».

И помните: тесты, которые покрывают только текущих вызывающих, — это тесты реализации; тесты, которые покрывают контракт, — это тесты функции. Первые протухают, как только кто-то новый вызывает ваш код. Проверьте свои тестовые наборы: вы тестируете то, что функция обещает, или то, что нужно сегодняшним вызывающим?

#генераторы#python#баги#тестирование#zulip
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →