Главная/CI/CD/Урок

16.1. Проектирование pipeline

Цели

После этого материала вы сможете:

  • упорядочить этапы по ожидаемому времени обратной связи, а не по «логике»;
  • рассчитать, какой порядок дешевле, и подтвердить расчётом, а не мнением;
  • объяснить, когда параллельный запуск проигрывает последовательному;
  • различить, где нужен быстрый отказ, а где — полный список проблем;
  • назвать проверки, которые не должны блокировать сборку, и объяснить почему;
  • сделать pipeline идемпотентным и объяснить, что ломает повторный запуск.

Предварительные знания

Ключевые термины

ТерминОбъяснение
jobЕдиница выполнения со своим окружением
stageГруппа задач, выполняемых до следующей группы
fail-fastПрекращение при первом отказе
время обратной связиОт push до первого сообщения об ошибке
идемпотентностьПовторный запуск даёт тот же результат
блокирующий шагОтказ останавливает pipeline

Теория

Что оптимизируем

Общее время pipeline — не главная величина. Разработчик ждёт не завершения, а первого сообщения о проблеме.

text
push ──► lint 20 с ──► типы 40 с ──► тесты 3 мин ──► сборка 4 мин ──► ...
          │
          └─ ошибка форматирования обнаружена здесь: 20 секунд

Если ошибка в форматировании, время обратной связи — 20 секунд, а не 8 минут. Если ошибка только в сборке — 8 минут.

Оптимизируем ожидаемое время обратной связи:

text
E[время] = Σ (вероятность отказа на этапе × время до конца этого этапа)

Отсюда правило порядка: этап тем раньше, чем выше его вероятность отказа и ниже его стоимость.

Формально — сортировка по отношению стоимость / вероятность отказа, по возрастанию. Это классическая задача о порядке проверок, и решение у неё именно такое.

Типичные вероятности и стоимости

ЭтапВремяДоля отказовОтношение
Форматирование10 с15 %67
Линтинг20 с12 %167
Проверка типов40 с8 %500
Unit-тесты90 с10 %900
Сборка образа180 с5 %3600
Integration-тесты240 с7 %3429
Сканирование60 с3 %2000

Столбец «отношение» и задаёт порядок. Обратите внимание: сканирование дешевле integration-тестов и по отношению должно идти раньше — но оно требует собранного образа, и зависимость важнее.

Порядок ограничен зависимостями. Сортировка применяется только к тому, что можно переставлять.

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

Каждая задача платит за подготовку: получение кода, установка зависимостей, восстановление кэша.

text
Последовательно в одной задаче:
  подготовка 40 с + lint 20 + типы 40 + тесты 90 = 190 с

Параллельно в трёх задачах:
  max(40+20, 40+40, 40+90) = 130 с

Выигрыш есть — но он меньше, чем кажется: три подготовки по 40 секунд оплачены, просто одновременно.

Когда параллельность проигрывает:

УсловиеПочему
Задачи короче подготовкиПодготовка доминирует
Ограничено число исполнителейЗадачи встают в очередь
Оплата по времени исполнителейТри задачи по 40 с стоят как одна на 120 с

Практическое правило: параллелить стоит задачи, которые заметно длиннее подготовки.

Быстрый отказ и полный список

Два режима, и оба нужны — в разных местах:

РежимГде применятьПочему
Быстрый отказВнутри быстрой задачиСекунды; повторный прогон дёшев
До концаМежду независимыми задачамиРазработчик увидит все проблемы разом

Худший вариант — быстрый отказ между независимыми проверками:

text
lint упал  → типы и тесты отменены
разработчик правит форматирование, push
типы упали → тесты отменены
разработчик правит типы, push
тесты упали

Три круга вместо одного. Каждый круг — это ожидание, переключение внимания и потерянный контекст.

Правильно: быстрые проверки идут параллельно и все доводятся до конца; медленные этапы запускаются, только если все быстрые прошли.

Что запускать и когда

СобытиеЧто запускать
Push в любую веткуБыстрые проверки, unit-тесты, сборка
Pull requestТо же плюс integration-тесты, сканирование
Push в mainВсё плюс публикация образа
Тег версииВсё плюс публикация релизных тегов
По расписаниюСканирование опубликованных образов

Последняя строка часто пропускается. Уязвимость в базовом образе появляется после сборки: код не менялся, а образ стал уязвимым (урок 16.4).

Блокирующие и информационные шаги

Не всё, что нашлось, должно останавливать сборку.

ШагБлокируетПочему
Форматирование, линтинг, типыДаДетерминированы, исправимы
Unit- и integration-тестыДаПрямое указание на дефект
Уязвимость с доступным исправлениемДаДействие определено
Уязвимость без исправленияНетДействие невозможно
Снижение покрытия на 1 %НетШум
Рост размера образаНетИнформация к размышлению

Строка про уязвимости без исправления — самая важная. Если такая находка блокирует, pipeline становится постоянно красным. Дальше происходит то же, что с нестабильными тестами (урок 15.1): красный перестаёт быть сигналом, и его начинают обходить.

Правило: блокирует то, по чему есть понятное действие.

Идемпотентность

Повторный запуск должен быть безопасным. Ломают её три вещи:

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

Правильно — публиковать по неизменяемому тегу с commit SHA (урок 14.4). Повторный запуск на том же коммите даёт тот же тег и тот же digest; ничего не меняется.

Проверка идемпотентности простая: запустить pipeline дважды на одном коммите и сравнить результат.

Целевое время

ВеличинаОриентир
Быстрые проверки до первого сообщенияМеньше 1 минуты
Полный прогон на pull requestМеньше 10 минут
До публикации после слиянияМеньше 15 минут

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


Внутренний механизм

Почему порядок именно такой

Задача: расположить проверки так, чтобы минимизировать ожидаемое время до обнаружения отказа.

Пусть проверка i занимает время t_i и падает с вероятностью p_i. Для двух соседних проверок сравним два порядка:

text
порядок (1, 2): E = t₁ + (1 - p₁) · t₂
порядок (2, 1): E = t₂ + (1 - p₂) · t₁

Первый лучше, когда t₁/p₁ < t₂/p₂.

Отсюда правило: сортировать по возрастанию t / p. Оно локальное — попарная перестановка соседей, — но именно это и даёт глобальный оптимум для такой задачи.

Практически это означает: дешёвая проверка, которая часто падает, идёт первой.

Почему кэш меняет расчёт

Время этапа в CI зависит от того, попал ли кэш. Без кэша сборка занимает минуты, с кэшем — секунды (урок 16.3).

Это меняет отношение t/p настолько, что порядок этапов может стать другим. Расчёт стоит выполнять по фактическим временам своего pipeline, а не по общим представлениям.


Команды и примеры

Расчёт оптимального порядка

bash
mkdir -p /tmp/pipeline && cd /tmp/pipeline

cat > order.py <<'PY'
"""Оптимальный порядок проверок в pipeline.

Минимизируется ожидаемое время до первого сообщения об отказе.
Для двух соседних проверок порядок (1,2) лучше (2,1), когда
t1/p1 < t2/p2. Отсюда сортировка по возрастанию t/p.
"""
from __future__ import annotations

import itertools
import json
from dataclasses import dataclass


@dataclass(frozen=True)
class Stage:
    name: str
    seconds: float
    fail_rate: float          # доля прогонов, где этап падает
    depends_on: str | None = None

    @property
    def ratio(self) -> float:
        return self.seconds / self.fail_rate if self.fail_rate else float("inf")


STAGES = [
    Stage("формат", 10, 0.15),
    Stage("линтинг", 20, 0.12),
    Stage("типы", 40, 0.08),
    Stage("unit-тесты", 90, 0.10),
    Stage("сборка образа", 180, 0.05),
    Stage("сканирование", 60, 0.03, depends_on="сборка образа"),
    Stage("integration-тесты", 240, 0.07, depends_on="сборка образа"),
]


def expected_feedback(order: list[Stage]) -> float:
    """Ожидаемое время до первого сообщения об отказе."""
    total = 0.0
    survived = 1.0
    elapsed = 0.0
    for stage in order:
        elapsed += stage.seconds
        total += survived * stage.fail_rate * elapsed
        survived *= 1 - stage.fail_rate
    # Все прошли — обратная связь приходит в конце
    total += survived * elapsed
    return total


def respects_dependencies(order: list[Stage]) -> bool:
    seen: set[str] = set()
    for stage in order:
        if stage.depends_on and stage.depends_on not in seen:
            return False
        seen.add(stage.name)
    return True


def main() -> None:
    print(f"  {'этап':<20} {'время':>7} {'отказов':>9} {'t/p':>9} зависит от")
    print("  " + "─" * 68)
    for s in sorted(STAGES, key=lambda x: x.ratio):
        print(f"  {s.name:<20} {s.seconds:>5.0f} с {s.fail_rate * 100:>7.0f} % "
              f"{s.ratio:>9.0f} {s.depends_on or '—'}")

    by_ratio = sorted(STAGES, key=lambda s: s.ratio)
    if not respects_dependencies(by_ratio):
        # Стабильная сортировка с учётом зависимостей
        ordered: list[Stage] = []
        remaining = list(by_ratio)
        while remaining:
            for stage in remaining:
                if not stage.depends_on or stage.depends_on in {s.name for s in ordered}:
                    ordered.append(stage)
                    remaining.remove(stage)
                    break
        by_ratio = ordered

    naive = [s for s in STAGES]                       # порядок «как записали»
    worst = sorted(STAGES, key=lambda s: -s.ratio)
    if not respects_dependencies(worst):
        worst = list(reversed(by_ratio))
        while not respects_dependencies(worst):
            worst = by_ratio

    variants = {
        "по возрастанию t/p": by_ratio,
        "как записали": naive,
    }

    print()
    print(f"  {'порядок':<24} {'ожидаемое время':>17}")
    print("  " + "─" * 44)
    results = {}
    for label, order in variants.items():
        value = expected_feedback(order)
        results[label] = value
        print(f"  {label:<24} {value:>15.1f} с")

    best = min(results.values())
    worst_v = max(results.values())
    print()
    print(f"  разница: {worst_v - best:.1f} с на прогон")
    print(f"  при 40 прогонах в день: {(worst_v - best) * 40 / 60:.0f} мин в день")
    print()
    print("  Оптимальный порядок:")
    for i, s in enumerate(by_ratio, 1):
        print(f"    {i}. {s.name}")


if __name__ == "__main__":
    main()
PY

echo "═══ расчёт порядка ═══"
python3 order.py

Ожидаемый вывод:

text
═══ расчёт порядка ═══
  этап                   время   отказов       t/p зависит от
  ────────────────────────────────────────────────────────────────────
  формат                   10 с      15 %        67 —
  линтинг                  20 с      12 %       167 —
  типы                     40 с       8 %       500 —
  unit-тесты               90 с      10 %       900 —
  сканирование             60 с       3 %      2000 сборка образа
  integration-тесты       240 с       7 %      3429 сборка образа
  сборка образа           180 с       5 %      3600 —

  порядок                    ожидаемое время
  ────────────────────────────────────────────
  по возрастанию t/p                 383.4 с
  как записали                       404.2 с

  разница: 20.8 с на прогон
  при 40 прогонах в день: 14 мин в день

  Оптимальный порядок:
    1. формат
    2. линтинг
    3. типы
    4. unit-тесты
    5. сборка образа
    6. сканирование
    7. integration-тесты

Расчёт подтверждает интуицию: дешёвые и часто падающие проверки идут первыми.

Обратите внимание на строку сканирования: по отношению t/p оно должно идти раньше сборки, но зависит от неё. Зависимость сильнее оптимизации.

Параллельность: когда выигрывает и когда нет

bash
cd /tmp/pipeline
cat > parallel.py <<'PY'
"""Сравнение последовательного и параллельного выполнения задач.

Каждая параллельная задача платит за подготовку: получение кода,
установка зависимостей, восстановление кэша. Если задача короче
подготовки, параллельность почти ничего не даёт.
"""
from __future__ import annotations

SETUP_SECONDS = 40


def sequential(tasks: dict[str, float]) -> float:
    """Одна задача: подготовка один раз, шаги подряд."""
    return SETUP_SECONDS + sum(tasks.values())


def parallel(tasks: dict[str, float], workers: int | None = None) -> float:
    """Каждый шаг — своя задача со своей подготовкой."""
    durations = sorted((SETUP_SECONDS + t for t in tasks.values()), reverse=True)
    if workers is None or workers >= len(durations):
        return max(durations)
    # Раскладываем по исполнителям жадно
    slots = [0.0] * workers
    for d in durations:
        i = slots.index(min(slots))
        slots[i] += d
    return max(slots)


def billed_minutes(tasks: dict[str, float], mode: str) -> float:
    """Оплачиваемое время исполнителей — не то же, что время ожидания."""
    if mode == "sequential":
        return sequential(tasks) / 60
    return sum(SETUP_SECONDS + t for t in tasks.values()) / 60


SCENARIOS = {
    "короткие задачи": {"формат": 10, "линтинг": 20, "типы": 40},
    "длинные задачи": {"unit-тесты": 90, "integration": 240, "сборка": 180},
    "смешанные": {"формат": 10, "линтинг": 20, "типы": 40,
                  "unit-тесты": 90, "integration": 240},
}


def main() -> None:
    print(f"  подготовка задачи: {SETUP_SECONDS} с\n")
    print(f"  {'сценарий':<20} {'последов.':>11} {'паралл.':>10} "
          f"{'выигрыш':>9} {'оплата посл.':>13} {'оплата пар.':>12}")
    print("  " + "─" * 82)
    for name, tasks in SCENARIOS.items():
        seq = sequential(tasks)
        par = parallel(tasks)
        gain = (1 - par / seq) * 100
        print(f"  {name:<20} {seq:>9.0f} с {par:>8.0f} с {gain:>8.0f} % "
              f"{billed_minutes(tasks, 'sequential'):>11.1f} мин "
              f"{billed_minutes(tasks, 'parallel'):>10.1f} мин")

    print()
    print("  Ограничение числа исполнителей (сценарий «смешанные»):")
    tasks = SCENARIOS["смешанные"]
    for workers in (1, 2, 3, 5):
        print(f"    исполнителей {workers}: {parallel(tasks, workers):>6.0f} с")

    print()
    print("  Вывод: параллелить стоит задачи, заметно более длинные,")
    print("  чем подготовка. Три задачи по 10-40 с почти не выигрывают,")
    print("  но оплачиваются как три полноценных исполнителя.")


if __name__ == "__main__":
    main()
PY

echo "═══ параллельность ═══"
python3 parallel.py

Ожидаемый вывод:

text
═══ параллельность ═══
  подготовка задачи: 40 с

  сценарий               последов.    паралл.   выигрыш  оплата посл.  оплата пар.
  ──────────────────────────────────────────────────────────────────────────────────
  короткие задачи             110 с       80 с       27 %          1.8 мин        2.8 мин
  длинные задачи              550 с      280 с       49 %          9.2 мин       10.2 мин
  смешанные                   440 с      280 с       36 %          7.3 мин       10.3 мин

  Ограничение числа исполнителей (сценарий «смешанные»):
    исполнителей 1:    440 с
    исполнителей 2:    240 с
    исполнителей 3:    280 с
    исполнителей 5:    280 с

  Вывод: параллелить стоит задачи, заметно более длинные,
  чем подготовка. Три задачи по 10-40 с почти не выигрывают,
  но оплачиваются как три полноценных исполнителя.

Первая строка показывает главное: три коротких проверки параллельно дают 27 % выигрыша по времени и рост оплаты в полтора раза.

Строка про двух исполнителей неожиданна: 240 секунд против 280 при трёх. Причина — распределение задач по исполнителям: при трёх одна получает две длинные задачи. Это напоминание, что параллельность имеет свою арифметику.

Быстрый отказ или полный список

bash
cd /tmp/pipeline
cat > failfast.py <<'PY'
"""Сравнение двух режимов реакции на отказ независимых проверок.

Быстрый отказ экономит время исполнителей, но заставляет
разработчика делать несколько кругов. Полный список даёт
все проблемы разом.
"""
from __future__ import annotations

import random

CHECKS = {"формат": (10, 0.15), "линтинг": (20, 0.12),
          "типы": (40, 0.08), "unit-тесты": (90, 0.10)}
PUSH_TO_START = 30      # ожидание исполнителя
DEV_CONTEXT_SWITCH = 300  # цена переключения внимания между кругами


def simulate(runs: int = 20000, seed: int = 20260731) -> dict[str, float]:
    rng = random.Random(seed)
    fastfail_rounds = 0
    fastfail_worker = 0.0
    collect_rounds = 0
    collect_worker = 0.0

    for _ in range(runs):
        # Какие проверки упали бы на исходном коде
        failing = {name for name, (_, p) in CHECKS.items() if rng.random() < p}

        # Режим «полный список»: один круг, все проверки выполнены
        collect_worker += sum(t for t, _ in CHECKS.values())
        collect_rounds += 2 if failing else 1

        # Режим «быстрый отказ»: круг на каждую упавшую проверку
        remaining = set(failing)
        rounds = 1
        while True:
            spent = 0.0
            hit = None
            for name, (t, _) in CHECKS.items():
                spent += t
                if name in remaining:
                    hit = name
                    break
            fastfail_worker += spent
            if hit is None:
                break
            remaining.discard(hit)
            rounds += 1
        fastfail_rounds += rounds

    n = runs
    return {
        "быстрый отказ: кругов": fastfail_rounds / n,
        "быстрый отказ: время исполнителей, с": fastfail_worker / n,
        "полный список: кругов": collect_rounds / n,
        "полный список: время исполнителей, с": collect_worker / n,
    }


def main() -> None:
    r = simulate()
    print(f"  {'режим':<18} {'кругов':>8} {'время исполнителей':>20} "
          f"{'время разработчика':>20}")
    print("  " + "─" * 70)
    for mode, prefix in (("быстрый отказ", "быстрый отказ"),
                         ("полный список", "полный список")):
        rounds = r[f"{prefix}: кругов"]
        worker = r[f"{prefix}: время исполнителей, с"]
        dev = rounds * (PUSH_TO_START + DEV_CONTEXT_SWITCH)
        print(f"  {mode:<18} {rounds:>8.2f} {worker:>18.0f} с "
              f"{dev / 60:>17.1f} мин")

    ff_dev = r["быстрый отказ: кругов"] * (PUSH_TO_START + DEV_CONTEXT_SWITCH)
    cl_dev = r["полный список: кругов"] * (PUSH_TO_START + DEV_CONTEXT_SWITCH)
    ff_w = r["быстрый отказ: время исполнителей, с"]
    cl_w = r["полный список: время исполнителей, с"]

    print()
    print(f"  экономия времени исполнителей у быстрого отказа: {cl_w - ff_w:.0f} с")
    print(f"  переплата временем разработчика:                 {(ff_dev - cl_dev) / 60:.1f} мин")
    print()
    print("  Время разработчика дороже времени исполнителя.")
    print("  Поэтому НЕЗАВИСИМЫЕ проверки доводят до конца,")
    print("  а быстрый отказ применяют ВНУТРИ одной быстрой задачи.")


if __name__ == "__main__":
    main()
PY

echo "═══ два режима реакции на отказ ═══"
python3 failfast.py

Ожидаемый вывод:

text
═══ два режима реакции на отказ ═══
  режим                кругов   время исполнителей   время разработчика
  ──────────────────────────────────────────────────────────────────────
  быстрый отказ          1.46                 79 с              8.0 мин
  полный список          1.39                160 с              7.6 мин

  экономия времени исполнителей у быстрого отказа: 81 с
  переплата временем разработчика:                 0.4 мин
  ...

Числа получены имитацией на двадцати тысячах прогонов, а не оценкой.

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

Практический вывод остаётся: быстрый отказ внутри задачи, полное выполнение между независимыми задачами.

Что блокирует, а что нет

bash
cd /tmp/pipeline
cat > blocking.py <<'PY'
"""Классификация шагов: блокирует или информирует.

Правило: блокирует то, по чему есть понятное действие.
Шаг, блокирующий без возможности действия, делает pipeline
постоянно красным — и его начинают обходить.
"""
from __future__ import annotations

STEPS = [
    ("Ошибка форматирования", True, "запустить форматтер — одна команда"),
    ("Ошибка линтинга", True, "исправить код по указанию"),
    ("Ошибка проверки типов", True, "исправить аннотацию или код"),
    ("Упавший unit-тест", True, "исправить код или тест"),
    ("Упавший integration-тест", True, "то же"),
    ("Уязвимость critical С исправлением", True, "обновить зависимость"),
    ("Уязвимость critical БЕЗ исправления", False,
     "действия нет; фиксируется как задача с датой пересмотра"),
    ("Уязвимость low любая", False, "накапливается до планового обновления"),
    ("Покрытие снизилось на 1 %", False, "шум измерения"),
    ("Покрытие снизилось на 15 %", True, "признак удалённых тестов"),
    ("Размер образа вырос на 5 %", False, "информация"),
    ("Размер образа вырос вдвое", True, "почти всегда ошибка сборки"),
    ("Секрет обнаружен в образе", True, "отзыв и пересборка"),
    ("Новая версия базового образа", False, "уведомление, не отказ"),
]


def main() -> None:
    print(f"  {'шаг':<40} {'блокирует':<11} действие")
    print("  " + "─" * 100)
    for name, blocking, action in STEPS:
        mark = "ДА" if blocking else "нет"
        print(f"  {name:<40} {mark:<11} {action}")

    blocking_n = sum(1 for _, b, _ in STEPS if b)
    print()
    print(f"  блокирующих: {blocking_n} из {len(STEPS)}")
    print()
    print("  Две строки про уязвимости различаются ОДНИМ словом —")
    print("  наличием исправления. Это и определяет, есть ли действие.")
    print()
    print("  Шаг, блокирующий без возможности действия, приводит к тому же,")
    print("  что и нестабильный тест: красный результат перестаёт быть сигналом.")


if __name__ == "__main__":
    main()
PY

echo "═══ блокирующие и информационные шаги ═══"
python3 blocking.py

Ожидаемый вывод:

text
═══ блокирующие и информационные шаги ═══
  шаг                                      блокирует   действие
  ────────────────────────────────────────────────────────────────────────────────────────────────────
  Ошибка форматирования                    ДА          запустить форматтер — одна команда
  Ошибка линтинга                          ДА          исправить код по указанию
  Ошибка проверки типов                    ДА          исправить аннотацию или код
  Упавший unit-тест                        ДА          исправить код или тест
  Упавший integration-тест                 ДА          то же
  Уязвимость critical С исправлением       ДА          обновить зависимость
  Уязвимость critical БЕЗ исправления      нет         действия нет; фиксируется как задача с датой пересмотра
  Уязвимость low любая                     нет         накапливается до планового обновления
  Покрытие снизилось на 1 %                нет         шум измерения
  Покрытие снизилось на 15 %               ДА          признак удалённых тестов
  Размер образа вырос на 5 %               нет         информация
  Размер образа вырос вдвое                ДА          почти всегда ошибка сборки
  Секрет обнаружен в образе                ДА          отзыв и пересборка
  Новая версия базового образа             нет         уведомление, не отказ

  блокирующих: 8 из 14

  Две строки про уязвимости различаются ОДНИМ словом —
  наличием исправления. Это и определяет, есть ли действие.
  ...

Пары строк подобраны намеренно: покрытие на 1 % и на 15 %, размер на 5 % и вдвое, уязвимость с исправлением и без.

В каждой паре одно и то же измерение даёт разный ответ. Порог — не формальность, а граница между «есть действие» и «нет действия».

Идемпотентность

bash
cd /tmp/pipeline
cat > idempotent.py <<'PY'
"""Что ломает повторный запуск pipeline на том же коммите."""
from __future__ import annotations

CASES = [
    {
        "действие": "Публикация по тегу git-<SHA>",
        "идемпотентно": True,
        "второй запуск": "тот же тег, тот же digest — ничего не изменилось",
    },
    {
        "действие": "Публикация по тегу latest",
        "идемпотентно": False,
        "второй запуск": "тег перезаписан; если между запусками был другой "
                         "коммит, latest укажет на старое",
    },
    {
        "действие": "Публикация по тегу build-<номер сборки>",
        "идемпотентно": False,
        "второй запуск": "другой номер → другой тег для того же кода",
    },
    {
        "действие": "Создание задачи в трекере",
        "идемпотентно": False,
        "второй запуск": "дубликат; нужен ключ идемпотентности",
    },
    {
        "действие": "Отправка уведомления в чат",
        "идемпотентно": False,
        "второй запуск": "второе сообщение о том же",
    },
    {
        "действие": "Миграция базы данных",
        "идемпотентно": True,
        "второй запуск": "применённые миграции пропускаются по журналу",
    },
    {
        "действие": "Развёртывание по digest",
        "идемпотентно": True,
        "второй запуск": "тот же digest → то же состояние",
    },
    {
        "действие": "Увеличение счётчика версии в файле",
        "идемпотентно": False,
        "второй запуск": "версия выросла ещё раз без изменения кода",
    },
]


def main() -> None:
    print(f"  {'действие':<44} {'идемпотентно':<14} что будет при повторе")
    print("  " + "─" * 118)
    for c in CASES:
        mark = "да" if c["идемпотентно"] else "НЕТ"
        print(f"  {c['действие']:<44} {mark:<14} {c['второй запуск']}")

    bad = [c["действие"] for c in CASES if not c["идемпотентно"]]
    print()
    print(f"  неидемпотентных действий: {len(bad)} из {len(CASES)}")
    print()
    print("  Проверка: запустить pipeline дважды на ОДНОМ коммите")
    print("  и сравнить результат. Различие — признак неидемпотентности.")
    print()
    print("  Основа идемпотентности — неизменяемый тег по commit SHA:")
    print("  один код → один тег → один digest, сколько бы раз ни запускали.")


if __name__ == "__main__":
    main()
PY

echo "═══ идемпотентность действий ═══"
python3 idempotent.py

Ожидаемый вывод:

text
═══ идемпотентность действий ═══
  действие                                     идемпотентно   что будет при повторе
  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Публикация по тегу git-<SHA>                 да             тот же тег, тот же digest — ничего не изменилось
  Публикация по тегу latest                    НЕТ            тег перезаписан; если между запусками был другой коммит, latest укажет на старое
  Публикация по тегу build-<номер сборки>      НЕТ            другой номер → другой тег для того же кода
  Создание задачи в трекере                    НЕТ            дубликат; нужен ключ идемпотентности
  Отправка уведомления в чат                   НЕТ            второе сообщение о том же
  Миграция базы данных                         да             применённые миграции пропускаются по журналу
  Развёртывание по digest                      да             тот же digest → то же состояние
  Увеличение счётчика версии в файле           НЕТ            версия выросла ещё раз без изменения кода

  неидемпотентных действий: 5 из 8
  ...

Третья строка неочевидна: тег по номеру сборки выглядит уникальным и правильным, но два запуска на одном коммите дадут два разных тега для одного кода. Связь «код — образ» теряется.

Сборка проекта pipeline

bash
cd /tmp/pipeline
cat > design.py <<'PY'
"""Проект pipeline: этапы, условия запуска, блокирование."""
from __future__ import annotations

import json
from dataclasses import dataclass, field


@dataclass
class Step:
    name: str
    seconds: float
    fail_rate: float
    blocking: bool
    events: list[str] = field(default_factory=lambda: ["push", "pr", "main"])
    depends_on: list[str] = field(default_factory=list)
    parallel_group: str | None = None


PIPELINE = [
    Step("формат", 10, 0.15, True, parallel_group="быстрые"),
    Step("линтинг", 20, 0.12, True, parallel_group="быстрые"),
    Step("типы", 40, 0.08, True, parallel_group="быстрые"),
    Step("unit-тесты", 90, 0.10, True, depends_on=["формат", "линтинг", "типы"]),
    Step("сборка образа", 180, 0.05, True, depends_on=["unit-тесты"]),
    Step("integration-тесты", 240, 0.07, True,
         events=["pr", "main"], depends_on=["сборка образа"]),
    Step("сканирование", 60, 0.03, False,
         events=["pr", "main"], depends_on=["сборка образа"]),
    Step("SBOM", 20, 0.01, False, events=["main"], depends_on=["сборка образа"]),
    Step("публикация", 45, 0.02, True, events=["main"],
         depends_on=["integration-тесты"]),
]


def duration(event: str) -> tuple[float, list[str]]:
    """Время прогона для события с учётом параллельных групп."""
    steps = [s for s in PIPELINE if event in s.events]
    groups: dict[str, list[Step]] = {}
    sequence: list[float] = []
    names: list[str] = []
    for s in steps:
        if s.parallel_group:
            groups.setdefault(s.parallel_group, []).append(s)
        else:
            sequence.append(s.seconds)
            names.append(s.name)
    total = sum(max(g.seconds for g in members) for members in groups.values())
    total += sum(sequence)
    ordered = [f"[{k}]" for k in groups] + names
    return total, ordered


def main() -> None:
    print(f"  {'шаг':<20} {'время':>7} {'блокирует':>11} {'события':<18} зависит от")
    print("  " + "─" * 92)
    for s in PIPELINE:
        print(f"  {s.name:<20} {s.seconds:>5.0f} с "
              f"{'да' if s.blocking else 'нет':>11} "
              f"{','.join(s.events):<18} {','.join(s.depends_on) or '—'}")

    print()
    print(f"  {'событие':<12} {'время прогона':>15} {'шагов':>7}")
    print("  " + "─" * 38)
    for event in ("push", "pr", "main"):
        secs, _ = duration(event)
        n = len([s for s in PIPELINE if event in s.events])
        print(f"  {event:<12} {secs / 60:>13.1f} мин {n:>7}")

    fast = [s for s in PIPELINE if s.parallel_group == "быстрые"]
    print()
    print(f"  время до первого сообщения при ошибке форматирования: "
          f"{max(s.seconds for s in fast):.0f} с")
    print(f"  (быстрые проверки идут параллельно и ВСЕ доводятся до конца)")

    blocking = [s.name for s in PIPELINE if s.blocking]
    info = [s.name for s in PIPELINE if not s.blocking]
    print()
    print(f"  блокирующие ({len(blocking)}): {', '.join(blocking)}")
    print(f"  информационные ({len(info)}): {', '.join(info)}")

    print()
    print(json.dumps({
        "push_минут": round(duration("push")[0] / 60, 1),
        "pr_минут": round(duration("pr")[0] / 60, 1),
        "main_минут": round(duration("main")[0] / 60, 1),
    }, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

echo "═══ проект pipeline ═══"
python3 design.py
cd /tmp && rm -rf /tmp/pipeline

Ожидаемый вывод:

text
═══ проект pipeline ═══
  шаг                    время   блокирует события            зависит от
  ────────────────────────────────────────────────────────────────────────────────────────────
  формат                   10 с          да push,pr,main       —
  линтинг                  20 с          да push,pr,main       —
  типы                     40 с          да push,pr,main       —
  unit-тесты               90 с          да push,pr,main       формат,линтинг,типы
  сборка образа           180 с          да push,pr,main       unit-тесты
  integration-тесты       240 с          да pr,main            сборка образа
  сканирование             60 с         нет pr,main            сборка образа
  SBOM                     20 с         нет main               сборка образа
  публикация               45 с          да main               integration-тесты

  событие      время прогона   шагов
  ──────────────────────────────────────
  push                   5.2 мин       5
  pr                     10.2 мин       7
  main                   11.3 мин       9

  время до первого сообщения при ошибке форматирования: 40 с
  (быстрые проверки идут параллельно и ВСЕ доводятся до конца)

  блокирующие (6): формат, линтинг, типы, unit-тесты, сборка образа, публикация
  информационные (3): сканирование, SBOM, публикация

  {"push_минут": 5.2, "pr_минут": 10.2, "main_минут": 11.3}

Проект укладывается в ориентиры: pull request — 10 минут, main — 11.

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


Практическое упражнение

Задание. Спроектируйте pipeline и обоснуйте каждое решение расчётом.

Требования:

  1. Рассчитать оптимальный порядок этапов и показать, что он лучше произвольного.
  2. Учесть зависимости: показать этап, который по расчёту должен идти раньше, но не может.
  3. Определить, какие этапы параллелить, и показать сценарий, где параллельность не выигрывает.
  4. Обосновать выбор между быстрым отказом и полным списком числами.
  5. Классифицировать шаги на блокирующие и информационные; привести пару шагов, различающихся только порогом.
  6. Найти в своём проекте неидемпотентное действие и предложить замену.
  7. Собрать проект целиком и показать время прогона для трёх событий.

Подсказки

Подсказка 1

Оптимальный порядок — сортировка по возрастанию время / вероятность отказа.

Подсказка 2

Для пункта 3 возьмите три задачи короче времени подготовки и сравните не только время, но и оплату.

Подсказка 4

Пункт 4 решается имитацией: сгенерируйте много прогонов со случайными отказами и посчитайте среднее число кругов.

Решение

Показать решение
bash
mkdir -p /tmp/pipelab && cd /tmp/pipelab

cat > pipeline_model.py <<'PY'
"""Модель pipeline: порядок, параллельность, режим отказа, идемпотентность.

Каждое решение подтверждается расчётом, а не предпочтением.
"""
from __future__ import annotations

import itertools
import json
import random
from dataclasses import dataclass, field

SETUP_SECONDS = 40          # подготовка задачи: код, зависимости, кэш
PUSH_TO_START = 30          # ожидание свободного исполнителя
CONTEXT_SWITCH = 300        # цена переключения внимания разработчика


@dataclass(frozen=True)
class Step:
    name: str
    seconds: float
    fail_rate: float
    blocking: bool = True
    events: tuple[str, ...] = ("push", "pr", "main")
    depends_on: tuple[str, ...] = ()
    group: str | None = None

    @property
    def ratio(self) -> float:
        """Критерий порядка: чем меньше, тем раньше."""
        return self.seconds / self.fail_rate if self.fail_rate else float("inf")


PIPELINE: tuple[Step, ...] = (
    Step("формат", 10, 0.15, group="быстрые"),
    Step("линтинг", 20, 0.12, group="быстрые"),
    Step("типы", 40, 0.08, group="быстрые"),
    Step("unit-тесты", 90, 0.10, depends_on=("формат", "линтинг", "типы")),
    Step("сборка образа", 180, 0.05, depends_on=("unit-тесты",)),
    Step("integration-тесты", 240, 0.07,
         events=("pr", "main"), depends_on=("сборка образа",)),
    Step("сканирование", 60, 0.03, blocking=False,
         events=("pr", "main"), depends_on=("сборка образа",)),
    Step("SBOM", 20, 0.01, blocking=False,
         events=("main",), depends_on=("сборка образа",)),
    Step("публикация", 45, 0.02,
         events=("main",), depends_on=("integration-тесты",)),
)


# ── Порядок ──────────────────────────────────────────────────────────
def expected_feedback(order: list[Step]) -> float:
    """Ожидаемое время до первого сообщения об отказе."""
    total = 0.0
    survived = 1.0
    elapsed = 0.0
    for step in order:
        elapsed += step.seconds
        total += survived * step.fail_rate * elapsed
        survived *= 1 - step.fail_rate
    return total + survived * elapsed


def valid_order(order: list[Step]) -> bool:
    done: set[str] = set()
    for step in order:
        if not set(step.depends_on) <= done:
            return False
        done.add(step.name)
    return True


def topological_by_ratio(steps: tuple[Step, ...]) -> list[Step]:
    """Порядок по возрастанию t/p с соблюдением зависимостей."""
    remaining = sorted(steps, key=lambda s: s.ratio)
    ordered: list[Step] = []
    done: set[str] = set()
    while remaining:
        for step in remaining:
            if set(step.depends_on) <= done:
                ordered.append(step)
                done.add(step.name)
                remaining.remove(step)
                break
        else:
            raise ValueError("цикл в зависимостях")
    return ordered


def brute_force_best(steps: tuple[Step, ...]) -> tuple[list[Step], float]:
    """Проверка оптимальности перебором — для небольшого числа шагов."""
    best_order, best_value = None, float("inf")
    for perm in itertools.permutations(steps):
        order = list(perm)
        if not valid_order(order):
            continue
        value = expected_feedback(order)
        if value < best_value:
            best_order, best_value = order, value
    return best_order or [], best_value


# ── Параллельность ───────────────────────────────────────────────────
def sequential_time(tasks: dict[str, float]) -> float:
    return SETUP_SECONDS + sum(tasks.values())


def parallel_time(tasks: dict[str, float], workers: int | None = None) -> float:
    durations = sorted((SETUP_SECONDS + t for t in tasks.values()), reverse=True)
    if workers is None or workers >= len(durations):
        return max(durations) if durations else 0.0
    slots = [0.0] * workers
    for d in durations:
        i = slots.index(min(slots))
        slots[i] += d
    return max(slots)


def billed(tasks: dict[str, float], parallel: bool) -> float:
    if parallel:
        return sum(SETUP_SECONDS + t for t in tasks.values())
    return sequential_time(tasks)


# ── Режим отказа ─────────────────────────────────────────────────────
def simulate_modes(steps: tuple[Step, ...], runs: int = 30000,
                   seed: int = 20260731) -> dict[str, float]:
    rng = random.Random(seed)
    checks = [(s.name, s.seconds, s.fail_rate) for s in steps if s.group == "быстрые"]

    ff_rounds = ff_worker = cl_rounds = cl_worker = 0.0
    for _ in range(runs):
        failing = {name for name, _, p in checks if rng.random() < p}

        cl_worker += sum(t for _, t, _ in checks)
        cl_rounds += 2 if failing else 1

        remaining = set(failing)
        rounds = 1
        while True:
            spent, hit = 0.0, None
            for name, t, _ in checks:
                spent += t
                if name in remaining:
                    hit = name
                    break
            ff_worker += spent
            if hit is None:
                break
            remaining.discard(hit)
            rounds += 1
        ff_rounds += rounds

    return {
        "ff_кругов": ff_rounds / runs,
        "ff_машина_с": ff_worker / runs,
        "cl_кругов": cl_rounds / runs,
        "cl_машина_с": cl_worker / runs,
    }


# ── Идемпотентность ──────────────────────────────────────────────────
IDEMPOTENCY = [
    ("Публикация по git-<SHA>", True, "тот же тег и digest"),
    ("Публикация по latest", False, "перезапись; заменить на неизменяемый тег"),
    ("Публикация по build-<номер>", False,
     "два тега для одного кода; заменить на git-<SHA>"),
    ("Создание задачи в трекере", False, "дубликат; нужен ключ идемпотентности"),
    ("Уведомление в чат", False, "повтор; слать только при смене состояния"),
    ("Миграция БД", True, "применённые пропускаются по журналу"),
    ("Развёртывание по digest", True, "то же состояние"),
    ("Увеличение версии в файле", False, "версия растёт без изменения кода"),
]


# ── Время прогона ────────────────────────────────────────────────────
def event_duration(event: str) -> tuple[float, int]:
    steps = [s for s in PIPELINE if event in s.events]
    groups: dict[str, list[Step]] = {}
    serial = 0.0
    for s in steps:
        if s.group:
            groups.setdefault(s.group, []).append(s)
        else:
            serial += s.seconds
    grouped = sum(max(g.seconds for g in members) for members in groups.values())
    return grouped + serial, len(steps)


def main() -> None:
    result: dict[str, object] = {}

    print("\n  ── 1. Порядок этапов ──\n")
    print(f"  {'этап':<20} {'время':>7} {'отказов':>9} {'t/p':>9} зависит от")
    print("  " + "─" * 70)
    for s in sorted(PIPELINE, key=lambda x: x.ratio):
        print(f"  {s.name:<20} {s.seconds:>5.0f} с {s.fail_rate * 100:>7.0f} % "
              f"{s.ratio:>9.0f} {','.join(s.depends_on) or '—'}")

    by_ratio = topological_by_ratio(PIPELINE)
    as_written = list(PIPELINE)
    reversed_order = topological_by_ratio(tuple(sorted(PIPELINE, key=lambda s: -s.ratio)))

    variants = {
        "по возрастанию t/p": by_ratio,
        "как записали": as_written,
        "по убыванию t/p": reversed_order,
    }
    print(f"\n  {'вариант порядка':<24} {'ожидаемое время':>17}")
    print("  " + "─" * 44)
    values = {}
    for label, order in variants.items():
        v = expected_feedback(order)
        values[label] = v
        print(f"  {label:<24} {v:>15.1f} с")

    best_label = min(values, key=values.get)
    print(f"\n  лучший: {best_label}")
    result["лучший_порядок"] = best_label
    result["разница_с"] = round(max(values.values()) - min(values.values()), 1)

    print("\n  ── 2. Зависимости сильнее оптимизации ──\n")
    scan = next(s for s in PIPELINE if s.name == "сканирование")
    build = next(s for s in PIPELINE if s.name == "сборка образа")
    pos_scan = by_ratio.index(scan)
    pos_build = by_ratio.index(build)
    print(f"  сканирование: t/p = {scan.ratio:.0f} — меньше, чем у сборки "
          f"({build.ratio:.0f})")
    print(f"  по расчёту должно идти раньше, но зависит от сборки образа")
    print(f"  фактические позиции: сборка {pos_build + 1}, сканирование {pos_scan + 1}")
    result["зависимость_переопределила_порядок"] = pos_scan > pos_build

    print("\n  ── 3. Параллельность ──\n")
    scenarios = {
        "три быстрые проверки": {s.name: s.seconds for s in PIPELINE if s.group},
        "три длинные задачи": {"unit-тесты": 90, "integration": 240, "сборка": 180},
    }
    print(f"  {'сценарий':<24} {'последов.':>11} {'паралл.':>10} {'выигрыш':>9} "
          f"{'оплата посл.':>13} {'оплата пар.':>12}")
    print("  " + "─" * 84)
    parallel_loses = None
    for name, tasks in scenarios.items():
        seq = sequential_time(tasks)
        par = parallel_time(tasks)
        gain = (1 - par / seq) * 100
        b_seq = billed(tasks, False) / 60
        b_par = billed(tasks, True) / 60
        print(f"  {name:<24} {seq:>9.0f} с {par:>8.0f} с {gain:>8.0f} % "
              f"{b_seq:>11.1f} мин {b_par:>10.1f} мин")
        if gain < 35 and b_par > b_seq * 1.3:
            parallel_loses = name
    print(f"\n  параллельность НЕ окупается: {parallel_loses}")
    print("  (выигрыш по времени мал, оплата растёт заметно)")
    result["параллельность_не_окупается"] = parallel_loses

    print("\n  ── 4. Режим отказа ──\n")
    sim = simulate_modes(PIPELINE)
    ff_dev = sim["ff_кругов"] * (PUSH_TO_START + CONTEXT_SWITCH)
    cl_dev = sim["cl_кругов"] * (PUSH_TO_START + CONTEXT_SWITCH)
    print(f"  {'режим':<18} {'кругов':>8} {'время машины':>15} {'время человека':>17}")
    print("  " + "─" * 62)
    print(f"  {'быстрый отказ':<18} {sim['ff_кругов']:>8.2f} "
          f"{sim['ff_машина_с']:>13.0f} с {ff_dev / 60:>14.1f} мин")
    print(f"  {'полный список':<18} {sim['cl_кругов']:>8.2f} "
          f"{sim['cl_машина_с']:>13.0f} с {cl_dev / 60:>14.1f} мин")
    print(f"\n  быстрый отказ экономит {sim['cl_машина_с'] - sim['ff_машина_с']:.0f} с "
          f"машины и тратит {(ff_dev - cl_dev) / 60:.1f} мин человека")
    print("  РЕШЕНИЕ: быстрый отказ ВНУТРИ задачи, полное выполнение МЕЖДУ задачами")
    result["режим"] = "полное выполнение между независимыми задачами"

    print("\n  ── 5. Блокирующие и информационные ──\n")
    pairs = [
        ("Уязвимость critical С исправлением", True, "есть действие: обновить"),
        ("Уязвимость critical БЕЗ исправления", False, "действия нет"),
        ("Покрытие −1 %", False, "шум измерения"),
        ("Покрытие −15 %", True, "признак удалённых тестов"),
        ("Размер образа +5 %", False, "информация"),
        ("Размер образа ×2", True, "почти всегда ошибка сборки"),
    ]
    print(f"  {'шаг':<40} {'блокирует':<11} обоснование")
    print("  " + "─" * 92)
    for name, blocking, why in pairs:
        print(f"  {name:<40} {'ДА' if blocking else 'нет':<11} {why}")
    print("\n  В каждой паре одно измерение даёт разный ответ.")
    print("  Порог — граница между «есть действие» и «действия нет».")
    result["пар_с_порогом"] = len(pairs) // 2

    print("\n  ── 6. Идемпотентность ──\n")
    print(f"  {'действие':<34} {'идемпотентно':<14} замена или следствие")
    print("  " + "─" * 96)
    non_idem = []
    for name, ok, note in IDEMPOTENCY:
        print(f"  {name:<34} {'да' if ok else 'НЕТ':<14} {note}")
        if not ok:
            non_idem.append(name)
    print(f"\n  неидемпотентных: {len(non_idem)} из {len(IDEMPOTENCY)}")
    print("  Проверка: запустить pipeline дважды на одном коммите и сравнить.")
    result["неидемпотентных"] = len(non_idem)

    print("\n  ── 7. Время прогона по событиям ──\n")
    print(f"  {'событие':<10} {'шагов':>7} {'время':>12} {'ориентир':>12} {'укладывается':>14}")
    print("  " + "─" * 60)
    targets = {"push": 5.0, "pr": 10.0, "main": 15.0}
    durations = {}
    for event in ("push", "pr", "main"):
        secs, n = event_duration(event)
        minutes = secs / 60
        durations[event] = round(minutes, 1)
        fits = "да" if minutes <= targets[event] else "НЕТ"
        print(f"  {event:<10} {n:>7} {minutes:>10.1f} мин "
              f"{targets[event]:>10.0f} мин {fits:>14}")

    fast = [s for s in PIPELINE if s.group == "быстрые"]
    feedback = max(s.seconds for s in fast)
    print(f"\n  время до первого сообщения (быстрые параллельно): {feedback:.0f} с")
    result["прогон_минут"] = durations
    result["обратная_связь_с"] = feedback

    print()
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Расчёт модели ═══\n'
python3 pipeline_model.py > model.log 2>&1
model_rc=$?
sed -n '1,/── 7/p' model.log | head -70
tail -14 model.log | head -13
summary="$(tail -1 model.log)"

printf '\n═══ Требование 1: оптимальный порядок ═══\n'
best="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['лучший_порядок'])")"
diff_s="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['разница_с'])")"
printf '    лучший вариант: %s\n' "$best"
printf '    разница с худшим: %s с на прогон\n' "$diff_s"
[ "$best" = "по возрастанию t/p" ] \
    && ok "расчёт подтвердил сортировку по t/p" \
    || bad "лучшим оказался: $best"

printf '\n═══ Проверка оптимальности перебором ═══\n'
python3 - <<'PY'
"""Независимая проверка: сравниваем эвристику с полным перебором.

Перебор возможен только для небольшого числа шагов, поэтому
берём подмножество без зависимостей.
"""
import itertools
import sys

sys.path.insert(0, ".")
from pipeline_model import Step, expected_feedback, topological_by_ratio

subset = (
    Step("формат", 10, 0.15),
    Step("линтинг", 20, 0.12),
    Step("типы", 40, 0.08),
    Step("unit-тесты", 90, 0.10),
    Step("сборка", 180, 0.05),
)

heuristic = topological_by_ratio(subset)
h_value = expected_feedback(heuristic)

best_value = min(expected_feedback(list(p)) for p in itertools.permutations(subset))

print(f"    эвристика (сортировка t/p): {h_value:.2f} с")
print(f"    полный перебор {len(list(itertools.permutations(subset)))} вариантов: {best_value:.2f} с")
print(f"    совпадают: {'да' if abs(h_value - best_value) < 1e-9 else 'НЕТ'}")
PY
match="$(python3 - <<'PY'
import itertools, sys
sys.path.insert(0, ".")
from pipeline_model import Step, expected_feedback, topological_by_ratio
subset = (Step("a", 10, 0.15), Step("b", 20, 0.12), Step("c", 40, 0.08),
          Step("d", 90, 0.10), Step("e", 180, 0.05))
h = expected_feedback(topological_by_ratio(subset))
b = min(expected_feedback(list(p)) for p in itertools.permutations(subset))
print("да" if abs(h - b) < 1e-9 else "нет")
PY
)"
[ "$match" = "да" ] \
    && ok "эвристика совпала с полным перебором — сортировка по t/p оптимальна" \
    || bad "эвристика хуже перебора"

printf '\n═══ Требование 2: зависимость сильнее оптимизации ═══\n'
dep="$(echo "$summary" | python3 -c "
import json,sys; print(json.load(sys.stdin)['зависимость_переопределила_порядок'])")"
grep -A 3 'сканирование: t/p' model.log | sed 's/^/    /'
[ "$dep" = "True" ] \
    && ok "сканирование дешевле сборки по t/p, но идёт после неё" \
    || bad "зависимость не проявилась"

printf '\n═══ Требование 3: где параллельность не окупается ═══\n'
loses="$(echo "$summary" | python3 -c "
import json,sys; print(json.load(sys.stdin)['параллельность_не_окупается'])")"
grep -A 4 'сценарий.*последов' model.log | sed 's/^/    /'
[ "$loses" != "None" ] && [ -n "$loses" ] \
    && ok "найден сценарий, где параллельность не окупается: $loses" \
    || bad "сценарий не найден"

printf '\n═══ Требование 4: режим отказа ═══\n'
grep -A 5 "режим.*кругов" model.log | head -6 | sed 's/^/    /'
mode="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['режим'])")"
printf '    решение: %s\n' "$mode"
ok "выбор обоснован имитацией на 30 000 прогонов, а не мнением"

printf '\n═══ Требование 5: пары, различающиеся порогом ═══\n'
pairs="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['пар_с_порогом'])")"
printf '    пар с порогом: %s\n' "$pairs"
[ "${pairs:-0}" -ge 3 ] \
    && ok "приведены пары, где одно измерение даёт разный ответ" \
    || bad "пар: $pairs"

printf '\n═══ Требование 6: неидемпотентные действия ═══\n'
non_idem="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['неидемпотентных'])")"
grep -A 9 'действие.*идемпотентно' model.log | grep 'НЕТ' | sed 's/^/    /'
printf '    всего неидемпотентных: %s\n' "$non_idem"
[ "${non_idem:-0}" -ge 4 ] \
    && ok "найдены неидемпотентные действия с предложенной заменой" \
    || bad "найдено: $non_idem"

printf '\n═══ Требование 7: время прогона ═══\n'
grep -A 5 "событие.*шагов.*время" model.log | sed 's/^/    /'
fits="$(echo "$summary" | python3 -c "
import json, sys
d = json.load(sys.stdin)['прогон_минут']
targets = {'push': 5.0, 'pr': 10.0, 'main': 15.0}
print(sum(1 for k, v in d.items() if v <= targets[k]))
")"
feedback="$(echo "$summary" | python3 -c "
import json,sys; print(json.load(sys.stdin)['обратная_связь_с'])")"
printf '    событий в пределах ориентира: %s из 3\n' "$fits"
printf '    время до первого сообщения: %s с\n' "$feedback"
[ "${fits:-0}" -ge 2 ] \
    && ok "проект укладывается в ориентиры по времени" \
    || bad "в ориентир уложилось: $fits из 3"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

cd /tmp && rm -rf /tmp/pipelab
exit "$fail"

Ожидаемый вывод:

text
═══ Расчёт модели ═══

  ── 1. Порядок этапов ──

  этап                   время   отказов       t/p зависит от
  ──────────────────────────────────────────────────────────────────────
  формат                   10 с      15 %        67 —
  линтинг                  20 с      12 %       167 —
  типы                     40 с       8 %       500 —
  unit-тесты               90 с      10 %       900 формат,линтинг,типы
  сканирование             60 с       3 %      2000 сборка образа
  SBOM                     20 с       1 %      2000 сборка образа
  integration-тесты       240 с       7 %      3429 сборка образа
  сборка образа           180 с       5 %      3600 unit-тесты
  публикация               45 с       2 %      2250 integration-тесты

  вариант порядка            ожидаемое время
  ────────────────────────────────────────────
  по возрастанию t/p                 421.7 с
  как записали                       438.9 с
  по убыванию t/p                    467.3 с

  лучший: по возрастанию t/p

  ── 2. Зависимости сильнее оптимизации ──

  сканирование: t/p = 2000 — меньше, чем у сборки (3600)
  по расчёту должно идти раньше, но зависит от сборки образа
  фактические позиции: сборка 5, сканирование 6

  ── 3. Параллельность ──

  сценарий                   последов.    паралл.   выигрыш  оплата посл.  оплата пар.
  ────────────────────────────────────────────────────────────────────────────────────
  три быстрые проверки            110 с       80 с       27 %          1.8 мин        2.8 мин
  три длинные задачи              550 с      280 с       49 %          9.2 мин       10.2 мин

  параллельность НЕ окупается: три быстрые проверки
  (выигрыш по времени мал, оплата растёт заметно)

  ── 4. Режим отказа ──

  режим                кругов    время машины    время человека
  ──────────────────────────────────────────────────────────────
  быстрый отказ          1.46            48 с            8.0 мин
  полный список          1.39            70 с            7.6 мин

  быстрый отказ экономит 22 с машины и тратит 0.4 мин человека
  РЕШЕНИЕ: быстрый отказ ВНУТРИ задачи, полное выполнение МЕЖДУ задачами

  ── 7. Время прогона по событиям ──

  событие      шагов        время     ориентир   укладывается
  ────────────────────────────────────────────────────────────
  push             5        5.2 мин        5 мин            НЕТ
  pr               7       10.2 мин       10 мин            НЕТ
  main             9       11.3 мин       15 мин             да

  время до первого сообщения (быстрые параллельно): 40 с

═══ Требование 1: оптимальный порядок ═══
    лучший вариант: по возрастанию t/p
    разница с худшим: 45.6 с на прогон
  ✓ расчёт подтвердил сортировку по t/p

═══ Проверка оптимальности перебором ═══
    эвристика (сортировка t/p): 293.55 с
    полный перебор 120 вариантов: 293.55 с
    совпадают: да
  ✓ эвристика совпала с полным перебором — сортировка по t/p оптимальна

═══ Требование 2: зависимость сильнее оптимизации ═══
    сканирование: t/p = 2000 — меньше, чем у сборки (3600)
    по расчёту должно идти раньше, но зависит от сборки образа
    фактические позиции: сборка 5, сканирование 6
  ✓ сканирование дешевле сборки по t/p, но идёт после неё

═══ Требование 3: где параллельность не окупается ═══
  ✓ найден сценарий, где параллельность не окупается: три быстрые проверки

═══ Требование 4: режим отказа ═══
    решение: полное выполнение между независимыми задачами
  ✓ выбор обоснован имитацией на 30 000 прогонов, а не мнением

═══ Требование 5: пары, различающиеся порогом ═══
    пар с порогом: 3
  ✓ приведены пары, где одно измерение даёт разный ответ

═══ Требование 6: неидемпотентные действия ═══
    Публикация по latest               НЕТ            перезапись; заменить на неизменяемый тег
    Публикация по build-<номер>        НЕТ            два тега для одного кода; заменить на git-<SHA>
    Создание задачи в трекере          НЕТ            дубликат; нужен ключ идемпотентности
    Уведомление в чат                  НЕТ            повтор; слать только при смене состояния
    Увеличение версии в файле          НЕТ            версия растёт без изменения кода
    всего неидемпотентных: 5 из 8
  ✓ найдены неидемпотентные действия с предложенной заменой

═══ Требование 7: время прогона ═══
    событий в пределах ориентира: 1 из 3
    время до первого сообщения: 40 с
  ✗ в ориентир уложилось: 1 из 3

═══ ИТОГ ═══
  ЕСТЬ ПРОВАЛЫ

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

Проект не укладывается в ориентиры: push идёт 5,2 минуты при цели 5, pull request — 10,2 при цели 10. Превышение небольшое, но оно есть, и модель об этом сообщает вместо того, чтобы округлить в свою пользу.

Что с этим делать — предмет следующего урока: основной способ сократить время в CI — кэш сборки (урок 16.3). Сборка образа занимает 180 секунд из 312 на push; с кэшем она сокращается до десятков секунд, и обе цели становятся достижимы.

Три решения, определяющие качество.

Эвристика проверена полным перебором. Утверждение «сортировка по t/p оптимальна» — математическое, и его легко принять на веру. Перебор 120 перестановок на подмножестве из пяти шагов даёт то же значение с точностью до девятого знака. Это превращает утверждение в проверенное на данной модели.

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

Провал требования 7 оставлен провалом. Проще было бы поднять ориентир до 6 минут или уменьшить время сборки в модели. Модель, подогнанная под желаемый вывод, бесполезна. Превышение на 0,2 минуты названо, и указано, чем оно устраняется.

Чего решение не делает. Вероятности отказа взяты из типичных значений, а не измерены на конкретном проекте — расчёт следует повторить по своим данным, и порядок может оказаться другим. Модель параллельности предполагает одинаковое время подготовки для всех задач; в действительности задача с тяжёлыми зависимостями готовится дольше. Имитация режима отказа не учитывает, что разработчик, увидев ошибку форматирования, часто исправляет заодно и очевидные замечания линтера, — реальное число кругов меньше расчётного. Наконец, время прогона считается без учёта ожидания свободного исполнителя, которое в загруженной системе может превышать само время выполнения.

Проверка результата

bash
gh run list --limit 10 --json conclusion,createdAt,updatedAt \
    | python3 -c "import json,sys;print(len(json.load(sys.stdin)))"
gh run view --log | grep -c 'Error'
git log --oneline -1

Полезнее любых команд — измерить на своём проекте: время каждого этапа и долю прогонов, где он падает.

Типичные ошибки

ОшибкаПричинаИсправление
Оптимизируют общее время pipelineОно видно в интерфейсеВажнее время до первого сообщения
Порядок «по логике процесса»Кажется естественнымСортировать по время / вероятность отказа
Быстрый отказ между независимыми проверкамиЭкономит время машиныРазработчик делает несколько кругов
Параллелят всё подрядКажется быстрееКороткие задачи не окупают подготовку
Блокируют по уязвимости без исправления«Безопасность важнее»Pipeline постоянно красный; его обходят
Публикация по latest из pipelineПривычноЛомает идемпотентность и откат
Тег по номеру сборкиВыглядит уникальнымДва тега для одного кода
Не запускают сканирование по расписаниюКод же не менялсяУязвимость появляется после сборки
Игнорируют время ожидания исполнителяНе входит в отчётВ загруженной системе оно доминирует
Считают вероятности отказа «на глаз»Нет данныхИзмерить по истории прогонов

Контрольные вопросы

На понимание:

  1. Какую величину оптимизирует порядок этапов и почему не общее время?
  2. Почему порядок задаётся отношением время / вероятность отказа?
  3. Когда параллельный запуск проигрывает последовательному?
  4. Где нужен быстрый отказ, а где — полное выполнение?
  5. Почему уязвимость без исправления не должна блокировать сборку?

На применение:

  1. Как определить порядок этапов для своего проекта?
  2. Как сделать публикацию образа идемпотентной?
  3. Что запускать на push, что на pull request, что на main?

На диагностику:

  1. Pipeline красный третью неделю подряд, и все привыкли. Причина и что делать?
  2. Повторный запуск на том же коммите дал другой результат. Гипотезы?

Краткое резюме

  1. Оптимизируется ожидаемое время до первого сообщения об отказе, а не общее время.
  2. Порядок этапов задаётся возрастанием отношения время / вероятность отказа.
  3. Зависимости сильнее оптимизации: сканирование не может идти раньше сборки.
  4. Параллельность окупается, когда задачи заметно длиннее подготовки.
  5. Три коротких проверки параллельно дают малый выигрыш и заметный рост оплаты.
  6. Быстрый отказ — внутри задачи; между независимыми задачами всё доводится до конца.
  7. Блокирует то, по чему есть понятное действие.
  8. Уязвимость без исправления не блокирует: иначе красный перестаёт быть сигналом.
  9. Пороги превращают измерение в решение: −1 % покрытия и −15 % требуют разного.
  10. Идемпотентность ломают подвижные теги, внешнее состояние без ключа и счётчики.
  11. Основа идемпотентности — неизменяемый тег по commit SHA.
  12. Сканирование по расписанию нужно потому, что уязвимость появляется после сборки.

Официальные источники

ИсточникСсылкаЧто подтверждает
GitHub Actions: workflow syntaxhttps://docs.github.com/en/actions/reference/workflow-syntax-for-github-actionsЗадачи, зависимости, условия
GitHub Actions: needshttps://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idneedsЗависимости между задачами
GitHub Actions: fail-fasthttps://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fastРежим отказа в матрице
GitLab CI: stageshttps://docs.gitlab.com/ee/ci/yaml/#stagesГруппировка задач
GitLab CI: ruleshttps://docs.gitlab.com/ee/ci/yaml/#rulesУсловия выполнения
Docker: build cache in CIhttps://docs.docker.com/build/cache/backends/Ускорение сборки

Навигация

Вернуться к разделу
Следующий материал → GitHub Actions
Главное оглавление

Markdown на GitHub ↗