dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

Тест падает раз в триста прогонов. Это не погода

Коротко: тест, который падает раз в триста прогонов, уже нашёл настоящую гонку — вы просто не можете её повторить. Случаен не баг, а порядок выполнения, который выбрала операционная система. Отберите у неё этот выбор — и падение из анекдота превращается в адрес.

8 мин чтения

Коротко

  • Плавающий тест — это найденный баг без адреса. `retry(3)` не чинит баг, он выключает сигнал.
  • В асинхронном коде случаен не сбой, а расписание: какой из готовых колбэков рантайм запустит первым.
  • Детерминированная симуляция забирает расписание себе: время, таймеры и порядок берутся из сида, и прогон повторяется побайтово на любой машине.
  • Выборка из 200 расписаний доказывает «не нашли». Полный перебор доказывает «не существует» — но только для маленького теста.

Что такое плавающий тест на самом деле

Плавающим называют тест, который иногда падает без изменений в коде. Отсюда обычно делают вывод, что виноват тест, и приписывают ему retry. Вывод неверный: в подавляющем большинстве случаев тест поймал настоящую гонку и не смог её повторить.

Асинхронный код не выполняется в одном порядке. Когда несколько колбэков готовы в один и тот же момент, кто-то должен решить, какой из них пойдёт первым, — и решает это рантайм вместе с операционной системой. Вариантов тысячи, ломает вас один. На вашей машине он не выпадает никогда, на сборочном сервере в три часа ночи — один раз, и больше не повторяется.

Сколько это стоит в проде

Гонка, которую заглушили ретраем, не исчезает — она переезжает в продакшн, где расписание выбирает не ваш ноутбук, а нагрузка. И там она стоит денег.

Класс гонки
Чем оборачивается
Повторная отправка платежа после таймаута
двойное списание и возврат вручную
Две параллельные выдачи одного соединения из пула
ответ уходит не тому клиенту
Два обновления кэша, медленное приезжает последним
старые данные затирают новые
Два перевода берут два замка в разном порядке
взаимная блокировка, сервис не отвечает

Первая строка — самая дорогая и самая частая. Это ровно тот случай, ради которого в платёжных системах существует идемпотентность операций, и ровно тот, который обычный тест не ловит: брошенный запрос доезжает уже после того, как тест ушёл.

Почему retry(3) — не решение

Ретрай меняет вопрос «почему упало» на вопрос «сколько раз перезапустить, чтобы прошло». Это работает ровно до того дня, когда красная сборка перестаёт кого-либо удивлять, — и с этого дня набор тестов больше не защищает.

  • Ретрай прячет не флейк, а класс ошибок: любая настоящая гонка выглядит так же и точно так же «чинится».
  • Время сборки растёт, а доверие падает: команда начинает перезапускать до того, как прочитает лог.
  • Баг остаётся в коде и ждёт нагрузки, при которой редкое расписание станет частым.

Отобрать расписание у операционной системы

Идея детерминированной симуляции простая: если недетерминизм приходит из того, что вы не контролируете, — заберите это себе. Внутри прогона подменяются setTimeout, setInterval, setImmediate, Date, performance.now и Math.random. Все способы подождать сходятся в одну виртуальную очередь таймеров, и вопрос «что дальше» получает единственного владельца.

Когда в один виртуальный момент готовы несколько колбэков, порядок выбирается из сида и записывается. Один сид — один и тот же прогон, побайтово, на любой машине. Между шагами перепроверяются инварианты, поэтому нарушение ловится на том расписании, которое его вызвало, а не тогда, когда тест в следующий раз решил проверить.

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

Как это выглядит в коде

Мы написали для этого открытый инструмент — unflake. Тест выглядит как обычный, только тело исполняется в контролируемом мире, а условие проверяется после каждого шага планировщика.

ts
import { check } from 'unflake';
import { it } from 'vitest';

it('соединение не выдаётся дважды', async () => {
  await check('одно соединение — один держатель', async (sim) => {
    const pool = createPool(sim, { size: 2 });
    const held = new Map<string, number>();

    // Перепроверяется после каждого шага планировщика, а не в конце теста.
    sim.invariant('нет соединения у двоих', () =>
      [...held.values()].every((n) => n <= 1),
    );

    await sim.parallel(4, async () => {
      const conn = await pool.acquire();
      held.set(conn, (held.get(conn) ?? 0) + 1);
      await sim.io('query', { latency: [1, 6] });
      held.set(conn, (held.get(conn) ?? 0) - 1);
      pool.release(conn);
    });
  }, { runs: 200 });
});
Пул соединений: проверяем, что одно соединение не выдаётся дважды

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

Выборка и доказательство — разные вещи

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

ts
const report = await explore('одно соединение — один держатель', body);
// { ok: true, schedules: 24, exhaustive: true }

exhaustive: true означает, что пространство обойдено полностью, и тогда прохождение читается как «нарушения не существует» — для каждого исполнения, которое модель способна породить. Ограничение честное: дерево растёт мультипликативно, широкий разброс задержек [1, 25] даёт двадцатипятикратное ветвление на каждой операции ввода-вывода, и полный перебор остаётся уделом небольших тестов.

Что показал прогон по чужому коду

Инструмент, который ничего не нашёл, вызывает подозрения — поэтому мы прогнали его по чужим пакетам и опубликовали результат целиком: 19 задокументированных контрактов в семи популярных библиотеках (p-limit, p-queue, async-mutex, async-sema, generic-pool, p-retry, bottleneck), сотни расписаний на каждый.

Ни один контракт не нарушился. Это честный заголовок, и его стоит произнести вслух, а не тихо не упомянуть. Это же и ожидаемый результат: у этих пакетов миллионы загрузок в неделю, а их основные гарантии проверяет каждый пользователь ежедневно. Инструмент, который в первый же вечер «ломает» ограничение параллелизма в p-limit, рассказывает о своей калибровке, а не о p-limit.

Одна находка выглядела настоящей: bottleneck запускал две задачи с разрывом 9 мс при заявленных minTime: 10. Она воспроизводилась и вне симулятора — но разрыв составлял ровно 1 мс во всех четырёхстах расписаниях, то есть это стоимость старта, а не дефект планирования. Отправить такой отчёт — отнять у мейнтейнера вечер из-за одной миллисекунды. Мы его не отправили и записали почему.

Чего метод не умеет

Инструмент тестирования, который переоценивает свои гарантии, хуже его отсутствия. Границы здесь такие.

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

С чего начать за час

Порядок действий
  • Найдите в истории сборок тест, который отмечен ретраем или падал без изменений в коде.
  • Сформулируйте инвариант, который он на самом деле проверяет: «одно соединение — один держатель», «списание происходит один раз».
  • Перепишите тело теста так, чтобы ожидания шли через симулятор, а не через настоящие таймеры и сеть.
  • Прогоните двести расписаний. Упало — у вас есть минимальное расписание и воспроизведение; не упало — у вас есть основание снять ретрай.
  • Оставьте прогон в CI: гонка, которую вы починили, теперь не вернётся молча.

Если система, о которой идёт речь, считает деньги, начинать стоит не с тестов, а с архитектуры операций: подробности — в разборе про идемпотентность платежей и на странице финтех-разработки. Остальные инженерные разборы собраны в рубрике «Инженерия».

Частые вопросы

Чем это отличается от подмены таймеров вроде fake-timers?

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

Это заменит наши обычные тесты?

Нет. Это отдельный слой для конкурентного кода: пулы, очереди, повторы, транзакции, всё, где несколько задач трогают общее состояние. Юнит-тесты и интеграционные никуда не деваются.

Сколько расписаний нужно прогонять?

Двести — разумное значение по умолчанию: прогон дешёвый, потому что время виртуальное. Для маленьких тестов имеет смысл полный перебор — он даёт не «не нашли», а «не существует»; чем это отличается по существу, разобрано в материале про проверку моделей.

Мы пишем не на TypeScript. Что делать?

Подход не привязан к языку: в Rust это loom и shuttle, в распределённых системах — Jepsen, в базах данных — собственные симуляторы. Общее правило одно: сделайте источник недетерминизма своим и записывайте сделанный выбор.

Источники

Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.

  1. 01FoundationDB: TestingБаза данных, корректность которой держится на детерминированной симуляции
  2. 02TigerBeetle: VOPRТот же подход для финансового регистра
  3. 03loom (Rust)Полный перебор с сокращением пространства — то, чего в JavaScript пока нет
  4. 04unflakeИнструмент из этой статьи: MIT, ноль зависимостей, аудит семи пакетов внутри
Автор
Дониёр Ботиров
Основатель dbit.one · автор материала

Этот материал подписан именем, в отличие от остальных: за ним не стоит клиент. Код, о котором идёт речь, открыт целиком и лежит под тем же именем — его можно прочитать, запустить и проверить утверждения статьи, не поверив ни одному из них на слово.

Услуги по теме

Читать дальше

Инженерия9 мин чтения

Падает мастер: что на самом деле происходит с вашими данными

Коротко: «у нас кластер из трёх узлов» — это описание конфигурации, а не свойство системы. Свойство появляется тогда, когда кто-то проверил: при разделении сети два узла не решат одновременно, что они главные, а запись, подтверждённая клиенту, не исчезнет вместе с упавшим лидером. Проверяется это симуляцией, а не выключением сервера руками.

Инженерия9 мин чтения

Уровни изоляции: что ваша база разрешает на самом деле

Коротко: уровень изоляции — это не настройка производительности, а список того, что вашему приложению разрешено увидеть. Между «read committed» и «serializable» лежат аномалии с именами и с ценой: списание сверх остатка, две смены без дежурного, задвоенный документ. Прочитать в документации, что база «поддерживает snapshot isolation», недостаточно — это свойство исполнений, а не текста.

Инженерия6 мин чтения

Повтор запроса не должен списывать деньги дважды

Коротко: сеть не сообщает, прошёл платёж или нет, — она просто молчит. Клиент нажимает кнопку второй раз, мобильное приложение повторяет запрос само, и без защиты вы списываете дважды. Разбираем, как устроена идемпотентность на уровне таблиц и кода и где её обычно ломают.

Остались вопросы по вашему проекту?

Опишите задачу — в течение 24 часов вернёмся с оценкой, сроками и планом.

[email protected]