Привет! Меня зовут Никита, я старший фронтенд-инженер в Ozon Tech, и я разрабатываю кабинет рекламодателя. Однажды мы попытались обновить версию Node.js, и у нас начали рандомно падать тесты в CI/CD. Как выяснилось позже — из-за нехватки памяти. Так как над нашим проектом трудятся 15 фронтенд-разработчиков, эта проблема сильно замедляла процесс выкатки, и разработчикам приходилось вручную перезапускать тесты, пока они не начинали проходить, что также ухудшало developer experience.
Мы быстро решили проблему откаткой версии, но хотелось докопаться до того, из-за чего это произошло. В этой статье мы увидим, как минорное обновление версии сможет породить баг, который затянется на два года и вовлечёт в себя команды Jest, Node.js и V8.
Как всё начиналось
Нам всегда хочется иметь актуальные технологии, чтобы код мог выполняться быстрее, и чтобы мы могли использовать все современные особенности наших библиотек. Освободившись от натиска бизнесовых задач, мы решили в рамках техдолга обновить версию Node.js с 16.10 на 18, так как поддержка 16-ой версии ноды уже подходила к концу.
Мы заметили прирост в скорости билда и выполнения тестов, но некоторые джобы с тестами периодически стали падать из-за того, что у раннера кончалась память, а так как у нас написано почти 3000 unit-тестов, для нас это стало серьёзной проблемой.
Как воспроизвести баг
Чтобы воспроизвести проблему, нам понадобятся Jest версии 27.x и Node.js версии 16.11. Запускаем тесты с флагами node --expose-gc ./node_modules/.bin/jest --logHeapUsage и видим, как потребление памяти начинает расти.
Давайте разберёмся, что же стало причиной этой проблемы.
Проблемы с Jest
Раньше Jest выполнял код вот так:
runSourceText(sourceText, filename) { return this.global.eval(sourceText + '\n//# sourceURL=' + filename); }
Он мог потреблять только commonjs-модули и вызывать eval этого скрипта в среде, в которой запускался. Но с течением времени многое поменялось, и теперь для запуска скриптов Jest использует виртуальную машину в Node.js, и код, который запускает тесты, теперь выглядит так:
private createScriptFromCode(scriptSource: string, filename: string) { try { const scriptFilename = this._resolver.isCoreModule(filename) ? `jest-nodejs-core-${filename}` : filename; return new Script(this.wrapCodeInModuleWrapper(scriptSource), { displayErrors: true, filename: scriptFilename, // @ts-expect-error: Experimental ESM API importModuleDynamically: async (specifier: string) => { invariant( runtimeSupportsVmModules, 'You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules', ); const context = this._environment.getVmContext?.(); invariant(context, 'Test environment has been torn down'); const module = await this.resolveModule( specifier, scriptFilename, context, ); return this.linkAndEvaluateModule(module); }, }); } catch (e) { throw handlePotentialSyntaxError(e); } }
Поскольку Jest перехватывает импорты модулей, чтобы они могли быть замоканы, Jest также вынужден передавать опцию importModuleDynamically, чтобы обрабатывать динамические импорты в коде. Как мы видим по коду, эта опция передаётся для всех скриптов, даже для тех, которые не используют динамические импорты. Давайте посмотрим, как это реализовано в Node.js.
Реализация в Node.js
Для обработки динамических импортов Node.js должна настраивать hostDefinedOptions, которые являются контекстом, содержащим информацию о том, где вызван динамический импорт. Они также являются полем referrer в спецификации:

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

В Node.js в качестве hostDefinedOptions используется Symbol из имени скрипта:
//https://github.com/nodejs/node/blob/5bea645e4b1ff9b740acf24cfb899fb099ba1065/lib/vm.js#L111 class Script extends ContextifyScript { constructor(code, options = kEmptyObject) { ... const hostDefinedOptionId = getHostDefinedOptionId(importModuleDynamically, filename); // Calling `ReThrow()` on a native TryCatch does not generate a new // abort-on-uncaught-exception check. A dummy try/catch in JS land // protects against that. try { // eslint-disable-line no-useless-catch super(code, filename, lineOffset, columnOffset, cachedData, produceCachedData, parsingContext, hostDefinedOptionId); } catch (e) { throw e; /* node-do-not-add-exception-line */ } ... } }
Далее vm.Script из Node.js преобразуется в v8::UnboundScript, в котором создаётся v8::internal::SharedFunctionInfo и затем сохраняется в таблицу кэша.
//https://github.com/nodejs/node/blob/main/src/node_contextify.cc#L1044 void ContextifyScript::New(const FunctionCallbackInfo<Value>& args) { ... // Инициализация ScriptOrigin Local<PrimitiveArray> host_defined_options = PrimitiveArray::New(isolate, loader::HostDefinedOptions::kLength); host_defined_options->Set( isolate, loader::HostDefinedOptions::kID, id_symbol); ScriptOrigin origin(filename, line_offset, // line offset column_offset, // column offset true, // is cross origin -1, // script id Local<Value>(), // source map URL false, // is opaque (?) false, // is WASM false, // is ES Module host_defined_options); ... // Инициализация UnboundScript MaybeLocal<UnboundScript> maybe_v8_script = ScriptCompiler::CompileUnboundScript(isolate, &source, compile_options); Local<UnboundScript> v8_script; if (!maybe_v8_script.ToLocal(&v8_script)) { errors::DecorateErrorStack(env, try_catch); no_abort_scope.Close(); if (!try_catch.HasTerminated()) try_catch.ReThrow(); TRACE_EVENT_END0(TRACING_CATEGORY_NODE2(vm, script), "ContextifyScript::New"); return; } contextify_script->set_unbound_script(v8_script); std::unique_ptr<ScriptCompiler::CachedData> new_cached_data; if (produce_cached_data) { new_cached_data.reset(ScriptCompiler::CreateCodeCache(v8_script)); } }
До версии 16.11 это не вызывало никаких проблем, но в этой версии была обновлена версия V8, которая начала обрабатывать эту опцию. В итоге, когда Jest передавал функцию importModuleDynamically, вызываемый скрипт не попадал в кэш, так как у него были другие hostDefinedOptions. Это приводило к лишним затратам времени на выполнение, а также этот скрипт попадал в кэш со старым исходным кодом и с новыми hostDefinedOptions, что и приводило к утечке памяти.
Реализация в V8
Когда V8 компилирует скрипт, он создаёт внутреннюю структуру SharedFunctionInfo, которая является внутренним представлением скрипта.

Это нужно, чтобы передавать информацию о скрипте в разные его инстансы. И как раз SharedFunctionInfo и хранится в кэше. До добавления обработки hostDefinedOptions, кэш проверял, что данные импортируемого скрипта совпадают с данными скрипта в кэше и проблем с кэшем не было.
bool HasOrigin(Isolate* isolate, Handle<SharedFunctionInfo> function_info, const ScriptDetails& script_details) { Handle<Script> script = Handle<Script>(Script::cast(function_info->script()), isolate); // If the script name isn't set, the boilerplate script should have // an undefined name to have the same origin. Handle<Object> name; if (!script_details.name_obj.ToHandle(&name)) { return script->name().IsUndefined(isolate); } // Do the fast bailout checks first. if (script_details.line_offset != script->line_offset()) return false; if (script_details.column_offset != script->column_offset()) return false; // Check that both names are strings. If not, no match. if (!name->IsString() || !script->name().IsString()) return false; // Are the origin_options same? if (script_details.origin_options.Flags() != script->origin_options().Flags()) { return false; } // Compare the two name strings for equality. return String::Equals(isolate, Handle<String>::cast(name), Handle<String>(String::cast(script->name()), isolate)); }
Но после того как hostDefinedOptions начали обрабатываться, появились утечки памяти, так как hostDefinedOptions, передаваемые из Node.js, всегда были разными и скрипт не попадал в кэш.
Кроме этого, в V8 не совсем правильно обрабатывается referer динамически импортируемого скрипта, что может приводить к интересным багам при использовании динамических импортов с относительными путями.
За ходом исправления динамических импортов в V8 вы можете следить тут.
Решение проблемы
Проблему вызвалась решить https://github.com/joyeecheung из команды Node.js , так как в V8 исправить этот баг сложнее, потому что изменения затронут не только виртуальную машину Node.js, но и реально работающий код.
Первая часть решения была для случаев, когда importModuleDynamically не использовался, решение заключалось в том чтобы передавать Symbol в качестве hostDefinedOptions, фикс был добавлен тут. Но, как мы помним, Jest всегда передаёт эту опцию, для чего во второй части решения проблемы было добавлено возвращение другого Symbol в качестве hostDefinedOptions, когда опция –expiremental-vm-modules, которая позволяет использовать динамические импорты в vm Node.js, была отключена и динамический импорт не вызывался. В случае если динамический импорт будет вызван — этот Symbol приводил бы к ошибке. Данный фикс был добавлен тут.
Изменения дошли до нас аж в версии 20.8.0, и только тогда наша команда смогла обновить версию Node.js, а команда Jest смогла закрыть ишью с почти 500 сообщениями .
Что в итоге
Простая задача по обновлению версии Node.js обернулась болью для команд с большим количеством тестов: кто-то уменьшал количество воркеров, кто-то оптимизировал импорты, ведь чем больше импортов, тем больше кэш. В общем, всем пришлось как-то выкручиваться, чтобы тесты заново начали проходить. Помните, что как бы идеально не был написан ваш код, ваши инструменты могут создать вам и вашей команде проблемы, которые могут длиться несколько лет.
ссылка на оригинал статьи https://habr.com/ru/articles/933884/
Добавить комментарий