open atlas
↑ К треку
CI/CD-пайплайны CICD · 06 · 05

Очереди слияния и зелёный main

Проверки PR тестируют устаревший main, поэтому два независимо зелёных PR ломают main логическим конфликтом, которого git не видит. Merge queue спекулятивно собирает main + очередь PR до слияния, батчит ради пропускной способности и бисектит виновника — main зелёный по построению.

CICD Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Команда сделала всё правильно. Защита ветки включена, обязательные проверки зелёные, каждый PR отревьюен. И main всё равно ломался примерно три раза в день. Шаблон был всегда один: два PR, каждый зелёный по отдельности, слиты с разницей в двадцать минут, и второй красил сборку на строке, которую ни один автор не трогал. В одну пятницу это был ренейминг — кто-то сократил computeShippingCost до shippingCost и обновил все одиннадцать мест вызова, зелёно. Сорока минутами раньше коллега открыл PR, добавлявший новый вызов computeShippingCost, тоже зелёный, отведённый от ветки до того, как ренейминг существовал. Git слил оба чисто — нет пересекающихся строк — и main не скомпилировался. Каждый push после этого был заблокирован за красной сборкой, пока кто-то не бисектил, не находил осиротевший вызов и не делал revert. Проверки не врали. Они отвечали на вопрос, который никто не задавал: «зелёный ли этот PR против main таким, каким он был, когда я тестировал?» — а не «против main таким, каким он станет, когда всё передо мной приземлится».

Почему зелёные PR всё равно ломают main

Если ты хоть раз видел, как два зелёных PR ломают main на строке, которую никто не трогал, — это ровно тот сбой. За десять минут поймёшь механизм, почему очевидные фиксы не работают и что merge queue (очередь слияния) реально с этим делает.

Обязательная проверка на pull request не тестирует main. Она тестирует голову твоего PR, слитую с main таким, каким он был в момент запуска CI, — снимок. К моменту, когда твой PR действительно сливается, другие PR могли приземлиться впереди него, так что main, против которого валидировался твой код, больше не существует.

Слияние git блокирует только текстовые конфликты: два изменения пересекающихся строк. Оно полностью слепо к логическим (семантическим) конфликтам, где два изменения не делят ни единой строки, но несовместимы по смыслу:

PR A (зелёный):  rename computeShippingCost -> shippingCost, обновить 11 вызовов
PR B (зелёный):  добавить НОВЫЙ вызов computeShippingCost(...) в файле, который PR A не трогал
                 (отведён до того, как PR A существовал)

git merge A затем B:  ноль пересекающихся строк  ->  сливается чисто
результат в main:     вызов функции, которой больше нет  ->  сборка красная

Каждый PR был зелёным против того main, на котором тестировался. Ни один прогон тестов не мог поймать поломку, потому что комбинация — A и B вместе — это состояние, которого нигде не существовало, пока слияние его не создало. Вот почему «просто перезапусти проверки на каждом PR» не спасает: перезапуск всё ещё тестирует каждый PR против main, который устаревает в тот же миг, как приземляется другой PR. Чем выше скорость слияния, тем чаще «протестировано против main, которого больше нет» выкатывает сломанный main.

Merge queue: тестировать main-каким-он-станет

Merge queue (очередь слияния; реализации — GitHub merge queue, Bors, Mergify) чинит это, сериализуя интеграцию. Вместо того чтобы слить PR напрямую, она конструирует спекулятивную ветку (временную ветку «как будто всё уже слито») = текущий main + этот PR (плюс всё, что впереди него в очереди) и прогоняет полный набор обязательных проверок на этом комбинированном результате. PR приземляется только если сборка очереди — main ровно такой, каким он станет после слияния этого батча, — зелёная.

# GitHub merge queue: обязательная проверка запускается на событии merge_group,
# то есть против спекулятивной ветки, которую строит очередь (main + очередь PR),
# А НЕ просто против головы PR. main защищён; ничто не пушит напрямую.
on:
  merge_group:        # <- спекулятивная сборка очереди
  pull_request:       # <- всё ещё на каждый PR ради быстрого фидбэка автору
jobs:
  required-checks:
    runs-on: ubuntu-latest
    steps:
      - run: ./ci/affected-build-and-test.sh   # держи БЫСТРОЙ — она гейтит очередь

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

Батчинг и бисекция: пропускная способность при высоком объёме слияний

Тестировать по одному PR за раз против текущего main корректно, но медленно: при высоком объёме очередь становится строем в один ряд, и каждый прогон CI пропускает только один PR. Поэтому очереди батчат — спекулятивно собирают main + PR1 + PR2 + … + PRn вместе. Если батч зелёный, все n PR сливаются с одного прогона CI. В этом выигрыш пропускной способности: одна дорогая сборка пропускает весь батч вместо n сборок, пропускающих n PR.

Риск в том, что один плохой PR в батче красит весь батч. Очередь справляется с этим, бисектя (bisect — делением пополам для локализации виновника): она разбивает падающий батч, чтобы найти, какой PR виноват, выгоняет виновника (отбрасывает обратно его автору) и перезапускает оставшиеся PR меньшим батчем. Так один сломанный PR удаляет себя сам, а не блокирует четыре хороших PR, стоящих вокруг него в очереди.

# Батч из 4 собран спекулятивно вместе; сборка батча падает.
# Очередь бисектит, локализуя поломку, выгоняет виновника, перезапускает остальных.
queue batch: [PR1, PR2, PR3, PR4]   -> спекулятивная сборка КРАСНАЯ
  bisect [PR1, PR2] -> зелёный       # поломка во второй половине
  bisect [PR3, PR4] -> красный       # сужено
  bisect [PR3]      -> зелёный
  bisect [PR4]      -> красный        # PR4 — виновник
eject PR4 (обратно автору); re-run [PR1, PR2, PR3] -> зелёный -> слить 3

Компромисс прямой: больше батчи = выше пропускная способность, но больше потерянной работы при падении (красный батч из 8 выбрасывает сборку восьмёрки и запускает бисекцию). Меньшие батчи переделывают меньше, но пропускают меньше PR за прогон. Размер батча ты настраиваешь под свою частоту падений — флакающий или склонный к поломкам репозиторий хочет меньших батчей; дисциплинированный с редкими падениями может гонять большие батчи и редко платить налог бисекции.

Почему это работает

Почему «требовать прохождения проверок PR перед слиянием» не держит main зелёным при высоком объёме слияний? Потому что проверки каждого PR прогоняются против main таким, каким он был, когда этот PR тестировался, а не каким он станет после слияния PR впереди него. Git блокирует только текстовые конфликты — пересекающиеся строки — так что логический конфликт (PR A переименовывает X, PR B добавляет вызов старого X в файле, который A не трогал) не имеет пересечений, сливается чисто и ломает main. Комбинация A-и-B — это состояние, которого нигде не существовало, пока слияние его не создало, так что прогон CI ни одного PR не мог её протестировать. Единственное, что действительно держит main зелёным, — это тестирование реальной пост-слиянной комбинации до её приземления, что ровно и делает спекулятивная сборка merge queue.

Почему очереди нужны защита ветки и быстрый гейт

Без этих двух предусловий гарантия очереди испаряется. Когда настраиваешь merge queue, спроси себя: есть ли путь в main, обходящий очередь, и достаточно ли быстра гейтящая проверка, чтобы не дросселировать команду?

Merge queue работает только если main защищён — никаких прямых пушей — и комбинированная проверка очереди является обязательным гейтом. Если кто угодно может пушить в main напрямую, он обходит спекулятивную сборку, и вся гарантия испаряется: очередь может обещать «всё, что приземлилось, было доказано зелёным как комбинация» только если всё приземляется через очередь.

И гейт должен быть быстрым. Пропускная способность очереди ограничена длительностью гейтящей проверки: очередь не может пропустить PR2, пока не закончится сборка, включающая PR1, так что 40-минутная обязательная проверка ограничивает твою скорость слияния независимо от того, сколько PR ждёт. Это сквозная линия всего этого юнита — сборки только затронутого, кэширование, шардинг тестов, правильно подобранные раннеры существуют, чтобы обязательная проверка оставалась короткой, потому что здесь именно эта проверка дросселирует каждое слияние в организации. Медленный гейт не просто раздражает одного автора; он сериализует всю команду за собой.

Выбери лучший вариант

main ломается несколько раз в день из-за PR, каждый из которых был зелёным на своей ветке — логические конфликты вроде ренейминга плюс устаревшего вызова. Ты сливаешь высокий объём PR. Что реально держит main зелёным, не дросселируя пропускную способность?

Викторина

Два PR проходят все обязательные проверки на своих ветках, сливаются с разницей в пару минут, и второе слияние красит main — на строке, которую ни один автор не редактировал. Что произошло?

Викторина

Как merge queue держит main зелёным И сохраняет пропускную способность, когда много PR приземляется в час?

Вспомните перед уходом
  1. 01
    Почему два PR, каждый из которых проходит все обязательные проверки, всё равно могут сломать main при слиянии, и почему перезапуск проверок это не чинит?
  2. 02
    Как merge queue держит main зелёным и как батчинг и бисекция сохраняют пропускную способность при высоком объёме слияний?
Итог

Обязательные проверки PR не тестируют main — они тестируют голову PR, слитую с main таким, каким он был при запуске CI, снимок, который устаревает в момент приземления другого PR впереди него. Слияние git блокирует только текстовые конфликты (пересекающиеся строки); оно не видит логических/семантических конфликтов, где два непересекающихся изменения несовместимы по смыслу — классический случай: PR A переименовывает функцию и обновляет её вызовы, а PR B, отведённый раньше, добавляет новый вызов старого имени в файле, который A не трогал. Оба зелёные, git сливает их чисто, и main ломается на строке, которую ни один автор не редактировал, потому что комбинация обоих PR — состояние, которого нигде не существовало, пока слияние его не создало; перезапуск проверок на каждый PR это не ловит. Merge queue чинит это, сериализуя интеграцию: она строит спекулятивную ветку текущего main плюс очередь PR, прогоняет полный набор обязательных проверок на этом комбинированном результате и сливает, только если main-каким-он-станет зелёный — так main остаётся зелёным по построению. Чтобы держать пропускную способность высокой при объёме, очередь батчит N PR в один спекулятивный прогон, сливая все, если зелёно; если батч красный, она бисектит, локализуя и выгоняя виновника, затем перезапускает остальных, так один плохой PR удаляет себя сам, а не клинит очередь. Больше батчи означают больше пропускной способности, но больше потерянной работы при падении, так что размер батча настраивается под частоту падений. Гарантия держится, только если main защищён (всё приземляется через очередь) и гейтящая проверка быстрая, потому что пропускная способность очереди ограничена длительностью этой проверки — ровно поэтому сборки затронутого, кэширование, шардинг и правильные раннеры важны. Теперь, когда видишь main, сломанный двумя зелёными PR, знаешь: фикс структурный — merge queue, — а не операционный, и первое, что проверяешь: защищён ли main и достаточно ли быстра гейтящая проверка, чтобы не дросселировать команду.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.