Все посты

От 10 минут до 15 секунд: инкрементальные сборки в монорепе из 125 пакетов

5 марта 2026 г.8 мин чтенияKB Labs
Share

При 125 пакетах полный pnpm -r run build занимает около десяти минут. Для CI — нормально. Для разработчика, который поменял одну строку в библиотеке утилит и хочет увидеть результат — нет. Мы написали kb-devkit — Go-бинарь — чтобы это исправить.

Почему не Turborepo?

Turborepo хорошо делает своё дело. Но у нашей монорепы есть особенности, ради которых кастомное решение оказалось оправданным:

  • Go-бинари живут рядом с TypeScript-пакетами — у них нет package.json, и npm-ориентированные инструменты их просто не видят
  • Нам нужны упорядоченные категории (библиотеки перед приложениями, Go перед Node) с семантикой «первый совпавший выигрывает»
  • Хотелось content-addressable кэша под полным нашим контролем, а не удалённого кэша с оплатой
  • Оценка здоровья воркспейса (покрытие линтером, отсутствующие конфиги) — фича, которую не предоставляет ни один существующий инструмент

Как работает kb-devkit

Обнаружение пакетов

Вместо обхода всей файловой системы kb-devkit разворачивает паттерны из pnpm-workspace.yaml уровень за уровнем через os.ReadDir(). Сложность — O(dirs_per_level × depth) вместо O(total_files). Ограничение maxDepth: 3 предотвращает бесконечную рекурсию.

Каждый найденный пакет классифицируется в категорию по совпадению относительного пути с glob-паттернами. Категории — упорядоченный список, первое совпадение выигрывает. Это важно, потому что паттерны ts-app и ts-lib пересекаются, а библиотеки должны собираться раньше приложений.

# devkit.yaml — категории (порядок важен)
categories:
  - name: ts-lib
    match: ['core/*/packages/**', 'plugins/*/packages/**']
  - name: ts-app
    match: ['core/*/apps/**', 'plugins/*/apps/**']
  - name: go-binary
    match: ['tools/kb-dev', 'tools/kb-devkit']  # литеральные пути, без package.json

Планирование через DAG

kb-devkit строит направленный ациклический граф из пар (пакет, задача) с помощью алгоритма Кана. Спецификация зависимости deps: ["^build"] означает «сначала выполни build для всех зависимостей из воркспейса». Планировщик выполняет слой за слоем: все задачи внутри слоя идут параллельно (до NumCPU - 1), но слои — последовательно, чтобы соблюдать порядок.

Content-addressable кэш

Вот откуда берётся цифра в 15 секунд. Перед запуском задачи kb-devkit вычисляет SHA256-хэш по всем входным файлам (отсортированным, с автоматическим исключением node_modules, .git, dist, coverage). Если хэш совпадает с кэшированным манифестом, результаты мгновенно восстанавливаются из content-addressable хранилища в .kb/devkit/objects/.

# Вычисление ключа кэша (упрощённо)
hash = SHA256(
  for each file matching task.inputs:
    relative_path + '\x00' + file_content + '\x00'
)
 
# Структура хранилища
.kb/devkit/
  objects/<ab>/<cdef...>            # контент-адресуемые блобы
  tasks/<pkg>/<task>/<hash>.json    # манифесты (код выхода, stdout, ссылки на файлы)

У каждой задачи независимый кэш. Запуск build не заполняет кэш lint — у них разные входы и выходы. Одинаковые выходные файлы из разных пакетов дедуплицируются (хранятся по одному экземпляру по хэшу содержимого).

Неочевидные края

  • Go-пакеты без package.json. В воркспейсе три Go-бинари (kb-devkit, kb-dev, kb-create). Их нельзя обнаружить через npm workspace protocol. Решение: сопоставление литеральных путей в категориях, минуя проверку hasPackageJSON.
  • Итерация по map в Go недетерминирована. Категории должны вычисляться в порядке объявления (первый совпавший выигрывает), но Go итерирует map в случайном порядке. Решение: кастомный yaml.Node UnmarshalYAML для сохранения порядка.
  • Порядок DTS. Файлы TypeScript-деклараций должны эмититься в порядке зависимостей — пакет downstream не может пройти type-check, пока не существуют .d.ts файлы его зависимостей. Именно поэтому pnpm -r run build нередко выдаёт ошибки типов, которые исчезают при повторном запуске. DAG-планировщик решает это по конструкции.

Результаты

Полная холодная сборка: ~158 пакетов за 26 секунд (Go-бинарь, параллельные слои, без кэша). Инкрементальная пересборка после изменения одного пакета: 2–15 секунд в зависимости от fan-out зависимостей. Худший случай в 15 секунд — изменение core-types, которое тригерит пересборку большей части графа.

В типичном случае — изменение исходников плагина — это меньше 3 секунд. Разница между «сохранил и проверил» и «сохранил, пошёл за кофе, проверил».

От 10 минут до 15 секунд: инкрементальные сборки в монорепе из 125 пакетов