{"index":26,"slug":"editorial-2027-04-mechanism-build-evolution","title":"Кэш frontend-сборки: как ключ сохраняет или скрывает устаревший результат","excerpt":"Разбираем, какие входы должны формировать ключ кэша сборки, почему cache hit не доказывает свежесть артефакта и как проверить отрицательный путь.","contentHtml":"

Сборка внезапно стала медленной, хотя в CI почти каждый запуск сообщает cache hit. В другом случае job проходит за секунды, но после изменения lockfile приложение получает старый bundle. Эти симптомы похожи на проблему производительности, но цена ошибки выше: команда либо платит временем за постоянные промахи, либо публикует артефакт, который не соответствует исходникам.

Тезис простой: кэш повторяет результат функции от конкретного набора входов. Ключ должен меняться, когда меняется любой вход, влияющий на dependency graph, transform или output. Cache hit подтверждает только совпадение ключа. Он не подтверждает полноту ключа, корректность публикации и соответствие source map.

Механизм: кэш повторяет вычисление, а не «проект»

Bundler читает исходники, lockfile, конфигурацию, плагины и окружение. Затем он строит граф модулей и сохраняет промежуточные или итоговые данные. При следующем запуске он вычисляет ключ и решает, можно ли использовать сохранённый результат. Если ключ содержит мало данных, система не видит устаревание. Если ключ содержит случайные данные, система не видит повторение.

У ключа есть три свойства. Он должен быть детерминированным: одинаковые нормализованные входы дают одинаковое значение. Он должен быть чувствительным: изменение значимого входа меняет значение. Он должен быть ограниченным: в него не попадают timestamp, случайный UUID и абсолютный путь, если они не влияют на output. Иначе кэш либо выдаёт ложный hit, либо превращает каждый запуск в miss.

Минимальный набор зависит от инструмента. Для dependency pre-bundling важны lockfile, patches, релевантная конфигурация и среда выполнения. Для файлового кэша webpack дополнительно важны режим, каталог и сериализация. Для linked dependency нужно проверить, как bundler разрешает symlink и когда повторяет оптимизацию. Нельзя перенести список входов из одного toolchain в другой без проверки его семантики.

Матрица ключа кэша frontend-сборки: lockfile, конфигурация, runtime, исходный digest и каталог кэша ведут к проверке результата.
Ключ связывает входы с результатом, но не заменяет проверку output. Изменение значимого входа должно вести к invalidation.

Симптом → причина → проверка → действие

Диагностика кэша сборки
СимптомПричинаПроверкаДействие
Каждый запуск — missКлюч включает время или нестабильный путьСравнить ключ двух запусков без изменения исходниковНормализовать входы и убрать шумные поля
Hit после изменения lockfileLockfile не входит в ключИзменить только lockfile и записать key до/послеДобавить digest lockfile и проверить invalidation
Разные job видят чужой outputОбщий каталог без namespaceСопоставить key, runner, права и locationРазделить namespace или ограничить общий кэш
Bundle новый, source map стараяКэшируются связанные артефакты с разными условиямиСверить bundle, map и commit в одном jobПубликовать согласованную пару или остановить выпуск
Linked package не меняетсяDev-server использует сохранённый pre-bundleИзменить linked dependency и проверить повторную оптимизациюПрименить документированный force/re-bundle и уточнить watch-контракт

Учебный пример ключа

Ниже функция показывает прозрачный способ собрать ключ из четырёх строк. Пример учебный: он не знает формат конфигурации вашего bundler, не запускает сборку и не доказывает результат в production. Его проверяемое свойство — изменение lockfile меняет ключ, а повтор тех же входов сохраняет его.

import { makeDependencyCacheKey } from './upgrade-2027-04.mjs';\n\nconst base = {\n  lockfile: 'lock-v1',\n  config: 'target=es2022;minify=true',\n  runtime: 'node-24',\n  sourceDigest: 'src-001',\n};\n\nconst first = makeDependencyCacheKey(base);\nconst repeat = makeDependencyCacheKey({ ...base });\nconst afterLockfileChange = makeDependencyCacheKey({\n  ...base,\n  lockfile: 'lock-v2',\n});\n\nconsole.log(first === repeat); // true\nconsole.log(first === afterLockfileChange); // false

Функция использует фиксированный порядок полей и разделитель строк перед вычислением SHA-256. Реальный проект должен определить полный набор входов отдельно. Если plugin меняет transform, его версия или нормализованная конфигурация должны участвовать в digest. Если runtime меняет ABI или формат сериализации, одной версии Node может быть мало.

Обратный путь важнее положительного. Если ключ совпал, но output не соответствует commit, нельзя лечить симптом постоянным force. Сначала нужно установить, какой вход пропущен, где лежит чужой результат и какой job его записал. Если причина неизвестна, безопасное действие — остановить публикацию или очистить ограниченный namespace, а затем добавить диагностический вывод. Принудительная инвалидизация скрывает дефект ключа и вернёт его после следующего изменения.

Cache hit требует второй проверки

После hit проверьте не только exit code. Сверьте digest bundle, source map, список chunks и commit, из которого построен артефакт. Если сборка публикует manifest, сравните его с фактическими файлами. Наличие файла в каталоге кэша не означает, что job использовал его целиком: bundler мог восстановить часть данных и пересобрать остальное.

Разделяйте cold и warm режимы. Cold run показывает стоимость работы без сохранённого результата. Warm run показывает выигрыш при совпадении условий. Эти числа отвечают на разные вопросы. Не смешивайте время установки зависимостей, bundling, minify и upload, если измеряете только сборку. Не переносите локальный hit на CI: другой Node, runner, каталог или права меняют результат.

Для Vite linked dependency является отдельной границей. Локальный пакет может разрешаться не так, как опубликованная зависимость. Изменение файла не обязано автоматически менять pre-bundle. Проверяйте документированное поведение dependency optimizer и режим повторной оптимизации. Для webpack memory cache живёт в процессе, а filesystem cache переживает запуски. У них разная стоимость, область действия и диагностика.

Порядок проверки

  1. Выпишите входы, которые меняют граф зависимостей, transform, target, формат output или правила публикации.
  2. Нормализуйте конфигурацию и значения окружения. Зафиксируйте lockfile, runtime, bundler, платформу, каталог и namespace.
  3. Соберите deterministic key. Уберите timestamp, случайные значения и абсолютные пути, если они не меняют результат.
  4. Проверьте положительный путь: одинаковые входы дают hit и одинаковые digest bundle, map и manifest.
  5. Проверьте отрицательный путь по одному изменению: lockfile, конфигурация, runtime, исходный модуль и linked package должны дать miss или документированную invalidation.
  6. Запишите key, hit/miss, location, cold/warm режим и причину invalidation. Не выводите секреты и приватные исходники.
  7. Запретите публикацию, если key совпал, а согласованность артефактов не доказана. Исправьте входы или границу кэша и повторите проверку.

Ограничения

Hash строки не понимает смысл конфигурации. Два разных текста могут описывать одинаковое поведение и дать разные ключи. Обратная ситуация опаснее: один digest может не учитывать plugin, symlink, системную библиотеку или скрытый флаг. Поэтому формула ключа должна следовать реальным входам bundler, а не удобству реализации.

Общий файловый кэш зависит от прав, конкуренции job, срока хранения и способа очистки. Namespace защищает от смешения результатов, но не исправляет неполный ключ. Source map может не публиковаться в production, однако при диагностике её нужно сверять с тем же bundle и commit. Пользовательская скорость также не следует из cache hit: её проверяют отдельными браузерными и сетевыми измерениями.

Проверяемый критерий готовности

Механизм готов, если другой инженер может повторить два запуска на одинаковом входе и получить одинаковый key, затем изменить один значимый вход и увидеть ожидаемый miss или явную invalidation. После hit bundle, source map и manifest проходят проверку согласованности. В отчёте видны входы, runtime, location, состояние кэша и причина решения.

Если хотя бы один изменённый вход сохраняет старый output без объяснённого контракта, кэш нельзя считать корректным. Если система не показывает причину hit или miss, сначала добавьте наблюдаемость. Только после этого сравнивайте секунды и решайте, оправдывает ли ускорение сложность хранения.

Проверяемые источники

"}