2 lines
16 KiB
JSON
2 lines
16 KiB
JSON
{"index":26,"slug":"editorial-2027-04-mechanism-build-evolution","title":"Кэш frontend-сборки: как ключ сохраняет или скрывает устаревший результат","excerpt":"Разбираем, какие входы должны формировать ключ кэша сборки, почему cache hit не доказывает свежесть артефакта и как проверить отрицательный путь.","contentHtml":"<p>Сборка внезапно стала медленной, хотя в CI почти каждый запуск сообщает <code>cache hit</code>. В другом случае job проходит за секунды, но после изменения lockfile приложение получает старый bundle. Эти симптомы похожи на проблему производительности, но цена ошибки выше: команда либо платит временем за постоянные промахи, либо публикует артефакт, который не соответствует исходникам.</p><p>Тезис простой: кэш повторяет результат функции от конкретного набора входов. Ключ должен меняться, когда меняется любой вход, влияющий на dependency graph, transform или output. Cache hit подтверждает только совпадение ключа. Он не подтверждает полноту ключа, корректность публикации и соответствие source map.</p><h2>Механизм: кэш повторяет вычисление, а не «проект»</h2><p>Bundler читает исходники, lockfile, конфигурацию, плагины и окружение. Затем он строит граф модулей и сохраняет промежуточные или итоговые данные. При следующем запуске он вычисляет ключ и решает, можно ли использовать сохранённый результат. Если ключ содержит мало данных, система не видит устаревание. Если ключ содержит случайные данные, система не видит повторение.</p><p>У ключа есть три свойства. Он должен быть детерминированным: одинаковые нормализованные входы дают одинаковое значение. Он должен быть чувствительным: изменение значимого входа меняет значение. Он должен быть ограниченным: в него не попадают timestamp, случайный UUID и абсолютный путь, если они не влияют на output. Иначе кэш либо выдаёт ложный hit, либо превращает каждый запуск в miss.</p><p>Минимальный набор зависит от инструмента. Для dependency pre-bundling важны lockfile, patches, релевантная конфигурация и среда выполнения. Для файлового кэша webpack дополнительно важны режим, каталог и сериализация. Для linked dependency нужно проверить, как bundler разрешает symlink и когда повторяет оптимизацию. Нельзя перенести список входов из одного toolchain в другой без проверки его семантики.</p><figure><img src='/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg' alt='Матрица ключа кэша frontend-сборки: lockfile, конфигурация, runtime, исходный digest и каталог кэша ведут к проверке результата.' loading='lazy' /><figcaption>Ключ связывает входы с результатом, но не заменяет проверку output. Изменение значимого входа должно вести к invalidation.</figcaption></figure><h2>Симптом → причина → проверка → действие</h2><div class='table-scroll'><table><caption>Диагностика кэша сборки</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Каждый запуск — miss</td><td>Ключ включает время или нестабильный путь</td><td>Сравнить ключ двух запусков без изменения исходников</td><td>Нормализовать входы и убрать шумные поля</td></tr><tr><td>Hit после изменения lockfile</td><td>Lockfile не входит в ключ</td><td>Изменить только lockfile и записать key до/после</td><td>Добавить digest lockfile и проверить invalidation</td></tr><tr><td>Разные job видят чужой output</td><td>Общий каталог без namespace</td><td>Сопоставить key, runner, права и location</td><td>Разделить namespace или ограничить общий кэш</td></tr><tr><td>Bundle новый, source map старая</td><td>Кэшируются связанные артефакты с разными условиями</td><td>Сверить bundle, map и commit в одном job</td><td>Публиковать согласованную пару или остановить выпуск</td></tr><tr><td>Linked package не меняется</td><td>Dev-server использует сохранённый pre-bundle</td><td>Изменить linked dependency и проверить повторную оптимизацию</td><td>Применить документированный force/re-bundle и уточнить watch-контракт</td></tr></tbody></table></div><h2>Учебный пример ключа</h2><p>Ниже функция показывает прозрачный способ собрать ключ из четырёх строк. Пример учебный: он не знает формат конфигурации вашего bundler, не запускает сборку и не доказывает результат в production. Его проверяемое свойство — изменение lockfile меняет ключ, а повтор тех же входов сохраняет его.</p><pre><code>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</code></pre><p>Функция использует фиксированный порядок полей и разделитель строк перед вычислением SHA-256. Реальный проект должен определить полный набор входов отдельно. Если plugin меняет transform, его версия или нормализованная конфигурация должны участвовать в digest. Если runtime меняет ABI или формат сериализации, одной версии Node может быть мало.</p><p>Обратный путь важнее положительного. Если ключ совпал, но output не соответствует commit, нельзя лечить симптом постоянным <code>force</code>. Сначала нужно установить, какой вход пропущен, где лежит чужой результат и какой job его записал. Если причина неизвестна, безопасное действие — остановить публикацию или очистить ограниченный namespace, а затем добавить диагностический вывод. Принудительная инвалидизация скрывает дефект ключа и вернёт его после следующего изменения.</p><h2>Cache hit требует второй проверки</h2><p>После hit проверьте не только exit code. Сверьте digest bundle, source map, список chunks и commit, из которого построен артефакт. Если сборка публикует manifest, сравните его с фактическими файлами. Наличие файла в каталоге кэша не означает, что job использовал его целиком: bundler мог восстановить часть данных и пересобрать остальное.</p><p>Разделяйте cold и warm режимы. Cold run показывает стоимость работы без сохранённого результата. Warm run показывает выигрыш при совпадении условий. Эти числа отвечают на разные вопросы. Не смешивайте время установки зависимостей, bundling, minify и upload, если измеряете только сборку. Не переносите локальный hit на CI: другой Node, runner, каталог или права меняют результат.</p><p>Для Vite linked dependency является отдельной границей. Локальный пакет может разрешаться не так, как опубликованная зависимость. Изменение файла не обязано автоматически менять pre-bundle. Проверяйте документированное поведение dependency optimizer и режим повторной оптимизации. Для webpack memory cache живёт в процессе, а filesystem cache переживает запуски. У них разная стоимость, область действия и диагностика.</p><h2>Порядок проверки</h2><ol><li>Выпишите входы, которые меняют граф зависимостей, transform, target, формат output или правила публикации.</li><li>Нормализуйте конфигурацию и значения окружения. Зафиксируйте lockfile, runtime, bundler, платформу, каталог и namespace.</li><li>Соберите deterministic key. Уберите timestamp, случайные значения и абсолютные пути, если они не меняют результат.</li><li>Проверьте положительный путь: одинаковые входы дают hit и одинаковые digest bundle, map и manifest.</li><li>Проверьте отрицательный путь по одному изменению: lockfile, конфигурация, runtime, исходный модуль и linked package должны дать miss или документированную invalidation.</li><li>Запишите key, hit/miss, location, cold/warm режим и причину invalidation. Не выводите секреты и приватные исходники.</li><li>Запретите публикацию, если key совпал, а согласованность артефактов не доказана. Исправьте входы или границу кэша и повторите проверку.</li></ol><h2>Ограничения</h2><p>Hash строки не понимает смысл конфигурации. Два разных текста могут описывать одинаковое поведение и дать разные ключи. Обратная ситуация опаснее: один digest может не учитывать plugin, symlink, системную библиотеку или скрытый флаг. Поэтому формула ключа должна следовать реальным входам bundler, а не удобству реализации.</p><p>Общий файловый кэш зависит от прав, конкуренции job, срока хранения и способа очистки. Namespace защищает от смешения результатов, но не исправляет неполный ключ. Source map может не публиковаться в production, однако при диагностике её нужно сверять с тем же bundle и commit. Пользовательская скорость также не следует из cache hit: её проверяют отдельными браузерными и сетевыми измерениями.</p><h2>Проверяемый критерий готовности</h2><p>Механизм готов, если другой инженер может повторить два запуска на одинаковом входе и получить одинаковый key, затем изменить один значимый вход и увидеть ожидаемый miss или явную invalidation. После hit bundle, source map и manifest проходят проверку согласованности. В отчёте видны входы, runtime, location, состояние кэша и причина решения.</p><p>Если хотя бы один изменённый вход сохраняет старый output без объяснённого контракта, кэш нельзя считать корректным. Если система не показывает причину hit или miss, сначала добавьте наблюдаемость. Только после этого сравнивайте секунды и решайте, оправдывает ли ускорение сложность хранения.</p><h2>Проверяемые источники</h2><ul><li><a href='https://vite.dev/guide/dep-pre-bundling.html' target='_blank' rel='noopener noreferrer'>Vite Guide: Dependency Pre-Bundling</a> — официальное руководство описывает cache dependency optimizer, invalidation по lockfile и конфигурации, а также отдельные случаи linked dependency. Оно не является контрактом другого bundler.</li><li><a href='https://webpack.js.org/configuration/cache/' target='_blank' rel='noopener noreferrer'>webpack 5 Configuration: cache</a> — официальная справка различает memory и filesystem cache и их параметры. Она не определяет права и каталог конкретного CI.</li><li><a href='https://webpack.js.org/guides/caching/' target='_blank' rel='noopener noreferrer'>webpack 5 Guide: Caching</a> — официальное руководство связывает кэширование с условиями сборки и стабильностью output. Оно не доказывает свежесть артефакта в конкретном проекте.</li></ul>"}
|